From 32f0b2c1ab169d83c376ac2fbfa4bf7f6756e382 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Wed, 2 Sep 2026 08:53:28 +0000 Subject: [PATCH] fix(seo): el reintento estricto llevaba meses sin ejecutarse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_raw` mandaba `temperature=0.0` a `messages.create`, y anthropic 1.2.0 dejó de aceptarlo: los parámetros de muestreo se movieron a `output_config`, que sólo expone `effort` y `format`. El `except` de abajo convertía el TypeError en un warning, así que el reintento NUNCA ocurría: se quedaba el primer intento y entraba el recortador mecánico. Se vio generando el artículo EN de Trans-en-Provence: `custom_excerpt` quedó en 389 caracteres contra un tope de 300, porque el recorte limpio lo habría dejado demasiado corto y el reintento que debía acortarlo con criterio estaba muerto. Lo que hacía vincular el reintento no era la temperatura: es el turno de edición —se le devuelve su propio JSON para que lo acorte, en vez de re-tirar de cero— y un `max_tokens` más corto. Los dos siguen. Y el `except` deja de disfrazar un error de programación: un TypeError se registra como error, no como aviso. Así fue como esto vivió en silencio. Los kwargs salen a `_create_kwargs()` para que un test los compare con la firma del SDK instalado. ⚠️ Ese test NO puede fallar hoy en local: el host tiene anthropic 0.102.0 y el pod 1.2.0, porque requirements.txt pone un suelo (`>=0.40.0`) y el CI no corre los tests. Verificado a mano dentro del pod: con `temperature` la firma lo rechaza, sin él pasa. El pin y el pytest en CI van aparte. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHakatofHeJAzdXL26Q5bq --- src/seo/autofill.py | 55 ++++++++++++++++++++++++++++---------- tests/test_seo_autofill.py | 45 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/seo/autofill.py b/src/seo/autofill.py index d2ac166..9fe720b 100644 --- a/src/seo/autofill.py +++ b/src/seo/autofill.py @@ -117,6 +117,36 @@ async def fetch_published_menu(lang: str) -> list[dict]: # ─── 2. SEO field generation (one Haiku JSON call) ────────────────────────── +def _create_kwargs(system: str, messages: list, max_tokens: int) -> dict: + """Los kwargs de `messages.create`, aparte para que un test los compare con + la firma del SDK que de verdad está instalado. + + Aquí vivía `temperature=0.0`, y el SDK dejó de aceptarlo: **anthropic 1.2.0 + no lleva `temperature` en `messages.create`** — los parámetros de muestreo + se movieron a `output_config`, que sólo expone `effort` y `format`. El + reintento estricto del SEO llevaba desde entonces muriendo con un TypeError + que el `except` de abajo convertía en un aviso, así que **nunca se + ejecutaba**: se quedaba el primer intento y entraba el recortador mecánico. + Se vio en el artículo EN de Trans-en-Provence, con el `custom_excerpt` en + 389 caracteres contra un tope de 300. + + Lo que hacía vincular el reintento no era la temperatura: es el turno de + edición —se le devuelve su propio JSON para que lo acorte, en vez de + volver a tirar de cero— y un `max_tokens` más corto que desanima la + divagación. Los dos siguen en pie. + + Una dependencia que estrecha su firma no rompe la importación ni los tests + que la simulan: sólo se ve comparando con la instalada, que es lo que hace + `test_los_kwargs_los_acepta_el_sdk_instalado`. + """ + return { + "model": settings.claude_model, + "max_tokens": max_tokens, + "system": system, + "messages": messages, + } + + def _system_prompt(lang: str) -> str: allow = ", ".join(ALLOWED_TAGS.get(lang, [])) out_lang = "SPANISH" if lang == "es" else "ENGLISH" @@ -525,17 +555,9 @@ async def generate_seo_fields( system = _system_prompt(lang) user = _user_message(article_text, link_menu) - async def _raw(messages: list, max_tokens: int = 1024, - temperature: float | None = None) -> str: - kwargs = { - "model": settings.claude_model, - "max_tokens": max_tokens, - "system": system, - "messages": messages, - } - if temperature is not None: - kwargs["temperature"] = temperature - msg = await client.messages.create(**kwargs) + async def _raw(messages: list, max_tokens: int = 1024) -> str: + msg = await client.messages.create( + **_create_kwargs(system, messages, max_tokens)) if db is not None and session_id is not None: try: await db.log_api_call( @@ -571,9 +593,8 @@ async def generate_seo_fields( {"role": "assistant", "content": text1}, {"role": "user", "content": instr}, ] - # temperature=0 so the shorten instruction binds deterministically. retry = _coerce(_parse_json_object( - await _raw(retry_msgs, max_tokens=768, temperature=0.0)), lang) + await _raw(retry_msgs, max_tokens=768)), lang) retry["internal_links"] = _sanitize_links(retry["internal_links"], link_menu) rlinked, _ = insert_internal_links( _markdown_to_html(article_text), retry["internal_links"], link_menu, lang) @@ -584,7 +605,13 @@ async def generate_seo_fields( # Keep the retry's text but record it stayed over (never truncate). fields, violations, blocking = retry, rviol, _blocking(rviol) except Exception as e: # noqa: BLE001 - logger.warning("seo.fields: retry failed, keeping first output", error=str(e)) + # Un TypeError aquí no es "la API tuvo un mal día": es que la + # llamada está mal escrita. Así fue como `temperature` vivió + # meses de reintentos que nunca ocurrieron, en un warning que + # nadie leía. + registra = logger.error if isinstance(e, TypeError) else logger.warning + registra("seo.fields: retry failed, keeping first output", + error=str(e), kind=type(e).__name__) # Final boundary-aware shortener — only for fields the LLM + retry left # over limit. Clean (sentence-drop / word-boundary), never mid-word, and diff --git a/tests/test_seo_autofill.py b/tests/test_seo_autofill.py index ef2864f..18901a5 100644 --- a/tests/test_seo_autofill.py +++ b/tests/test_seo_autofill.py @@ -9,6 +9,8 @@ BASE = { "image_context": "c", } +import src.seo.autofill as _autofill_module + def test_coerce_es_drops_invented_tags(): obj = dict(BASE, tags=["uap", "humanoides", "Desclasificados", "investigacion-2"]) @@ -167,3 +169,46 @@ def test_un_idioma_desconocido_no_revienta_la_generacion(): assert _check_con_sitio({"slug": "x"}, "pt") == [] finally: R.check_post = orig + + +# --- la llamada al SDK ------------------------------------------------------- + +def test_los_kwargs_los_acepta_el_sdk_instalado(): + """Contra la firma REAL, no contra una copia nuestra ni contra un doble. + + `temperature=0.0` viajaba en esta llamada y anthropic 1.2.0 dejó de + aceptarlo (los parámetros de muestreo se fueron a `output_config`). Nada lo + vio: la importación no falla, y un cliente simulado en un test acepta + cualquier kwarg encantado. Sólo se ve preguntándole al SDK instalado qué + admite — el mismo movimiento que publicar el contrato en vez de copiarlo. + + Si esto falla tras subir el SDK, el arreglo es cambiar la llamada, no + relajar el assert. + """ + import inspect + + from anthropic import AsyncAnthropic + + from src.seo.autofill import _create_kwargs + + kwargs = _create_kwargs("system", [{"role": "user", "content": "x"}], 768) + firma = inspect.signature(AsyncAnthropic(api_key="test").messages.create) + desconocidos = sorted(k for k in kwargs if k not in firma.parameters) + + assert desconocidos == [], ( + f"el SDK instalado no acepta {desconocidos} en messages.create; " + f"acepta {sorted(firma.parameters)}") + + +def test_el_reintento_no_pide_nada_que_no_este_en_los_kwargs(): + """El reintento usa la MISMA constructora, así que no puede divergir. + + Antes tenía su propia rama —`temperature` sólo se añadía en el reintento—, + y por eso el fallo sólo aparecía cuando el primer intento violaba un límite: + el camino feliz nunca lo tocaba. + """ + normal = _autofill_module._create_kwargs("s", [], 1024) + reintento = _autofill_module._create_kwargs("s", [], 768) + + assert set(normal) == set(reintento) + assert reintento["max_tokens"] == 768