ci: pinea anthropic y corre la suite DENTRO de la imagen
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m6s

Tres piezas de un mismo agujero: ningún test de este repo había visto nunca las
librerías que ejecuta el bot.

1. **El pin.** `anthropic>=0.40.0` era el ÚNICO suelo del fichero — todo lo demás
   ya estaba pinneado exacto, y medido con `pip freeze` dentro del pod, todo lo
   pinneado cuadra. Ese suelo dejó entrar la 1.2.0, una versión MAYOR, sin un
   commit: quitó `temperature` de `messages.create` y rompió el reintento del SEO
   (32f0b2c). 1.2.0 es lo que ya corre, así que el pin no cambia producción:
   la escribe. Evidencia de compatibilidad del propio pod: convive con
   httpx 0.28.1, pydantic 2.13.4 y aiohttp 3.14.1.

2. **La suite en la CI, dentro de la imagen construida.** `Dockerfile.test` parte
   del tag recién publicado, así que los tests ven EXACTAMENTE lo que se
   despliega. Va antes de actualizar los manifiestos: si falla, no se despliega.
   `tests/` no hace falta copiarlo, el runtime ya hace `COPY . .`.
   `TELEGRAM_BOT_TOKEN` de mentira porque es obligatorio y en la imagen no hay
   `.env` — comprobado que la suite entera pasa sólo con eso.

3. **El barrido, que cierra la clase y no el caso.** `tests/test_sdk_contract.py`
   lee con `ast` TODAS las llamadas a `messages.create` del árbol y compara sus
   kwargs con la firma del SDK instalado. Comprobar sólo el sitio arreglado
   habría dejado los otros ocho a ciegas.

