fix(seo): el reintento estricto llevaba meses sin ejecutarse
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
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:
+41
-14
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user