testsAndMisc/python_pkg/code_tutor/_verifier.py
Krzysztof kuhy Rudnicki 277cb9fd6f Split the code_tutor challenge flows out of _challenge
_challenge.py 467 -> 236. The two flows and run_coding_challenge go to
_challenge_flows.py; the three static prompt strings to
_challenge_prompts.py, which covered the last eleven lines after the
first cut landed at 261.

The 13 patch strings in test_challenge_part3.py were repointed per test,
by which function is under test - the _run_user_impl tests keep
_challenge. Getting that wrong does not fail here, it HANGS: an
unintercepted _collect_and_rate_tests ran the real interactive flow and
spun the suite for two minutes. Runs are now under an explicit timeout,
and the patch targets are asserted to resolve before pytest starts.

Pass count is 216 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 17:42:50 +02:00

348 lines
12 KiB
Python

"""Socratic lesson loop: ask-first, then judge, then explain.
The LLM explanation is NEVER shown before the user attempts to explain.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import time
from typing import TYPE_CHECKING
from rich.live import Live
from rich.panel import Panel
from rich.text import Text
from python_pkg.code_tutor._challenge_flows import run_coding_challenge
from python_pkg.code_tutor._progress import LessonRecord
if TYPE_CHECKING:
from collections.abc import Callable
from rich.console import Console
from python_pkg.code_tutor._analyzer import CodeItem
from python_pkg.code_tutor._llm import Backend
_MAX_ATTEMPTS = 3
_SYSTEM_PROMPT = (
"You are a fair but rigorous code tutor assessing whether a student"
" genuinely understands a piece of code.\n\n"
"Grade their explanation PASS or FAIL.\n\n"
"PASS when the student demonstrates understanding of:\n"
" - what problem the code solves (purpose)\n"
" - what goes in (inputs/parameters and their roles)\n"
" - what comes out or happens (return value or side effect)\n"
" - any key behavior that would surprise a reader"
" (edge cases, error handling, non-obvious constraints)\n\n"
"You do NOT require verbatim phrasing from the code or docstring.\n"
"Correct paraphrasing counts as understanding.\n"
"ONLY cite a gap if it is genuinely absent from the student's answer"
" -- do NOT cite a gap the student already covered in different words.\n\n"
"FAIL only if the student is vague, factually wrong, or missed a key"
" behavioral detail that materially changes how the code is used.\n"
'"It does some stuff" = FAIL. "It processes data" = FAIL.'
' But "it reads the first 512 bytes and checks for a null byte" = PASS'
" on the behavior criterion, even if they didn't mention OSError.\n\n"
"Respond with valid JSON only, no other text:\n"
'{"verdict": "PASS" | "FAIL",'
' "gap": "<one sentence on the specific missing piece, or empty string on PASS>"}'
)
_EXPLAIN_SYSTEM = (
"You are a code tutor."
" Explain the following code clearly and concisely in 3-5 sentences."
)
_QUESTIONS: tuple[tuple[str, str], ...] = (
("Purpose", "What does this code do?"),
("Inputs", "What are the inputs (parameters / arguments)?"),
("Outputs", "What does it output or do as a side effect?"),
("Why", "Why does it exist? What problem does it solve?"),
)
_IMPROVEMENT_Q = "What would you improve or simplify here? (Enter to skip)"
def _class_header(lines: list[str], class_name: str, before_line: int) -> str:
"""Find the class definition for *class_name* and return a short header.
Searches backward from *before_line* for ``class <class_name>``. Returns
the class signature plus up to 4 following lines (docstring / key attrs).
Args:
lines: All source lines of the file (0-indexed).
class_name: Name of the enclosing class.
before_line: 1-based line number of the method; search stops here.
Returns:
A short string summary of the class, or ``""`` if not found.
"""
pattern = re.compile(rf"^class\s+{re.escape(class_name)}\b")
for i in range(min(before_line - 1, len(lines)) - 1, -1, -1):
if pattern.match(lines[i]):
snippet_end = min(i + 5, before_line - 1)
return "\n".join(lines[i:snippet_end])
return ""
def _read_snippet(item: CodeItem, codebase_path: str) -> str:
"""Read source lines for *item* from *codebase_path*.
When the item belongs to a class, a short class header is prepended so
the user has context about what ``self`` refers to.
Args:
item: Code item whose file and line range to read.
codebase_path: Absolute path of the codebase root.
Returns:
The extracted source lines as a single string, or a placeholder
message when the file cannot be read.
"""
method_src = ""
try:
text = (Path(codebase_path) / item.file).read_text(
encoding="utf-8", errors="replace"
)
lines = text.splitlines()
start = max(0, item.start_line - 1)
end = min(len(lines), item.end_line)
method_src = "\n".join(lines[start:end])
if item.class_name:
header = _class_header(lines, item.class_name, item.start_line)
if header:
return (
f"# class {item.class_name} (context):\n{header}"
f"\n\n# method:\n{method_src}"
)
except OSError:
return f"(source unavailable for {item.file})"
return method_src
def _parse_verdict(raw: str) -> tuple[str, str]:
"""Parse the LLM's JSON verdict, tolerating markdown code fences.
Args:
raw: Raw text returned by the judge LLM.
Returns:
Tuple of ``(verdict, gap)`` where *verdict* is ``"PASS"`` or
``"FAIL"`` and *gap* is a one-sentence explanation of what was wrong.
Returns ``("FAIL", ...)`` on any parse failure.
"""
clean = re.sub(r"```(?:json)?\s*", "", raw, flags=re.DOTALL).strip()
start = clean.find("{")
end = clean.rfind("}") + 1
if start == -1 or end == 0:
return "FAIL", "Could not parse response from judge."
try:
data = json.loads(clean[start:end])
except json.JSONDecodeError:
return "FAIL", "Could not parse response from judge."
verdict = str(data.get("verdict", "FAIL")).upper()
if verdict not in {"PASS", "FAIL"}:
verdict = "FAIL"
gap = str(data.get("gap", "No specific gap identified."))
return verdict, gap
class Verifier:
"""Runs Socratic lessons: user explains first, then the LLM judges.
Args:
backend: LLM backend used for both judging and generating explanations.
console: Rich console for all output.
"""
def __init__(self, backend: Backend, console: Console) -> None:
"""Store backend and console."""
self._backend = backend
self._console = console
def _judge(self, snippet: str, explanation: str) -> tuple[str, str]:
"""Stream the judge call, show elapsed time, accumulate and parse.
The raw JSON is streamed silently; a live elapsed-time counter shows
the user that inference is progressing.
Args:
snippet: Source code the user was shown.
explanation: The user's explanation text.
Returns:
Tuple ``(verdict, gap)`` from ``_parse_verdict``.
"""
user_msg = f"Code:\n{snippet}\n\nUser's explanation:\n{explanation}"
start = time.monotonic()
parts: list[str] = []
def _on_token(token: str) -> None:
parts.append(token)
elapsed = int(time.monotonic() - start)
live.update(Text(f"Judging... {elapsed}s", style="yellow"))
with Live(
Text("Judging... 0s", style="yellow"),
console=self._console,
refresh_per_second=4,
transient=True,
) as live:
self._backend.stream(_SYSTEM_PROMPT, user_msg, _on_token)
return _parse_verdict("".join(parts))
def _collect_answers(
self, input_fn: Callable[[str], str]
) -> tuple[dict[str, str], bool]:
"""Ask the four core questions and collect answers.
Args:
input_fn: Callable used for reading user input.
Returns:
Tuple of ``(answers, skipped)`` where *answers* maps question
labels to the user's text. *skipped* is ``True`` when the user
typed ``skip`` on any question.
"""
answers: dict[str, str] = {}
for label, question in _QUESTIONS:
self._console.print(f"[bold]{question}[/bold]")
answer = input_fn("> ").strip()
if answer.lower() == "skip":
self._console.print("[yellow]Skipped.[/yellow]\n")
return {}, True
answers[label] = answer
return answers, False
def _ask_improvement(self, input_fn: Callable[[str], str]) -> str:
"""Prompt for an optional code-improvement note.
Args:
input_fn: Callable used for reading user input.
Returns:
The user's improvement idea, or ``""`` if they pressed Enter.
"""
self._console.print(f"\n[dim]{_IMPROVEMENT_Q}[/dim]")
return input_fn("> ").strip()
def run_lesson(
self,
item: CodeItem,
codebase_path: str,
*,
input_fn: Callable[[str], str] = input,
) -> LessonRecord:
"""Run one Socratic lesson for *item* and return a full transcript.
Shows the code, asks four sequential questions, judges the answer, and
repeats up to ``_MAX_ATTEMPTS`` times. On PASS, offers an optional
coding challenge (rewrite from scratch, validated by tests) then asks
the improvement question. Never reveals the correct explanation before
the user has tried.
Args:
item: The code item to study.
codebase_path: Absolute path of the codebase root.
input_fn: Callable used for reading user input.
Returns:
``LessonRecord`` with ``outcome`` of ``"learned"``,
``"struggled"``, or ``"skipped"``.
"""
snippet = _read_snippet(item, codebase_path)
timestamp = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
lines_str = f"{item.start_line}-{item.end_line}"
title = f"{item.file} lines {lines_str}"
self._console.print(Panel(snippet, title=title, border_style="blue"))
self._console.print(
"\nAnswer each question before I say anything. "
"Type [dim]skip[/dim] on any question to skip this item.\n"
)
answers: dict[str, str] = {}
for attempt in range(1, _MAX_ATTEMPTS + 1):
collected, skipped = self._collect_answers(input_fn)
if skipped:
return LessonRecord(
timestamp=timestamp,
item_id=item.id,
file=item.file,
lines=lines_str,
snippet=snippet,
outcome="skipped",
answers={},
improvement="",
verdict="skipped",
attempt=attempt,
)
answers = collected
explanation = "\n".join(f"{k}: {v}" for k, v in answers.items())
verdict, gap = self._judge(snippet, explanation)
if verdict == "PASS":
self._console.print(
f"[green]✓ PASS[/green] (attempt {attempt}/{_MAX_ATTEMPTS})\n"
)
challenge_result = run_coding_challenge(
item,
codebase_path,
explanation,
self._backend,
self._console,
input_fn,
)
improvement = self._ask_improvement(input_fn)
return LessonRecord(
timestamp=timestamp,
item_id=item.id,
file=item.file,
lines=lines_str,
snippet=snippet,
outcome="learned",
answers=answers,
improvement=improvement,
verdict="PASS",
attempt=attempt,
challenge_result=challenge_result,
)
if attempt < _MAX_ATTEMPTS:
self._console.print(f"[red]✗ FAIL[/red] -- {gap}")
self._console.print(f"Try again ({attempt}/{_MAX_ATTEMPTS}):\n")
self._console.print(
f"[red]After {_MAX_ATTEMPTS} attempts -- correct explanation:[/red]\n"
)
self._backend.stream(
_EXPLAIN_SYSTEM,
f"Explain:\n{snippet}",
lambda token: self._console.print(
token, end="", markup=False, highlight=False
),
)
self._console.print("\n")
improvement = self._ask_improvement(input_fn)
return LessonRecord(
timestamp=timestamp,
item_id=item.id,
file=item.file,
lines=lines_str,
snippet=snippet,
outcome="struggled",
answers=answers,
improvement=improvement,
verdict="FAIL",
attempt=_MAX_ATTEMPTS,
)