⚠️ Los dos guardianes nuevos NO pueden fallar en un portátil con anthropic
0.102.0: ahí `temperature` sigue en la firma. Verificados a mano dentro del pod
contra 1.2.0 — inyectando la avería en una copia aislada, el barrido la caza; con
el código real, las nueve llamadas pasan. Es el pin + la CI lo que los convierte
en pruebas y no en gestos.

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:58:14 +00:00
co-authored by Claude Opus 5
parent 32f0b2c1ab
commit 6cbb7280fa
5 changed files with 133 additions and 5 deletions
+17
View File
@@ -91,6 +91,23 @@ jobs:
fi
echo "OK: chemavx/researchowl:${TAG} verified in registry"
- name: Run the suite inside the image
run: |
TAG=${{ steps.tag.outputs.TAG }}
BASE=gitea.gitea.svc.cluster.local:3000/chemavx/researchowl:${TAG}
# Contra la imagen recién construida, no contra el árbol de fuentes:
# la suite tiene que ver las librerías que se van a desplegar. Antes
# corría sólo en el portátil, con otro anthropic que el del pod, y por
# eso un kwarg que el SDK ya no aceptaba pasó meses sin que nada lo
# dijera.
docker buildx build \
--builder ci-builder \
--load \
--build-arg BASE=${BASE} \
-t researchowl-test:${TAG} \
-f Dockerfile.test .
docker run --rm researchowl-test:${TAG}
- name: Update k8s manifests
run: |
pip3 install pyyaml -q
+22
View File
@@ -0,0 +1,22 @@
# La imagen de runtime más los corredores de la suite.
#
# Parte de la imagen que se acaba de construir, no del árbol de fuentes, para
# que los tests vean las MISMAS librerías que se despliegan. Sin esto la suite
# corría en el portátil contra anthropic 0.102.0 mientras el pod ejecutaba
# 1.2.0, y así es como un `temperature` que el SDK ya no aceptaba vivió meses
# de reintentos que nunca ocurrieron.
#
# `tests/` no hace falta copiarlo: el Dockerfile de runtime hace `COPY . .` y ya
# viaja dentro.
ARG BASE
FROM ${BASE}
RUN pip install --no-cache-dir pytest==9.0.3 pytest-asyncio==1.4.0
# `settings.telegram_bot_token` es obligatorio y en la imagen no hay `.env`:
# sin esto la suite no llega ni a recolectar. Es un valor de mentira a propósito
# — ningún test habla con Telegram.
ENV TELEGRAM_BOT_TOKEN=ci-dummy
ENV PYTEST_ADDOPTS="-p no:cacheprovider"
CMD ["python", "-m", "pytest", "tests", "-q"]
+9 -4
View File
@@ -1,6 +1,11 @@
# Solo para ejecutar la suite en local — NO va en la imagen (el Dockerfile
# instala requirements.txt y nada más; la CI no corre pytest).
# Los corredores de la suite. NO van en la imagen de runtime: los instala
# `Dockerfile.test`, que parte de la imagen ya construida para que los tests
# vean EXACTAMENTE las librerías que se despliegan.
#
# Pinneados por la misma razón que el resto: un suelo deja que una versión
# distinta entre sin commit, y entonces "pasa en mi máquina" deja de significar
# nada. Ver el comentario de `anthropic` en requirements.txt.
# pip install -r requirements-dev.txt && make test
-r requirements.txt
pytest>=8.0
pytest-asyncio>=0.24
pytest==9.0.3
pytest-asyncio==1.4.0
+12 -1
View File
@@ -27,7 +27,18 @@ numpy==1.26.4
scikit-learn==1.5.1
# Claude API (scoring)
anthropic>=0.40.0
#
# Pinneado, no con suelo — y esta línea es la razón por la que el resto lo está.
# Era `>=0.40.0`, y con eso entró **anthropic 1.2.0** en producción sin un solo
# commit: una versión mayor que quitó `temperature` de `messages.create` (los
# parámetros de muestreo se fueron a `output_config`). El reintento estricto del
# SEO llevaba desde entonces muriendo con un TypeError que un `except` convertía
# en aviso. Nadie lo vio porque la suite corría en otra máquina con 0.102.0.
#
# 1.2.0 es lo que había instalado en el pod el 2026-09-02, medido con
# `pip freeze` dentro del contenedor. Para subirlo: cambiar aquí, y que la suite
# de la CI —que ahora corre DENTRO de la imagen— diga si vale.
anthropic==1.2.0
# PDF export
markdown==3.10.2
+73
View File
@@ -0,0 +1,73 @@
"""Lo que le pedimos al SDK de Anthropic contra lo que el SDK acepta.
Este fichero existe por un fallo concreto: `src/seo/autofill.py` mandaba
`temperature=0.0` a `messages.create`, y anthropic 1.2.0 lo quitó de la firma
los parámetros de muestreo se movieron a `output_config`. La llamada moría
con un TypeError que un `except` convertía en aviso, así que el reintento
estricto del SEO llevaba meses sin ejecutarse. Nada lo vio: la importación no
falla, ningún test toca la API de verdad, y la suite corría en un portátil con
anthropic 0.102.0 mientras el pod ejecutaba 1.2.0.
Comprobar el caso arreglado no habría bastado. Lo que hace falta es cerrar la
CLASE: se leen TODAS las llamadas del árbol con `ast` y se comparan sus kwargs
con la firma del SDK instalado. Un kwarg que el SDK ya no acepta falla aquí, en
el sitio donde está escrito, sin que nadie tenga que ejercitar ese camino.
Junto con el pin de `requirements.txt` y el `pytest` que la CI corre DENTRO de
la imagen, esto pasa a ejecutarse contra el SDK que de verdad se despliega
que es lo único que lo convierte en una prueba y no en un gesto.
"""
from __future__ import annotations
import ast
import inspect
from pathlib import Path
import pytest
SRC = Path(__file__).resolve().parents[1] / "src"
def _llamadas_al_sdk() -> list[tuple[str, int, list[str], bool]]:
"""(fichero, línea, kwargs, tiene_**kwargs) de cada `*.messages.create(...)`."""
encontradas = []
for fichero in sorted(SRC.rglob("*.py")):
arbol = ast.parse(fichero.read_text(encoding="utf-8"), filename=str(fichero))
for nodo in ast.walk(arbol):
if not isinstance(nodo, ast.Call):
continue
fn = nodo.func
if not (isinstance(fn, ast.Attribute) and fn.attr == "create"
and isinstance(fn.value, ast.Attribute)
and fn.value.attr == "messages"):
continue
nombres = [k.arg for k in nodo.keywords if k.arg is not None]
estrella = any(k.arg is None for k in nodo.keywords)
encontradas.append((str(fichero.relative_to(SRC)), nodo.lineno,
nombres, estrella))
return encontradas
def _firma() -> set[str]:
from anthropic import AsyncAnthropic
return set(inspect.signature(
AsyncAnthropic(api_key="test").messages.create).parameters)
def test_hay_llamadas_que_revisar():
"""Un barrido que no encuentra nada mide una lista vacía y pasa siempre."""
assert len(_llamadas_al_sdk()) >= 5
@pytest.mark.parametrize("fichero,linea,kwargs,estrella", _llamadas_al_sdk(),
ids=lambda v: str(v))
def test_cada_llamada_usa_kwargs_que_el_sdk_acepta(fichero, linea, kwargs, estrella):
if estrella and not kwargs:
# Los kwargs se construyen aparte (p. ej. `_create_kwargs`): esa función
# tiene su propio test, que compara el diccionario ya montado.
pytest.skip(f"{fichero}:{linea} pasa **kwargs; cubierto en su propio test")
firma = _firma()
desconocidos = sorted(k for k in kwargs if k not in firma)
assert desconocidos == [], (
f"{fichero}:{linea} pasa {desconocidos} a messages.create y el SDK "
f"instalado no lo acepta. Arregla la llamada, no relajes el assert.")