diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 3baaeea..3a119eb 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -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 diff --git a/backend/app/services/article_writer.py b/backend/app/services/article_writer.py index ef11fb6..a966bc6 100644 --- a/backend/app/services/article_writer.py +++ b/backend/app/services/article_writer.py @@ -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() diff --git a/backend/tests/test_ai_mode.py b/backend/tests/test_ai_mode.py index 9a30edd..0faabae 100644 --- a/backend/tests/test_ai_mode.py +++ b/backend/tests/test_ai_mode.py @@ -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