mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:47:01 +01:00
New python_pkg/code_tutor package that turns an unfamiliar codebase into an interactive lesson: analyse code into items (_analyzer), order them dependency-first (_plan_builder), pose reimplement challenges (_challenge + _challenge_support), verify user implementations with tests and coverage (_verifier), track progress (_progress/_session), and offer local-LLM hints (_llm), driven by a Typer/Rich CLI (cli). - meta/pyproject.toml + .pre-commit-config.yaml + meta/requirements.txt: add anthropic/rich/typer deps, a cli.py per-file-ignore (Typer B008 etc), and code_tutor-specific ruff/pylint/mypy tuning. - _challenge.py and its tests are split across _challenge_support.py and test_challenge_part2/part3.py to stay under the 500-line file cap. Verification: pytest 631 passed at 100% line+branch coverage; every file <=500 lines; pre-commit (ruff ALL, mypy strict, pylint 10/10, bandit, evidence + multi-file contract) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
296 lines
8.2 KiB
Python
296 lines
8.2 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
|
|
|
|
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)
|
|
|
|
|
|
class ClaudeBackend:
|
|
"""LLM backend that calls the Anthropic Claude API.
|
|
|
|
Reads ``ANTHROPIC_API_KEY`` from the environment automatically.
|
|
|
|
Args:
|
|
model: Claude model identifier.
|
|
max_tokens: Maximum tokens in the response.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
model: str = "claude-sonnet-4-6",
|
|
max_tokens: int = 1024,
|
|
) -> None:
|
|
"""Initialise Anthropic client."""
|
|
self._client = anthropic.Anthropic()
|
|
self._model = model
|
|
self._max_tokens = max_tokens
|
|
|
|
def complete(self, system: str, user: str) -> str:
|
|
"""Call Claude and return the response text.
|
|
|
|
Args:
|
|
system: System prompt.
|
|
user: User message.
|
|
|
|
Returns:
|
|
Response string from Claude.
|
|
|
|
Raises:
|
|
anthropic.APIError: On any Anthropic API failure.
|
|
"""
|
|
message = self._client.messages.create(
|
|
model=self._model,
|
|
max_tokens=self._max_tokens,
|
|
system=system,
|
|
messages=[{"role": "user", "content": user}],
|
|
)
|
|
block = message.content[0]
|
|
if hasattr(block, "text"):
|
|
return str(block.text)
|
|
return ""
|
|
|
|
def stream(
|
|
self,
|
|
system: str,
|
|
user: str,
|
|
on_token: Callable[[str], None],
|
|
) -> str:
|
|
"""Call Claude with streaming, invoking *on_token* per text delta.
|
|
|
|
Falls back to ``complete`` on ``APIError``.
|
|
|
|
Args:
|
|
system: System prompt.
|
|
user: User message.
|
|
on_token: Callback invoked with each text delta as it arrives.
|
|
|
|
Returns:
|
|
Full accumulated response text.
|
|
"""
|
|
try:
|
|
parts: list[str] = []
|
|
with self._client.messages.stream(
|
|
model=self._model,
|
|
max_tokens=self._max_tokens,
|
|
system=system,
|
|
messages=[{"role": "user", "content": user}],
|
|
) as s:
|
|
for text in s.text_stream:
|
|
on_token(text)
|
|
parts.append(text)
|
|
return "".join(parts)
|
|
except anthropic.APIError:
|
|
return self.complete(system, user)
|
|
|
|
|
|
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)
|