ci: pinea anthropic y corre la suite DENTRO de la imagen
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m6s
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:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user