From 6cbb7280fa3e082beb5e4e3adc3bb37a5596f895 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Wed, 2 Sep 2026 08:58:14 +0000 Subject: [PATCH] ci: pinea anthropic y corre la suite DENTRO de la imagen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EHakatofHeJAzdXL26Q5bq --- .gitea/workflows/build.yml | 17 +++++++++ Dockerfile.test | 22 ++++++++++++ requirements-dev.txt | 13 ++++--- requirements.txt | 13 ++++++- tests/test_sdk_contract.py | 73 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 Dockerfile.test create mode 100644 tests/test_sdk_contract.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index edeafe7..b06aae8 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..17e3278 --- /dev/null +++ b/Dockerfile.test @@ -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"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 1d6cf3f..d5ac543 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/requirements.txt b/requirements.txt index e6ab9d2..1285c02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_sdk_contract.py b/tests/test_sdk_contract.py new file mode 100644 index 0000000..755843a --- /dev/null +++ b/tests/test_sdk_contract.py @@ -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.")