From 6e4b3e1379114a0a4ac65cb06be8f73ca7985d8e Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Thu, 13 Aug 2026 21:47:07 +0000 Subject: [PATCH] fix(short): dos citas en el campo de una son una frase que nadie dijo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El comprobador de fundamento une las líneas de `quote` antes de buscarlas, y eso cerró la forma con la que falló Socorro en agosto (`366ded1`): `“LIKE ALUMINUM` + `SMOOTH, NO WINDOWS”` unidas son una sola frase, no aparece en ninguna fuente, y se rechaza. Pero la unión se derrota poniéndole a cada línea su propio par de comillas. Entonces son DOS citas, cada una fundamentada por su lado, y pasa en silencio mientras el fotograma dibuja la frase de nadie. Medido sobre los cinco Shorts generados hoy: le pasaba a dos. Se comprueba la FORMA, no el contenido — dos aperturas son dos citas, diga lo que diga la fuente — y se comprueba en `validate_spec`, o sea que cuesta un reintento y no una revisión humana. Es regla del canal, no de shortsmith: allí renderiza igual. Va como error duro y no como nota editorial porque el fallo que evita es el peor que tiene este sistema: una cita fabricada con material auténtico y atribuida a alguien con nombre y apellidos. La comprobación es universal por nombre de prop, no por plantilla: una plantilla nueva con un `quote` de varias líneas queda cubierta sin tocar nada. Co-Authored-By: Claude Opus 5 --- src/generator/spec_contract.py | 44 +++++++++++++++++++++++++++++++++- tests/test_spec_contract.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index 2f5f9cf..eb25330 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -232,6 +232,48 @@ def _track_map_waypoints_inside(props: dict, path: str) -> list[str]: return [] +#: Comillas de apertura. Una cita bien partida abre UNA vez. +_OPENING_QUOTES = "“«„‟" + + +def _quote_is_one_span(props: dict, path: str) -> list[str]: + """Una `quote` de varias líneas es UN span partido, no dos citas. + + Esta regla NO es de shortsmith: allí renderiza igual. Es del canal, y es de + las duras, porque el fallo que evita es el peor que tiene este sistema — + una cita fabricada con material auténtico y atribuida a una persona con + nombre y apellidos. + + El comprobador de fundamento ya une las líneas antes de buscarlas, y eso + cerró la forma con la que falló Socorro en agosto (`“LIKE ALUMINUM` + + `SMOOTH, NO WINDOWS”`): unidas son una sola frase, no aparece en ninguna + fuente, y se rechaza. Pero la unión se puede derrotar poniéndole a cada + línea su propio par de comillas: entonces son DOS citas, cada una + fundamentada por su lado, y pasa en silencio — mientras el fotograma dibuja + la frase de nadie. Le pasó a la sesión 162 el 2026-08-13 con + `“GRAY, LIKE ZINC”` + `“TWO SAUCERS GLUED AT THE RIM”`. + + Por eso se mira la FORMA y no el contenido: dos aperturas son dos citas, + diga lo que diga la fuente. + """ + quote = props.get("quote") + if not isinstance(quote, list) or len(quote) < 2: + return [] + joined = " ".join(str(line) for line in quote) + openings = sum(joined.count(glyph) for glyph in _OPENING_QUOTES) + if openings < 2: + return [] + return [f"{path}.quote: son {openings} citas, y este campo es UNA cita " + "partida en líneas — leídas seguidas forman una frase que nadie " + "dijo. Elige un solo span verbatim y pártelo donde tenga que " + "partirse, o quita las comillas y cuenta el hecho en llano"] + + +#: Comprobaciones que se aplican a TODAS las plantillas, por nombre de prop. Van +#: aparte de las de abajo para que una plantilla nueva con un campo `quote` de +#: varias líneas quede cubierta sin tocar nada — el mismo pacto que el contrato. +UNIVERSAL_CHECKS = [_quote_is_one_span] + #: template -> comprobaciones extra. Una plantilla sin entrada no tiene reglas #: cruzadas, que es el caso de casi todas. CROSS_FIELD_CHECKS = { @@ -244,7 +286,7 @@ def _check_cross_field(template: str, props: Any, path: str) -> list[str]: if not isinstance(props, dict): return [] errors: list[str] = [] - for check in CROSS_FIELD_CHECKS.get(template, ()): + for check in (*UNIVERSAL_CHECKS, *CROSS_FIELD_CHECKS.get(template, ())): errors.extend(check(props, path)) return errors diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index f6133c1..411bd56 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -246,6 +246,45 @@ class TestCrossFieldRules: "waypoints": [{"label": "A", "lat": 2.0, "lon": -1.0}]}}), templates) + def test_two_quotes_welded_into_one_field_are_rejected(self): + """El peor fallo del sistema: una frase que nadie dijo, hecha con + material auténtico y firmada por alguien con nombre y apellidos. + + El comprobador de fundamento une las líneas antes de buscarlas, y eso + caza la forma con la que falló Socorro. Pero la unión se derrota + poniéndole a cada línea su propio par de comillas: entonces son dos + citas, cada una fundamentada por su lado, y pasa en silencio. Caso real + de la sesión 162. + """ + errors = errors_of(spec_with(shot( + "scale_bars", duration=25.0, + bars=[{"label": "A", "value": 1}], + quote=["“GRAY, LIKE ZINC”", "“TWO SAUCERS GLUED AT THE RIM”"]))) + assert any("es UNA cita partida en líneas" in e for e in errors) + + def test_a_span_broken_across_lines_is_the_normal_case(self): + """La forma buena: abre en la primera línea y cierra en la última. Es + como está escrito el ejemplo de referencia, así que rechazarla rompería + el propio prompt.""" + validate_spec(spec_with(shot( + "scale_bars", duration=25.0, + bars=[{"label": "A", "value": 1}], + quote=["“TWICE THE SIZE OF", "AN AIRCRAFT CARRIER”"])), TEMPLATES) + + def test_a_quote_without_marks_is_left_alone(self): + validate_spec(spec_with(shot( + "scale_bars", duration=25.0, + bars=[{"label": "A", "value": 1}], + quote=["LANDING TRACE", "CONFIRMED BY LAB"])), TEMPLATES) + + def test_the_reference_example_survives_the_quote_rule(self): + """Si el ejemplo no pasara su propia regla, volveríamos a enseñar el + fallo que la regla intenta evitar.""" + spec = json.loads(EXAMPLE.read_text()) + permissive = {name: {"type": "object"} for name in + {s["template"] for s in spec["shots"]}} + validate_spec(spec, permissive) + def test_a_bad_schema_hides_the_cross_field_noise(self): """Con props mal tipadas, la regla cruzada diría algo que no es el fallo real y taparía el que sí lo es."""