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) ──────────────────────────
|
# ─── 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:
|
def _system_prompt(lang: str) -> str:
|
||||||
allow = ", ".join(ALLOWED_TAGS.get(lang, []))
|
allow = ", ".join(ALLOWED_TAGS.get(lang, []))
|
||||||
out_lang = "SPANISH" if lang == "es" else "ENGLISH"
|
out_lang = "SPANISH" if lang == "es" else "ENGLISH"
|
||||||
@@ -525,17 +555,9 @@ async def generate_seo_fields(
|
|||||||
system = _system_prompt(lang)
|
system = _system_prompt(lang)
|
||||||
user = _user_message(article_text, link_menu)
|
user = _user_message(article_text, link_menu)
|
||||||
|
|
||||||
async def _raw(messages: list, max_tokens: int = 1024,
|
async def _raw(messages: list, max_tokens: int = 1024) -> str:
|
||||||
temperature: float | None = None) -> str:
|
msg = await client.messages.create(
|
||||||
kwargs = {
|
**_create_kwargs(system, messages, max_tokens))
|
||||||
"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)
|
|
||||||
if db is not None and session_id is not None:
|
if db is not None and session_id is not None:
|
||||||
try:
|
try:
|
||||||
await db.log_api_call(
|
await db.log_api_call(
|
||||||
@@ -571,9 +593,8 @@ async def generate_seo_fields(
|
|||||||
{"role": "assistant", "content": text1},
|
{"role": "assistant", "content": text1},
|
||||||
{"role": "user", "content": instr},
|
{"role": "user", "content": instr},
|
||||||
]
|
]
|
||||||
# temperature=0 so the shorten instruction binds deterministically.
|
|
||||||
retry = _coerce(_parse_json_object(
|
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)
|
retry["internal_links"] = _sanitize_links(retry["internal_links"], link_menu)
|
||||||
rlinked, _ = insert_internal_links(
|
rlinked, _ = insert_internal_links(
|
||||||
_markdown_to_html(article_text), retry["internal_links"], link_menu, lang)
|
_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).
|
# Keep the retry's text but record it stayed over (never truncate).
|
||||||
fields, violations, blocking = retry, rviol, _blocking(rviol)
|
fields, violations, blocking = retry, rviol, _blocking(rviol)
|
||||||
except Exception as e: # noqa: BLE001
|
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
|
# Final boundary-aware shortener — only for fields the LLM + retry left
|
||||||
# over limit. Clean (sentence-drop / word-boundary), never mid-word, and
|
# over limit. Clean (sentence-drop / word-boundary), never mid-word, and
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ BASE = {
|
|||||||
"image_context": "c",
|
"image_context": "c",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import src.seo.autofill as _autofill_module
|
||||||
|
|
||||||
|
|
||||||
def test_coerce_es_drops_invented_tags():
|
def test_coerce_es_drops_invented_tags():
|
||||||
obj = dict(BASE, tags=["uap", "humanoides", "Desclasificados", "investigacion-2"])
|
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") == []
|
assert _check_con_sitio({"slug": "x"}, "pt") == []
|
||||||
finally:
|
finally:
|
||||||
R.check_post = orig
|
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