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))