testsAndMisc/python_pkg/code_tutor/_llm.py
Krzysztof kuhy Rudnicki eb322dcf80 Split the Claude backend out of code_tutor/_llm
_llm.py 295 -> 217, clearing the last code_tutor source violation; only
its seven test modules remain over the cap.

The seam falls between the two backends because each pins a different
plain import: test_llm.py patches requests.post on _llm for
OllamaBackend and anthropic.Anthropic for ClaudeBackend. _llm keeps its
own anthropic import - complete_with_fallback still catches APIError -
so the four anthropic patch strings were split per test rather than
moved wholesale.

Pass count is 217 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 18:00:53 +02:00

220 lines
6.1 KiB
Python

"""LLM backend abstraction: Ollama (primary) and Claude (fallback).
Backends implement the ``Backend`` protocol. The ``complete_with_fallback``
helper calls Ollama, detects weak responses, and offers to switch to Claude.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Protocol
import anthropic
import requests
from python_pkg.code_tutor._claude_backend import ClaudeBackend
if TYPE_CHECKING:
from collections.abc import Callable
from rich.console import Console
_OLLAMA_URL = "http://localhost:11434/api/generate"
_OLLAMA_MODEL = "qwen3-32b-dev:latest"
_OLLAMA_TIMEOUT = 120
_WEAK_PHRASES: frozenset[str] = frozenset(
{
"i don't know",
"i cannot",
"unclear",
"i'm not sure",
}
)
_WEAK_MIN_LEN = 30
class Backend(Protocol):
"""Structural protocol for an LLM backend."""
def complete(self, system: str, user: str) -> str:
"""Send *system* + *user* prompts and return the model's response.
Args:
system: System prompt (role / instructions).
user: User message (the actual query).
Returns:
Raw response text from the model.
"""
... # pragma: no cover
def stream(
self,
system: str,
user: str,
on_token: Callable[[str], None],
) -> str:
"""Stream the response token-by-token via *on_token*, return full text.
Args:
system: System prompt.
user: User message.
on_token: Called with each text token as it arrives.
Returns:
Full accumulated response text.
"""
... # pragma: no cover
class OllamaBackend:
"""LLM backend that calls the local Ollama HTTP API.
Args:
model: Ollama model tag to use.
url: Base URL of the Ollama API.
timeout: Request timeout in seconds.
"""
def __init__(
self,
model: str = _OLLAMA_MODEL,
url: str = _OLLAMA_URL,
timeout: int = _OLLAMA_TIMEOUT,
) -> None:
"""Initialise with model/URL/timeout settings."""
self._model = model
self._url = url
self._timeout = timeout
def complete(self, system: str, user: str) -> str:
"""Call Ollama and return the response text (non-streaming).
Args:
system: System prompt.
user: User message / prompt.
Returns:
Response string from the model.
Raises:
requests.exceptions.Timeout: When the request exceeds *timeout*.
requests.exceptions.ConnectionError: When Ollama is unreachable.
requests.exceptions.HTTPError: On non-2xx HTTP responses.
"""
payload = {
"model": self._model,
"system": system,
"prompt": user,
"stream": False,
}
response = requests.post(self._url, json=payload, timeout=self._timeout)
response.raise_for_status()
data = response.json()
return str(data.get("response", ""))
def stream(
self,
system: str,
user: str,
on_token: Callable[[str], None],
) -> str:
"""Call Ollama with streaming enabled, invoking *on_token* per token.
Args:
system: System prompt.
user: User message.
on_token: Callback invoked with each response token as it arrives.
Returns:
Full accumulated response text.
Raises:
requests.exceptions.Timeout: When the request exceeds *timeout*.
requests.exceptions.ConnectionError: When Ollama is unreachable.
requests.exceptions.HTTPError: On non-2xx HTTP responses.
"""
payload = {
"model": self._model,
"system": system,
"prompt": user,
"stream": True,
}
response = requests.post(
self._url, json=payload, timeout=self._timeout, stream=True
)
response.raise_for_status()
parts: list[str] = []
for raw_line in response.iter_lines():
if not raw_line:
continue
data = json.loads(raw_line)
token = str(data.get("response", ""))
if token:
on_token(token)
parts.append(token)
if data.get("done"):
break
return "".join(parts)
def _is_weak(response: str) -> bool:
"""Return True if *response* looks too short or expresses uncertainty.
Args:
response: Raw text from the LLM.
Returns:
True when the response is shorter than ``_WEAK_MIN_LEN`` characters
or contains one of the ``_WEAK_PHRASES`` (case-insensitive).
"""
if len(response) < _WEAK_MIN_LEN:
return True
lower = response.lower()
return any(phrase in lower for phrase in _WEAK_PHRASES)
def complete_with_fallback(
primary: OllamaBackend,
system: str,
user: str,
*,
console: Console,
input_fn: Callable[[str], str] = input,
) -> str:
"""Call *primary*, detect weakness, and offer a Claude fallback.
If the primary's response is weak, the user is asked whether to switch
to Claude for this item. If they decline (or Claude fails), Ollama is
retried once.
Args:
primary: The Ollama backend to call first.
system: System prompt.
user: User message.
console: Rich console for status messages.
input_fn: Callable used for user input (injectable for testing).
Returns:
Final response string.
"""
response = primary.complete(system, user)
if not _is_weak(response):
return response
console.print(
"[yellow][!] Qwen3's response looks weak. Switch to Claude for this item?\n"
" (This will use your ANTHROPIC_API_KEY) [y/N]:[/yellow]"
)
choice = input_fn("").strip().lower()
if choice == "y":
try:
claude = ClaudeBackend()
return claude.complete(system, user)
except anthropic.APIError:
console.print("[yellow]Claude unavailable, retrying Qwen3...[/yellow]")
return primary.complete(system, user)