From 782f36ed5175f4b215c671bf0ced9d248ab5e2c3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 12 Apr 2026 22:13:21 -0400 Subject: [PATCH] fix(llm): surface Ollama error body; refresh pre-gemma3 summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small hardening fixes from the mistral-nemo testing round: 1. stream_chat / stream_chat_with_tools now read the Ollama response body and log it before raising on non-2xx. Previously all we saw was 'HTTP 400 Bad Request' — the gemma3-no-tools failure would have been diagnosed in one step if we'd been logging the body, which says e.g. 'model does not support tools'. 2. backfill_project_summaries() now also targets summaries stamped before 2026-04-12 (the gemma3:4b cutover). The remaining projects still carrying the broken qwen2.5:3b output (token repetition, hallucinated topics) will regenerate on next startup on the better model. Co-Authored-By: Claude Opus 4.6 --- src/fabledassistant/services/llm.py | 22 ++++++++++++++++++++-- src/fabledassistant/services/projects.py | 21 +++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 288df68..a01edc6 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -159,6 +159,24 @@ async def wait_for_model_loaded(model: str, timeout: float = 90.0) -> bool: await asyncio.sleep(min(2.0, remaining)) +async def _raise_ollama_error(resp: httpx.Response, model: str) -> None: + """On a non-2xx Ollama response, log its body before raising. + + ``resp.raise_for_status()`` alone throws away Ollama's error body, which + carries the actual reason (e.g. ``"model does not support tools"``, + ``"context length exceeded"``). Reading the body first means failures + surface a usable message instead of a bare HTTPStatusError. + """ + if resp.status_code < 400: + return + body = (await resp.aread()).decode("utf-8", errors="replace").strip() + logger.error( + "Ollama /api/chat %d for model=%s: %s", + resp.status_code, model, body[:500], + ) + resp.raise_for_status() + + async def stream_chat( messages: list[dict], model: str, @@ -184,7 +202,7 @@ async def stream_chat( f"{Config.OLLAMA_URL}/api/chat", json=payload, ) as resp: - resp.raise_for_status() + await _raise_ollama_error(resp, model) async for line in resp.aiter_lines(): if not line.strip(): continue @@ -245,7 +263,7 @@ async def stream_chat_with_tools( f"{Config.OLLAMA_URL}/api/chat", json=payload, ) as resp: - resp.raise_for_status() + await _raise_ollama_error(resp, model) accumulated_tool_calls: list[dict] = [] async for line in resp.aiter_lines(): if not line.strip(): diff --git a/src/fabledassistant/services/projects.py b/src/fabledassistant/services/projects.py index d3e1e38..16080a4 100644 --- a/src/fabledassistant/services/projects.py +++ b/src/fabledassistant/services/projects.py @@ -141,13 +141,30 @@ async def generate_project_summary(user_id: int, project_id: int) -> None: logger.debug("Failed to generate summary for project %d", project_id, exc_info=True) +# Cutoff: summaries older than this were generated by qwen2.5:3b, which +# produced broken output (token repetition, hallucinated topics, misspellings). +# Anything stamped before the switch to gemma3:4b on 2026-04-12 gets +# regenerated at startup so the whole project list ends up on the better model. +_BACKGROUND_MODEL_CUTOVER = datetime(2026, 4, 12, tzinfo=timezone.utc) + + async def backfill_project_summaries() -> None: - """Generate summaries for all projects missing auto_summary. Fire-and-forget.""" + """Generate summaries for projects missing or predating the gemma3:4b cutover. + + Fire-and-forget: each summary runs as its own background task. + """ import asyncio + from sqlalchemy import or_ try: async with async_session() as session: rows = (await session.execute( - select(Project.id, Project.user_id).where(Project.auto_summary.is_(None)) + select(Project.id, Project.user_id).where( + or_( + Project.auto_summary.is_(None), + Project.summary_updated_at.is_(None), + Project.summary_updated_at < _BACKGROUND_MODEL_CUTOVER, + ) + ) )).all() for row in rows: asyncio.create_task(generate_project_summary(row.user_id, row.id))