fix(seo): el reintento estricto llevaba meses sin ejecutarse
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s

`_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHakatofHeJAzdXL26Q5bq
This commit is contained in:
ChemaVX
2026-09-02 08:53:28 +00:00
co-authored by Claude Opus 5
parent bf51fb30c0
commit 32f0b2c1ab
2 changed files with 86 additions and 14 deletions
+41 -14
View File
@@ -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
+45
View File
@@ -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