fix: a model call with no timeout, and tests that measured Redis

The generation run stalled at topic 28 with the process alive and the log
frozen. `_call_model` had no timeout — every other call in ai_service.py has
one — so a stalled connection to the proxy hung the caller indefinitely. An
interactive request survives that because the person gives up; an unattended run
of five hundred topics does not, it just stops quietly and looks busy.

It now takes a timeout, generous by default and 150s from the article writer:
long enough for a full article, short enough that a stall is noticed in minutes
rather than found hours later with nothing written since.

Separately, the AI Mode tests passed this morning and failed this evening with
no code between them. Not flakiness: they call the real Redis rate limiter, and
sixty-eight runs of the suite had exhausted a daily limit of sixty. A test that
depends on shared external state stops testing the code and starts reporting how
often it has been run, so the limiter is now patched out for those tests.

208 backend tests green, and the run is moving again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-10 22:02:50 +02:00
parent 18afb138dc
commit 1d5962b40e
3 changed files with 22 additions and 3 deletions

View file

@ -216,14 +216,22 @@ def extract_questions(
raise RuntimeError(f"Failed to extract questions after 3 attempts: {last_error}")
def _call_model(prompt: str, model_id: str | None, api_key: str | None) -> str:
"""Call the configured LLM and return raw text response."""
def _call_model(prompt: str, model_id: str | None, api_key: str | None,
timeout: int = 180) -> str:
"""Call the configured LLM and return raw text response.
The timeout is not optional in practice: every other call in this module has
one, and this one did not. A stalled connection to the proxy hung the caller
for good which an interactive request survives by the user giving up, and
an unattended run of several hundred topics does not.
"""
use_model = _proxy_model(model_id or settings.LITELLM_MODEL)
use_key = api_key or settings.LITELLM_API_KEY
kwargs = {
"model": use_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1,
"timeout": timeout,
}
if use_key:
kwargs["api_key"] = use_key

View file

@ -33,6 +33,9 @@ PASSAGES = 14
# Enough retrieved prose to write from. Below this the model is being asked to
# write a medical article out of fragments, and it will either refuse or invent.
MIN_SOURCE_CHARS = 3000
# Generous for a long article, short enough that a stalled call is noticed in
# minutes rather than discovered hours later with nothing written since.
WRITE_TIMEOUT = 150
# Long enough to be worth reading, short enough that nobody skims past the point.
LONG_WORDS = 550
SHELF = "Pediatrics"
@ -148,7 +151,8 @@ def write_article(db: Session, topic: str, category_id: int | None = None,
"passages": len(passages)}
model_id, api_key = get_model_for_task(db, "extraction")
raw = _call_model(_prompt(topic, passages), model_id, api_key)
# One topic must not be able to stall a run of five hundred.
raw = _call_model(_prompt(topic, passages), model_id, api_key, timeout=WRITE_TIMEOUT)
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```[a-z]*\n?|```$", "", text).strip()

View file

@ -67,6 +67,13 @@ class _AiModeBase(unittest.TestCase):
"""Fixtures shared by the route and retrieval cases; holds no tests itself."""
def setUp(self):
# The daily limit is enforced through the real Redis, so without this a
# suite that passes today fails once it has been run sixty times — the
# tests would be measuring shared state rather than this code.
self._no_limit = patch("app.routers.ai_mode.check_rate_limit", lambda **kwargs: None)
self._no_limit.start()
self.addCleanup(self._no_limit.stop)
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client