diff --git a/src/bot/bot.py b/src/bot/bot.py index 088c080..da5e423 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -473,9 +473,17 @@ def _claims_message(result) -> str: if trimmed: lines.append("") + # Un recorte grave no es un titular más pequeño, es uno ilegible: se + # separa para que no se pierda entre los cosméticos. + severe = [w for w in trimmed if w.get("severe")] lines.append(f"✂️ {len(trimmed)} textos recortados al dibujar:") for w in trimmed[:5]: - lines.append(f" • [{w.get('template', '?')}] {str(w.get('text', ''))[:60]}") + mark = "🔴 " if w.get("severe") else "" + lines.append(f" • {mark}[{w.get('template', '?')}] " + f"{str(w.get('text', ''))[:60]}") + if severe: + lines.append(f" 🔴 {len(severe)} quedaron ILEGIBLES (dibujados a menos " + "de la mitad): acorta ese texto y reenvía el spec.") if spoken: lines.append("") diff --git a/src/generator/shortspec.py b/src/generator/shortspec.py index 91b9f7d..0c71318 100644 --- a/src/generator/shortspec.py +++ b/src/generator/shortspec.py @@ -90,6 +90,12 @@ not a target. Aim for {target_min:.0f}-{target_max:.0f}. a card with four rows needs longer than a headline. - Every string is drawn as given. Write them the way they should appear: \ SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words. +- **"CABE ~N caracteres dibujados" is a width, and it is the one limit nothing \ +will catch for you.** Nothing rejects a longer string: the renderer shrinks the \ +type until it fits, so a quote of 58 characters in a 16-character slot is drawn \ +at a quarter of its size and ends up the smallest text on a frame it was \ +supposed to dominate. Stay at or under N. On a quote that means picking a \ +shorter verbatim span, never squeezing the whole sentence in. - Respect every max length and list-length limit above. They are enforced. - Colours are palette names ({colors}) — never hex. - Quotes carry the typographic quote marks: “SPLIT RADAR IMAGE”, with U+201C \ diff --git a/src/generator/spec_contract.py b/src/generator/spec_contract.py index 31963ee..42e35dc 100644 --- a/src/generator/spec_contract.py +++ b/src/generator/spec_contract.py @@ -406,6 +406,12 @@ def _describe_field(name: str, schema: dict, required: bool, defs: dict, bits.append("no vacío") if "maxLength" in schema: bits.append(f"máx {schema['maxLength']} caracteres") + # `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño, medido por + # shortsmith contra sus propias fuentes. No se valida — los caracteres son + # un proxy de los píxeles — pero es lo único que evita que el modelo escriba + # una cita de 58 caracteres en un hueco de 16 y salga dibujada ilegible. + if "x-fits" in schema: + bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados") for key, text in (("minimum", "≥"), ("maximum", "≤"), ("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")): if key in schema: diff --git a/tests/test_bot_short_report.py b/tests/test_bot_short_report.py index a8c1bef..c68f514 100644 --- a/tests/test_bot_short_report.py +++ b/tests/test_bot_short_report.py @@ -165,3 +165,21 @@ class TestNarrationWarnings: {"kind": "narration", "text": "no voice installed"}])) assert "1 textos recortados" in text # sólo cuenta el de dibujo assert "🔇 no voice installed" in text + + +class TestSevereShrink: + """Un recorte leve es cosmético; uno grave deja el texto ilegible justo + donde importaba. Mezclarlos entrena al lector a ignorar los dos.""" + + def test_a_severe_shrink_is_called_out(self): + text = _claims_message(result_with(render_warnings=[ + {"template": "document_quote", "text": "“UNA CITA MUY LARGA”", + "requested": 84, "size": 20, "severe": True}])) + assert "🔴" in text and "ILEGIBLES" in text + + def test_a_cosmetic_shrink_stays_quiet(self): + text = _claims_message(result_with(render_warnings=[ + {"template": "scale_bars", "text": "PHYSICAL EVIDENCE", + "requested": 92, "size": 84, "severe": False}])) + assert "recortados" in text + assert "ILEGIBLES" not in text and "🔴" not in text diff --git a/tests/test_spec_contract.py b/tests/test_spec_contract.py index b0da517..fb1b7ea 100644 --- a/tests/test_spec_contract.py +++ b/tests/test_spec_contract.py @@ -323,3 +323,27 @@ def test_narration_that_overshoots_the_target_is_flagged_as_narration(): def test_a_spec_without_narration_keeps_the_old_wording(): note = editorial_notes(spec_with(*[shot(duration=10.0) for _ in range(6)]))[0] assert "duración total" in note and "estimada" not in note + + +def test_the_prompt_carries_how_much_text_actually_fits(): + """`x-fits` es el único límite que nada rechaza: si no llega al prompt, el + modelo escribe una cita de 58 caracteres para un hueco de 16.""" + templates = {"document_quote": { + "type": "object", "required": ["quote_a"], + "properties": {"quote_a": {"type": "string", "minLength": 1, "x-fits": 16}}}} + + described = describe_templates(templates) + + assert "CABE ~16 caracteres" in described + + +def test_a_string_longer_than_it_fits_is_still_valid(): + """Los caracteres son un proxy de los píxeles: rechazar por ancho estimado + tiraría specs que se dibujan perfectamente.""" + templates = {"radar_sweep": { + "type": "object", "additionalProperties": False, "required": ["headline"], + "properties": {"headline": {"type": "string", "minLength": 1, "x-fits": 13}}}} + doc = spec_with({"template": "radar_sweep", "duration": 25.0, + "props": {"headline": "UN TITULAR BASTANTE MAS LARGO QUE ESO"}}) + + validate_spec(doc, templates)