fix(short): los textos ilegibles se avisaban con el render ya pagado
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m18s
La nota que teníamos era falsa: los avisos `severe` de shortsmith SÍ llegan a
un humano — el informe de Telegram los saca en rojo. Lo que pasa es que llegan
colgados del render TERMINADO, así que hacerles caso significa editar el spec a
mano y pagar un segundo. Por eso nadie actuó nunca.
`x-fits` tampoco podía ser la comprobación, y a propósito: es guía blanda que el
propio ejemplo de referencia se salta por uno o tres caracteres viéndose bien.
Parar ahí sería gritar con specs buenos, y un aviso que grita se ignora — que es
justo cómo sobrevivieron los graves. Así que shortsmith publica desde 289d50e un
segundo número medido por campo, `x-fits-hard`, y esto lo comprueba antes de
gastar el render: cuesta un reintento del modelo en vez de un render. El mismo
movimiento que hizo `MAX_CUE_CHARS` con los captions en e32c59f.
Auditado contra los 17 short_en de producción: OCHO llevan al menos un texto que
se dibuja ilegible.
- El ya conocido, Cash-Landrum: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH
RADIATION EXPOSURE`, 36 px pedidos y 20 dibujados. Salta con 63 caracteres
contra un presupuesto de 61 — así de vertical es la curva pegada al muro.
- El que nadie había visto es peor y más común: el CARTEL DE CIERRE.
`counter_close.lines` pide 110 px y se dibujó a 28 en el peor caso, y por
debajo de 64 en cinco de los ocho. Es la llamada a la acción, y en un tercio
del catálogo es el texto más pequeño del fotograma.
- Y la tolerancia aguanta sobre datos reales: `STILL UNEXPLAINED` encoge de 110
a 84 px y no se avisa, que es lo correcto.
El desempate entre intentos pasa a contar averías —gancho y texto ilegible—
antes que segundos, por la misma razón que en 6d9b602: una reescritura que
arregla un rótulo pero se pasa un segundo perdía contra la que no lo arreglaba.
Un campo sin presupuesto propio (`Bar.unit`, que se dibuja dentro de la cadena
de `value_label`) se salta a conciencia: reconstruir esa cadena aquí pediría
conocer el formato de la plantilla, que es lo que este repo no sabe ni debe.
Y contra un shortsmith anterior a 289d50e esto se calla — no puede inventarse el
número —, así que de que el contrato traiga el campo se encarga
test_shortsmith_live.py, que es quien habla con el servicio.
Suite: 274 pasan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -280,8 +280,34 @@ second long would lose to the attempt that opened with a date, and the note woul
|
|||||||
decoration.
|
decoration.
|
||||||
|
|
||||||
Still open on the generator side: `document_quote` is also weak as the *second* shot for
|
Still open on the generator side: `document_quote` is also weak as the *second* shot for
|
||||||
the same reason, and nothing yet reads the `severe: true` auto-fit warnings shortsmith
|
the same reason.
|
||||||
already emits (one real caption was drawn at 20 px).
|
|
||||||
|
### Text drawn unreadable — **done 2026-09-01**
|
||||||
|
|
||||||
|
The `severe: true` auto-fit warnings were never the problem we had written down. They
|
||||||
|
*do* reach a human: the Telegram report prints them in red with "quedaron ILEGIBLES".
|
||||||
|
They just arrive attached to the **finished render**, so acting on one means editing the
|
||||||
|
spec by hand and paying for a second one — which is why nobody ever did.
|
||||||
|
|
||||||
|
`x-fits` could not be the check either, and deliberately so: it is soft guidance the
|
||||||
|
reference example itself exceeds by a character or three while looking right. A check
|
||||||
|
there would fire on good specs and get ignored, which is how the genuinely bad ones
|
||||||
|
survived. So shortsmith now publishes a second measured number per field (`289d50e`):
|
||||||
|
`x-fits-hard`, the length past which auto-fit's shrink turns severe. `unreadable_notes()`
|
||||||
|
checks it before the render, at the cost of one model retry instead of one render —
|
||||||
|
the same move `MAX_CUE_CHARS` made for captions in `e32c59f`.
|
||||||
|
|
||||||
|
Audited against the seventeen `short_en` in production: **eight carry at least one text
|
||||||
|
that is drawn unreadable.** The one already known — Cash-Landrum's
|
||||||
|
`ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION EXPOSURE`, 36 px requested and
|
||||||
|
20 px drawn — is flagged at 63 characters against a budget of 61, which is how steep the
|
||||||
|
curve is near the wall. The one nobody had noticed is worse and more common: the
|
||||||
|
**closing card**, `counter_close.lines`, asks for 110 px and was drawn at **28** in the
|
||||||
|
worst case and under 64 in five of the eight. That is the call to action, and in a third
|
||||||
|
of the catalogue it is the smallest type on the frame.
|
||||||
|
|
||||||
|
The tolerance holds on real data too: `STILL UNEXPLAINED` is shrunk 110 → 84 px and is
|
||||||
|
correctly left alone.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+20
-14
@@ -23,7 +23,7 @@ import structlog
|
|||||||
|
|
||||||
from src.generator.spec_contract import (
|
from src.generator.spec_contract import (
|
||||||
SpecInvalid, describe_templates, editorial_notes, estimated_duration,
|
SpecInvalid, describe_templates, editorial_notes, estimated_duration,
|
||||||
max_words_in, opens_badly, validate_spec, NARRATION_ROUNDED_PAD,
|
defect_notes, max_words_in, validate_spec, NARRATION_ROUNDED_PAD,
|
||||||
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
|
NARRATION_SENTENCE_SILENCE, NARRATION_WORDS_PER_SECOND,
|
||||||
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
|
TARGET_MAX_DURATION, TARGET_MIN_DURATION,
|
||||||
)
|
)
|
||||||
@@ -136,12 +136,17 @@ headline. A {max_shot:.0f}-second shot with a short line on it is not a \
|
|||||||
generous shot, it is a shot the viewer has already finished reading.
|
generous shot, it is a shot the viewer has already finished reading.
|
||||||
- Every string is drawn as given. Write them the way they should appear: \
|
- Every string is drawn as given. Write them the way they should appear: \
|
||||||
SHORT, UPPERCASE, no trailing punctuation. A headline is 2-5 words.
|
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 \
|
- **"CABE ~N caracteres dibujados" is a width, not a character count you can \
|
||||||
will catch for you.** Nothing rejects a longer string: the renderer shrinks the \
|
argue with.** Nothing rejects a longer string: the renderer shrinks the type \
|
||||||
type until it fits, so a string at twice its budget is drawn at a fraction of \
|
until it fits, so a string at twice its budget is drawn at a fraction of its \
|
||||||
its size and ends up the smallest text on a frame it was supposed to dominate. \
|
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, \
|
Stay at or under N. On a quote that means picking a shorter verbatim span, \
|
||||||
never squeezing the whole sentence in.
|
never squeezing the whole sentence in.
|
||||||
|
- **"ILEGIBLE por encima de M" is the line that is actually checked**, before \
|
||||||
|
anything renders. Between N and M the text is drawn a little smaller and looks \
|
||||||
|
fine — that tolerance is deliberate. Past M it is not a smaller headline, it is \
|
||||||
|
an unreadable one: one real caption asked for 36 px and was drawn at 20 on a \
|
||||||
|
1080-wide frame. Aim at N; M is the wall.
|
||||||
- Respect every max length and list-length limit above. They are enforced.
|
- Respect every max length and list-length limit above. They are enforced.
|
||||||
- Colours are palette names ({colors}) — never hex.
|
- Colours are palette names ({colors}) — never hex.
|
||||||
- Quotes carry the typographic quote marks: “SPLIT RADAR IMAGE”, with U+201C \
|
- Quotes carry the typographic quote marks: “SPLIT RADAR IMAGE”, with U+201C \
|
||||||
@@ -461,24 +466,25 @@ def _off_target(spec: dict) -> float:
|
|||||||
|
|
||||||
def _closer_to_target(a: Optional[SpecResult], b: SpecResult,
|
def _closer_to_target(a: Optional[SpecResult], b: SpecResult,
|
||||||
templates: dict[str, dict]) -> SpecResult:
|
templates: dict[str, dict]) -> SpecResult:
|
||||||
"""De dos specs válidos, el mejor: primero el gancho, luego la duración.
|
"""De dos specs válidos, el mejor: primero las averías, luego la duración.
|
||||||
|
|
||||||
Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que
|
Antes se guardaba el PRIMERO válido y punto, con lo que una reescritura que
|
||||||
obedecía la nota a medias — 53 s en vez de 58 — se tiraba entera y salía el
|
obedecía la nota a medias — 53 s en vez de 58 — se tiraba entera y salía el
|
||||||
largo. El empate se lo lleva el anterior: sin razón para cambiar, no se
|
largo. El empate se lo lleva el anterior: sin razón para cambiar, no se
|
||||||
cambia.
|
cambia.
|
||||||
|
|
||||||
El gancho va delante de los segundos, y no por gusto: ordenar sólo por
|
Las averías van delante de los segundos, y no por gusto: ordenar sólo por
|
||||||
duración deja pasar el caso que hace inútil el aviso de apertura — un
|
duración deja pasar el caso que hace inútiles los avisos — un segundo
|
||||||
segundo intento que arregla el titular pero se pasa un segundo perdería
|
intento que arregla el titular, o acorta un rótulo que iba a salir
|
||||||
contra el primero, y el modelo habría obedecido la nota para nada. Los
|
ilegible, pero se pasa un segundo, perdería contra el primero, y el modelo
|
||||||
segundos fuera de objetivo son un gradiente; abrir con una fecha o con un
|
habría obedecido la nota para nada. Los segundos fuera de objetivo son un
|
||||||
plano que aún se está escribiendo es binario, y cuesta más.
|
gradiente; abrir con una fecha, o dibujar un texto que no se lee, es
|
||||||
|
binario y cuesta más.
|
||||||
"""
|
"""
|
||||||
if a is None:
|
if a is None:
|
||||||
return b
|
return b
|
||||||
def rank(r: SpecResult) -> tuple[bool, float]:
|
def rank(r: SpecResult) -> tuple[int, float]:
|
||||||
return (opens_badly(r.spec, templates), _off_target(r.spec))
|
return (len(defect_notes(r.spec, templates)), _off_target(r.spec))
|
||||||
return a if rank(a) <= rank(b) else b
|
return a if rank(a) <= rank(b) else b
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+122
-11
@@ -28,7 +28,8 @@ __all__ = [
|
|||||||
"validate_spec",
|
"validate_spec",
|
||||||
"editorial_notes",
|
"editorial_notes",
|
||||||
"opening_notes",
|
"opening_notes",
|
||||||
"opens_badly",
|
"unreadable_notes",
|
||||||
|
"defect_notes",
|
||||||
"estimated_duration",
|
"estimated_duration",
|
||||||
"spoken_seconds",
|
"spoken_seconds",
|
||||||
"sentence_count",
|
"sentence_count",
|
||||||
@@ -655,9 +656,113 @@ def opening_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def opens_badly(spec: dict, templates: dict[str, dict]) -> bool:
|
#: Cuántos textos ilegibles se nombran antes de resumir. Cinco caben en un
|
||||||
"""Si el gancho está mal. Lo usa el desempate entre intentos válidos."""
|
#: mensaje de Telegram y son de sobra para que el modelo entienda el patrón; la
|
||||||
return bool(opening_notes(spec, templates))
|
#: lista entera sólo entierra el consejo.
|
||||||
|
MAX_NAMED_UNREADABLE = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_of(node: Any) -> Optional[int]:
|
||||||
|
"""`x-fits-hard` del nodo, resuelto por si el campo es una lista de textos."""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return None
|
||||||
|
hard = node.get("x-fits-hard")
|
||||||
|
return hard if isinstance(hard, int) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _too_long(value: Any, node: Any, path: str) -> list[tuple[str, int, int]]:
|
||||||
|
"""(ruta, longitud, presupuesto) de cada cadena que pasa de `x-fits-hard`."""
|
||||||
|
hard = _budget_of(node)
|
||||||
|
if hard is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [(path, len(value), hard)] if len(value) > hard else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
# El presupuesto de una lista de textos es POR LÍNEA: el campo se dibuja
|
||||||
|
# como varias líneas, no como una cadena unida. Medirlo sobre el total
|
||||||
|
# avisaría de una cita bien partida en dos.
|
||||||
|
return [(f"{path}[{i}]", len(v), hard)
|
||||||
|
for i, v in enumerate(value)
|
||||||
|
if isinstance(v, str) and len(v) > hard]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def unreadable_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
||||||
|
"""Los textos que shortsmith va a dibujar ilegibles, dichos ANTES del render.
|
||||||
|
|
||||||
|
`x-fits` es guía blanda y está bien que lo sea: el ejemplo de referencia se
|
||||||
|
pasa de varios de sus propios presupuestos por uno o tres caracteres y se ve
|
||||||
|
bien. Parar ahí sería gritar con specs buenos. `x-fits-hard` es la otra
|
||||||
|
línea que shortsmith publica desde 289d50e: pasada ella la cadena no es un
|
||||||
|
titular más pequeño, es uno ilegible, y ahí sí hay algo que decir.
|
||||||
|
|
||||||
|
El caso real: `ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION
|
||||||
|
EXPOSURE` pidió 36 px y se dibujó a **20** en un fotograma de 1080 de ancho.
|
||||||
|
El aviso existía —shortsmith lo manda con `severe`, y el informe de Telegram
|
||||||
|
lo saca en rojo— pero salía del render TERMINADO, y hacerle caso significaba
|
||||||
|
editar el spec a mano y pagar un segundo render. Comprobarlo aquí cuesta un
|
||||||
|
reintento del modelo. Es exactamente el mismo movimiento que `MAX_CUE_CHARS`
|
||||||
|
hizo con los captions.
|
||||||
|
|
||||||
|
Nota editorial y no error de contrato, y a propósito: los caracteres son un
|
||||||
|
proxy de los píxeles y el número es una medida sobre inglés en mayúsculas
|
||||||
|
realista, así que una cadena estrecha puede pasarse de la cuenta y caber.
|
||||||
|
Rechazarla sería el error que `x-fits` evita a conciencia.
|
||||||
|
|
||||||
|
Los campos con `x-fits-part-of` se saltan: no tienen presupuesto propio
|
||||||
|
porque se dibujan dentro de la cadena de otro (`Bar.unit` va en
|
||||||
|
`f"{value} {unit}"`), y reconstruir esa cadena aquí pediría conocer el
|
||||||
|
formato de la plantilla, que es justo lo que este repo no sabe ni debe.
|
||||||
|
"""
|
||||||
|
found: list[tuple[str, int, int]] = []
|
||||||
|
for i, shot in enumerate(spec.get("shots") or []):
|
||||||
|
if not isinstance(shot, dict):
|
||||||
|
continue
|
||||||
|
schema = templates.get(shot.get("template")) if isinstance(templates, dict) else None
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
continue
|
||||||
|
defs = schema.get("$defs", {})
|
||||||
|
props = shot.get("props")
|
||||||
|
if not isinstance(props, dict):
|
||||||
|
continue
|
||||||
|
for name, value in props.items():
|
||||||
|
node = (schema.get("properties") or {}).get(name)
|
||||||
|
if not isinstance(node, dict) or "x-fits-part-of" in node:
|
||||||
|
continue
|
||||||
|
path = f"shots.{i}.{name}"
|
||||||
|
found.extend(_too_long(value, node, path))
|
||||||
|
# Listas de objetos: el presupuesto vive en el submodelo.
|
||||||
|
item = _resolve(node.get("items", {}), defs) if node.get("type") == "array" else {}
|
||||||
|
if item.get("type") == "object" and isinstance(value, list):
|
||||||
|
for k, entry in enumerate(value):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
for sub, sub_value in entry.items():
|
||||||
|
sub_node = (item.get("properties") or {}).get(sub)
|
||||||
|
if isinstance(sub_node, dict) and "x-fits-part-of" not in sub_node:
|
||||||
|
found.extend(_too_long(sub_value, sub_node,
|
||||||
|
f"{path}[{k}].{sub}"))
|
||||||
|
if not found:
|
||||||
|
return []
|
||||||
|
|
||||||
|
named = ", ".join(f"{path} ({n} caracteres, caben {hard})"
|
||||||
|
for path, n, hard in found[:MAX_NAMED_UNREADABLE])
|
||||||
|
rest = f" y {len(found) - MAX_NAMED_UNREADABLE} más" if len(found) > MAX_NAMED_UNREADABLE else ""
|
||||||
|
return [f"{len(found)} texto{'s' if len(found) > 1 else ''} se va{'n' if len(found) > 1 else ''} "
|
||||||
|
"a dibujar ILEGIBLE: el renderizador encoge lo que no cabe y a esta "
|
||||||
|
"longitud queda por debajo de la mitad del tamaño de diseño. Acorta "
|
||||||
|
f"{named}{rest}"]
|
||||||
|
|
||||||
|
|
||||||
|
def defect_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
||||||
|
"""Lo que está MAL, frente a lo que está fuera de objetivo.
|
||||||
|
|
||||||
|
La distinción decide dos cosas: qué se dice primero y, sobre todo, cómo se
|
||||||
|
eligen los intentos entre sí. Un vídeo que se pasa cinco segundos se ve; uno
|
||||||
|
que abre con una fecha o lleva un rótulo ilegible no se arregla durando
|
||||||
|
menos.
|
||||||
|
"""
|
||||||
|
return opening_notes(spec, templates) + unreadable_notes(spec, templates)
|
||||||
|
|
||||||
|
|
||||||
def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
||||||
@@ -667,9 +772,9 @@ def editorial_notes(spec: dict, templates: dict[str, dict]) -> list[str]:
|
|||||||
70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez;
|
70 s se ve, sólo que peor. Se le devuelve al modelo como comentario una vez;
|
||||||
si insiste, se renderiza igual antes que tirar la generación a la basura.
|
si insiste, se renderiza igual antes que tirar la generación a la basura.
|
||||||
"""
|
"""
|
||||||
# Primero el gancho: si el vídeo se abre mal, es lo que hay que arreglar
|
# Primero los defectos: si el vídeo abre mal o lleva un texto ilegible, eso
|
||||||
# antes que su duración.
|
# va antes que su duración, que es un objetivo y no una avería.
|
||||||
notes = opening_notes(spec, templates)
|
notes = defect_notes(spec, templates)
|
||||||
declared = _total_duration(spec)
|
declared = _total_duration(spec)
|
||||||
total = estimated_duration(spec)
|
total = estimated_duration(spec)
|
||||||
stretched = total > declared + 0.5
|
stretched = total > declared + 0.5
|
||||||
@@ -747,12 +852,18 @@ def _describe_field(name: str, schema: dict, required: bool, defs: dict,
|
|||||||
bits.append("no vacío")
|
bits.append("no vacío")
|
||||||
if "maxLength" in schema:
|
if "maxLength" in schema:
|
||||||
bits.append(f"máx {schema['maxLength']} caracteres")
|
bits.append(f"máx {schema['maxLength']} caracteres")
|
||||||
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño, medido por
|
# `x-fits` es cuánto texto cabe DIBUJADO al tamaño de diseño y `x-fits-hard`
|
||||||
# shortsmith contra sus propias fuentes. No se valida — los caracteres son
|
# dónde deja de leerse, los dos medidos por shortsmith contra sus propias
|
||||||
# un proxy de los píxeles — pero es lo único que evita que el modelo escriba
|
# fuentes. Ninguno se valida — los caracteres son un proxy de los píxeles —
|
||||||
# una cita de 58 caracteres en un hueco de 16 y salga dibujada ilegible.
|
# pero el segundo sí se comprueba antes de renderizar (`unreadable_notes`),
|
||||||
|
# así que se le enseñan los dos: el objetivo y la línea roja. Sin el segundo,
|
||||||
|
# el modelo lee "~16" como una sugerencia sin consecuencia y escribe 58.
|
||||||
if "x-fits" in schema:
|
if "x-fits" in schema:
|
||||||
bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados")
|
bits.append(f"CABE ~{schema['x-fits']} caracteres dibujados")
|
||||||
|
if "x-fits-hard" in schema:
|
||||||
|
bits.append(f"ILEGIBLE por encima de {schema['x-fits-hard']}")
|
||||||
|
if "x-fits-part-of" in schema:
|
||||||
|
bits.append(f"se dibuja dentro de {schema['x-fits-part-of']}, comparte su sitio")
|
||||||
for key, text in (("minimum", "≥"), ("maximum", "≤"),
|
for key, text in (("minimum", "≥"), ("maximum", "≤"),
|
||||||
("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")):
|
("exclusiveMinimum", ">"), ("exclusiveMaximum", "<")):
|
||||||
if key in schema:
|
if key in schema:
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ async def test_healthz_and_templates():
|
|||||||
assert schema.get("type") == "object", f"{name} no publica un esquema de objeto"
|
assert schema.get("type") == "object", f"{name} no publica un esquema de objeto"
|
||||||
assert "properties" in schema
|
assert "properties" in schema
|
||||||
|
|
||||||
|
# Los dos presupuestos de texto, en el contrato SERVIDO. `unreadable_notes`
|
||||||
|
# se calla contra un esquema que no los traiga —no puede inventarse el
|
||||||
|
# número—, así que un shortsmith anterior a 289d50e apagaría la comprobación
|
||||||
|
# entera sin un solo error. Esto es lo único que lo nota.
|
||||||
|
sin_tope = [
|
||||||
|
f"{name}.{prop}"
|
||||||
|
for name, schema in templates.items()
|
||||||
|
for owner in [schema, *(schema.get("$defs") or {}).values()]
|
||||||
|
for prop, node in (owner.get("properties") or {}).items()
|
||||||
|
if "x-fits" in node and "x-fits-hard" not in node
|
||||||
|
]
|
||||||
|
assert sin_tope == [], f"campos con x-fits pero sin x-fits-hard: {sin_tope}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_render_the_reference_example_end_to_end(tmp_path):
|
async def test_render_the_reference_example_end_to_end(tmp_path):
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ def test_the_prompt_states_what_the_opening_shot_has_to_do():
|
|||||||
assert "never the date" in prompt and "subline" in prompt
|
assert "never the date" in prompt and "subline" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_prompt_states_both_text_budgets():
|
||||||
|
"""El primero es el objetivo y el segundo la línea roja, y hacen falta los
|
||||||
|
dos: con sólo "CABE ~16" el modelo lee una sugerencia sin consecuencia y
|
||||||
|
escribe 58. Con sólo la línea roja, apunta a ella y todo sale encogido."""
|
||||||
|
w, _ = writer("{}")
|
||||||
|
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
|
||||||
|
|
||||||
|
assert "CABE ~13 caracteres dibujados" in prompt # del esquema
|
||||||
|
assert "ILEGIBLE por encima de 21" in prompt
|
||||||
|
assert "is the line that is actually checked" in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_includes_the_worked_example_in_full():
|
def test_prompt_includes_the_worked_example_in_full():
|
||||||
w, _ = writer("{}")
|
w, _ = writer("{}")
|
||||||
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
|
prompt = w.build_prompt("Caso X", "material", None, "X.TEST")
|
||||||
@@ -426,6 +438,23 @@ async def test_a_fixed_hook_beats_a_closer_duration():
|
|||||||
assert result.notes and "objetivo" in result.notes[0]
|
assert result.notes and "objetivo" in result.notes[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_shortened_label_also_beats_a_closer_duration():
|
||||||
|
"""El mismo desempate que el gancho, por la otra avería: un rótulo que se iba
|
||||||
|
a dibujar ilegible no se arregla durando menos."""
|
||||||
|
ilegible = json.loads(json.dumps(GOOD))
|
||||||
|
ilegible["shots"][0]["props"]["subline"] = "X" * 60 # 30 s: en objetivo
|
||||||
|
corto = json.loads(json.dumps(GOOD))
|
||||||
|
corto["shots"][0]["props"]["subline"] = "SOCORRO, NEW MEXICO"
|
||||||
|
for shot in corto["shots"]:
|
||||||
|
shot["duration"] = 24.0 # 48 s: se pasa
|
||||||
|
w, _ = writer(json.dumps(ilegible), json.dumps(corto))
|
||||||
|
|
||||||
|
result = await w.write("Caso X", "material")
|
||||||
|
|
||||||
|
assert result.spec["shots"][0]["props"]["subline"] == "SOCORRO, NEW MEXICO"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_note_does_not_eat_the_attempt_the_contract_needs():
|
async def test_a_note_does_not_eat_the_attempt_the_contract_needs():
|
||||||
"""Si la reescritura sale malformada, aún queda un intento para arreglarla."""
|
"""Si la reescritura sale malformada, aún queda un intento para arreglarla."""
|
||||||
|
|||||||
+118
-11
@@ -13,7 +13,7 @@ import pytest
|
|||||||
|
|
||||||
from src.generator.spec_contract import (
|
from src.generator.spec_contract import (
|
||||||
SpecInvalid, describe_templates, editorial_notes, opening_notes,
|
SpecInvalid, describe_templates, editorial_notes, opening_notes,
|
||||||
validate_spec,
|
unreadable_notes, validate_spec,
|
||||||
)
|
)
|
||||||
|
|
||||||
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
|
EXAMPLE = Path(__file__).resolve().parents[1] / "src/generator/examples/jal1628.json"
|
||||||
@@ -23,8 +23,10 @@ TEMPLATES = {
|
|||||||
"type": "object", "additionalProperties": False,
|
"type": "object", "additionalProperties": False,
|
||||||
"required": ["headline"],
|
"required": ["headline"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"headline": {"type": "string", "minLength": 1},
|
"headline": {"type": "string", "minLength": 1,
|
||||||
"subline": {"type": "string", "default": ""},
|
"x-fits": 13, "x-fits-hard": 21},
|
||||||
|
"subline": {"type": "string", "default": "",
|
||||||
|
"x-fits": 27, "x-fits-hard": 42},
|
||||||
"contact_bearing_deg": {"type": "number", "minimum": 0,
|
"contact_bearing_deg": {"type": "number", "minimum": 0,
|
||||||
"exclusiveMaximum": 360, "default": 210.0},
|
"exclusiveMaximum": 360, "default": 210.0},
|
||||||
"sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10,
|
"sweeps": {"type": "number", "exclusiveMinimum": 0, "maximum": 10,
|
||||||
@@ -38,20 +40,26 @@ TEMPLATES = {
|
|||||||
"type": "object", "additionalProperties": False,
|
"type": "object", "additionalProperties": False,
|
||||||
"required": ["label", "value"],
|
"required": ["label", "value"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"label": {"type": "string", "minLength": 1},
|
"label": {"type": "string", "minLength": 1,
|
||||||
|
"x-fits": 32, "x-fits-hard": 53},
|
||||||
"value": {"type": "number", "exclusiveMinimum": 0},
|
"value": {"type": "number", "exclusiveMinimum": 0},
|
||||||
"unit": {"type": "string", "default": ""},
|
"unit": {"type": "string", "default": "",
|
||||||
|
"x-fits-part-of": "value_label"},
|
||||||
"color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"],
|
"color": {"enum": ["ink", "amber", "amber_dark", "muted", "dim", "red"],
|
||||||
"type": "string", "default": "ink"},
|
"type": "string", "default": "ink"},
|
||||||
"value_label": {"type": "string", "default": ""},
|
"value_label": {"type": "string", "default": "",
|
||||||
|
"x-fits": 30, "x-fits-hard": 49},
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
"properties": {
|
"properties": {
|
||||||
"headline": {"type": "string", "minLength": 1},
|
"headline": {"type": "string", "minLength": 1,
|
||||||
|
"x-fits": 16, "x-fits-hard": 26},
|
||||||
"bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"},
|
"bars": {"type": "array", "items": {"$ref": "#/$defs/Bar"},
|
||||||
"minItems": 1, "maxItems": 3},
|
"minItems": 1, "maxItems": 3},
|
||||||
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2},
|
"quote": {"type": "array", "items": {"type": "string"}, "maxItems": 2,
|
||||||
"attribution": {"type": "string", "default": ""},
|
"x-fits": 33, "x-fits-hard": 49},
|
||||||
|
"attribution": {"type": "string", "default": "",
|
||||||
|
"x-fits": 46, "x-fits-hard": 73},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -353,10 +361,16 @@ def test_a_spec_that_is_not_even_a_dict():
|
|||||||
def test_describe_templates_is_driven_by_what_the_service_publishes():
|
def test_describe_templates_is_driven_by_what_the_service_publishes():
|
||||||
text = describe_templates(TEMPLATES)
|
text = describe_templates(TEMPLATES)
|
||||||
assert "radar_sweep:" in text and "scale_bars:" in text
|
assert "radar_sweep:" in text and "scale_bars:" in text
|
||||||
assert "headline: string, no vacío, OBLIGATORIO" in text
|
|
||||||
assert "1-3 elementos" in text # los límites llegan al prompt
|
assert "1-3 elementos" in text # los límites llegan al prompt
|
||||||
assert "ink, amber, amber_dark, muted, dim, red" in text
|
assert "ink, amber, amber_dark, muted, dim, red" in text
|
||||||
assert "label: string, no vacío, OBLIGATORIO" in text # despliega los objetos anidados
|
# Los dos presupuestos, y el que no tiene por dibujarse dentro de otro: el
|
||||||
|
# modelo apunta al primero, y el segundo es el que se le comprueba.
|
||||||
|
assert "headline: string, no vacío, CABE ~13 caracteres dibujados, " \
|
||||||
|
"ILEGIBLE por encima de 21, OBLIGATORIO" in text
|
||||||
|
assert "unit: string, se dibuja dentro de value_label, comparte su sitio" in text
|
||||||
|
# Y despliega los objetos anidados, con sus presupuestos de `$defs`.
|
||||||
|
assert "label: string, no vacío, CABE ~32 caracteres dibujados, " \
|
||||||
|
"ILEGIBLE por encima de 53, OBLIGATORIO" in text
|
||||||
|
|
||||||
|
|
||||||
def test_a_template_nobody_wrote_here_still_gets_described():
|
def test_a_template_nobody_wrote_here_still_gets_described():
|
||||||
@@ -644,6 +658,99 @@ def test_the_hook_note_comes_before_the_duration_one():
|
|||||||
assert "fecha" in notes[0] and "objetivo" in notes[1]
|
assert "fecha" in notes[0] and "objetivo" in notes[1]
|
||||||
|
|
||||||
|
|
||||||
|
# --- textos que se van a dibujar ilegibles -----------------------------------
|
||||||
|
# `x-fits` es guía blanda y tiene que serlo: el ejemplo de referencia se pasa de
|
||||||
|
# varios de sus propios presupuestos por uno o tres caracteres y se ve bien.
|
||||||
|
# `x-fits-hard` es la otra línea, y esa sí se comprueba antes de gastar el
|
||||||
|
# render — que es donde el aviso llegaba antes, con el vídeo ya pagado.
|
||||||
|
|
||||||
|
def one_shot(template="radar_sweep", **props):
|
||||||
|
return spec_with({"template": template, "duration": 25.0, "props": props})
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_text_past_the_hard_budget_is_flagged():
|
||||||
|
"""El caso real, medido sobre Cash-Landrum: un texto pidió 36 px y se dibujó
|
||||||
|
a 20 en un fotograma de 1080 de ancho."""
|
||||||
|
largo = "ALL THREE DEVELOPED SYMPTOMS CONSISTENT WITH RADIATION EXPOSURE"
|
||||||
|
|
||||||
|
note = unreadable_notes(one_shot(headline=largo), TEMPLATES)[0]
|
||||||
|
|
||||||
|
assert "ILEGIBLE" in note
|
||||||
|
assert "shots.0.headline" in note and f"{len(largo)} caracteres" in note
|
||||||
|
assert "caben 21" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_text_between_the_two_budgets_is_left_alone():
|
||||||
|
"""El control, y es la mitad del diseño: entre `x-fits` y `x-fits-hard` el
|
||||||
|
texto sale un poco más pequeño y se ve bien. Avisar ahí sería gritar con
|
||||||
|
specs buenos, y un aviso que grita se acaba ignorando — que es exactamente
|
||||||
|
cómo el de verdad grave se pasó meses sin que nadie actuara."""
|
||||||
|
assert len("3 RADARS TRACKING") > 13 # por encima del x-fits
|
||||||
|
assert len("3 RADARS TRACKING") < 21 # por debajo del ilegible
|
||||||
|
|
||||||
|
assert unreadable_notes(one_shot(headline="3 RADARS TRACKING"), TEMPLATES) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_budget_of_a_list_of_lines_is_per_line():
|
||||||
|
"""Una cita se dibuja partida en líneas, así que el presupuesto es por línea.
|
||||||
|
Medirlo sobre el texto unido avisaría de una cita bien partida en dos."""
|
||||||
|
dos = ["A QUOTE SPLIT WHERE IT HAS TO", "BREAK SO THAT IT FITS ON SCREEN"]
|
||||||
|
assert sum(len(x) for x in dos) > 49 and all(len(x) < 49 for x in dos)
|
||||||
|
|
||||||
|
ok = spec_with(shot("scale_bars", quote=dos))
|
||||||
|
assert unreadable_notes(ok, TEMPLATES) == []
|
||||||
|
|
||||||
|
larga = spec_with(shot("scale_bars", quote=["X" * 60]))
|
||||||
|
assert "shots.0.quote[0]" in unreadable_notes(larga, TEMPLATES)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_budget_inside_a_list_of_objects_is_found():
|
||||||
|
"""Los presupuestos de un submodelo viven en `$defs`, y saltárselos fue justo
|
||||||
|
el agujero por el que shortsmith se pasó meses sin medir nueve campos."""
|
||||||
|
doc = spec_with(shot("scale_bars",
|
||||||
|
bars=[{"label": "BOEING 747", "value": 232},
|
||||||
|
{"label": "X" * 60, "value": 100}]))
|
||||||
|
|
||||||
|
note = unreadable_notes(doc, TEMPLATES)[0]
|
||||||
|
|
||||||
|
assert "shots.0.bars[1].label" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_field_drawn_inside_another_is_not_judged_alone():
|
||||||
|
"""`unit` no tiene presupuesto propio: se dibuja dentro de la cadena de
|
||||||
|
`value_label`. Juzgarlo solo sería inventarse un límite que el contrato dice
|
||||||
|
expresamente que no existe."""
|
||||||
|
doc = spec_with(shot("scale_bars",
|
||||||
|
bars=[{"label": "BOEING 747", "value": 232,
|
||||||
|
"unit": "X" * 60}]))
|
||||||
|
|
||||||
|
assert unreadable_notes(doc, TEMPLATES) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_old_contract_without_the_hard_budget_says_nothing():
|
||||||
|
"""shortsmith publicó `x-fits-hard` en 289d50e. Contra uno anterior esto no
|
||||||
|
puede inventarse el número: se calla, y de que el contrato lo traiga se
|
||||||
|
encarga `test_shortsmith_live.py`, que es quien habla con el servicio."""
|
||||||
|
viejo = {"radar_sweep": {"type": "object", "required": ["headline"],
|
||||||
|
"properties": {"headline": {"type": "string",
|
||||||
|
"x-fits": 13}}}}
|
||||||
|
|
||||||
|
assert unreadable_notes(one_shot(headline="X" * 90), viejo) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_defects_come_before_the_duration():
|
||||||
|
"""Los tres avisos pueden salir juntos y quien los lee coge `[0]`. Primero lo
|
||||||
|
que está roto, después lo que está fuera de objetivo."""
|
||||||
|
doc = spec_with({"template": "radar_sweep", "duration": 90.0,
|
||||||
|
"props": {"headline": "8 JAN 1981",
|
||||||
|
"subline": "X" * 60}})
|
||||||
|
|
||||||
|
notes = editorial_notes(doc, TEMPLATES)
|
||||||
|
|
||||||
|
assert len(notes) == 3
|
||||||
|
assert "fecha" in notes[0] and "ILEGIBLE" in notes[1] and "objetivo" in notes[2]
|
||||||
|
|
||||||
|
|
||||||
def test_the_prompt_carries_how_much_text_actually_fits():
|
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
|
"""`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."""
|
modelo escribe una cita de 58 caracteres para un hueco de 16."""
|
||||||
|
|||||||
Reference in New Issue
Block a user