From eb322dcf807a2eac8134b5b7b4557942ca406656 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 18:00:53 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- ...tutor-claude-backend-split-2026-08-16.json | 44 +++++++++ python_pkg/code_tutor/_claude_backend.py | 93 +++++++++++++++++++ python_pkg/code_tutor/_llm.py | 80 +--------------- python_pkg/code_tutor/tests/test_llm.py | 14 ++- 4 files changed, 148 insertions(+), 83 deletions(-) create mode 100644 docs/superpowers/evidence/code-tutor-claude-backend-split-2026-08-16.json create mode 100644 python_pkg/code_tutor/_claude_backend.py diff --git a/docs/superpowers/evidence/code-tutor-claude-backend-split-2026-08-16.json b/docs/superpowers/evidence/code-tutor-claude-backend-split-2026-08-16.json new file mode 100644 index 00000000..a0b17688 --- /dev/null +++ b/docs/superpowers/evidence/code-tutor-claude-backend-split-2026-08-16.json @@ -0,0 +1,44 @@ +{ + "intent": "Bring code_tutor/_llm.py under the 250-line cap by moving the Anthropic-API backend into its own module, clearing the last code_tutor source violation.", + "scope": [ + "python_pkg/code_tutor/_claude_backend.py (new)", + "python_pkg/code_tutor/_llm.py (295 -> 217)", + "python_pkg/code_tutor/tests/test_llm.py (import and patch paths only)", + "Non-goal: no assertion changed, no test deleted, skipped or weakened" + ], + "changes": [ + "Moved ClaudeBackend into _claude_backend.py. The seam falls between the two backends precisely because each pins a different plain import: test_llm.py patches requests.post on _llm for OllamaBackend and anthropic.Anthropic for ClaudeBackend, so separating the classes separates the two pinned modules cleanly.", + "_llm.py keeps its anthropic import, because complete_with_fallback still catches anthropic.APIError, and imports ClaudeBackend back since it constructs one.", + "Repointed the four anthropic patch strings belonging to the ClaudeBackend tests. The two in complete_with_fallback tests stay on _llm, since that is the module whose globals the except clause resolves through." + ], + "verification": [ + { + "command": "timeout 90 env PYTHONPATH=. python3 -m pytest python_pkg/code_tutor/tests -q", + "result": "pass", + "evidence": "217 passed - unchanged from the count established by the previous commit." + }, + { + "command": "PYTHONPATH=. python3 -c \"from python_pkg.code_tutor import _llm as l, _claude_backend as c; l.requests; l.anthropic; l.ClaudeBackend; l.OllamaBackend; c.anthropic; c.ClaudeBackend\"", + "result": "pass", + "evidence": "resolve OK - anthropic is a real attribute of both modules, which is what makes the per-test split of its patch strings valid." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all | grep code_tutor", + "result": "pass", + "evidence": "Only test modules are listed now: every code_tutor source file is under the 250-line cap." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "ruff flagged E501 on a patch string the longer module name pushed past 88 columns; auto-formatted, then clean. pytest-with-coverage passes at 100%." + } + ], + "risks": [ + "anthropic is now imported by two modules and patched on both, for different reasons. A future change that stops complete_with_fallback catching APIError would make the _llm import unused, and ruff would strip it and break the two patches that remain there.", + "_llm imports ClaudeBackend from _claude_backend; the reverse import would be a cycle." + ], + "rollback": [ + "git revert the commit; _llm.py returns to 295 lines and _claude_backend.py disappears.", + "After rollback, re-run the package suite and confirm it still reports 217 passed." + ] +} diff --git a/python_pkg/code_tutor/_claude_backend.py b/python_pkg/code_tutor/_claude_backend.py new file mode 100644 index 00000000..ad9a4364 --- /dev/null +++ b/python_pkg/code_tutor/_claude_backend.py @@ -0,0 +1,93 @@ +"""The Anthropic-API backend used as a fallback when the local model is weak. + +Split out of :mod:`python_pkg.code_tutor._llm` to keep it under the 250-line +cap. The ``anthropic`` import travels with the only class that uses it, so +``tests/test_llm.py`` patches it on this module and ``requests`` on ``_llm``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import anthropic + +if TYPE_CHECKING: + from collections.abc import Callable + + +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) diff --git a/python_pkg/code_tutor/_llm.py b/python_pkg/code_tutor/_llm.py index 84ba69b6..005bb457 100644 --- a/python_pkg/code_tutor/_llm.py +++ b/python_pkg/code_tutor/_llm.py @@ -12,6 +12,8 @@ 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 @@ -157,84 +159,6 @@ class OllamaBackend: 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. diff --git a/python_pkg/code_tutor/tests/test_llm.py b/python_pkg/code_tutor/tests/test_llm.py index 35830286..d7562d9c 100644 --- a/python_pkg/code_tutor/tests/test_llm.py +++ b/python_pkg/code_tutor/tests/test_llm.py @@ -7,8 +7,8 @@ from unittest.mock import MagicMock, patch import pytest +from python_pkg.code_tutor._claude_backend import ClaudeBackend from python_pkg.code_tutor._llm import ( - ClaudeBackend, OllamaBackend, _is_weak, complete_with_fallback, @@ -94,7 +94,8 @@ def test_claude_complete_with_text_block() -> None: mock_client.messages.create.return_value = mock_message with patch( - "python_pkg.code_tutor._llm.anthropic.Anthropic", return_value=mock_client + "python_pkg.code_tutor._claude_backend.anthropic.Anthropic", + return_value=mock_client, ): backend = ClaudeBackend() result = backend.complete("sys", "user") @@ -111,7 +112,8 @@ def test_claude_complete_no_text_attr() -> None: mock_client.messages.create.return_value = mock_message with patch( - "python_pkg.code_tutor._llm.anthropic.Anthropic", return_value=mock_client + "python_pkg.code_tutor._claude_backend.anthropic.Anthropic", + return_value=mock_client, ): backend = ClaudeBackend() result = backend.complete("sys", "user") @@ -135,7 +137,8 @@ def test_claude_stream_success() -> None: tokens: list[str] = [] with patch( - "python_pkg.code_tutor._llm.anthropic.Anthropic", return_value=mock_client + "python_pkg.code_tutor._claude_backend.anthropic.Anthropic", + return_value=mock_client, ): backend = ClaudeBackend() result = backend.stream("sys", "user", tokens.append) @@ -161,7 +164,8 @@ def test_claude_stream_api_error_fallback() -> None: tokens: list[str] = [] with patch( - "python_pkg.code_tutor._llm.anthropic.Anthropic", return_value=mock_client + "python_pkg.code_tutor._claude_backend.anthropic.Anthropic", + return_value=mock_client, ): backend = ClaudeBackend() result = backend.stream("sys", "user", tokens.append)