mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:47:03 +01:00
cli.py 345 -> 222 and _verifier.py 347 -> 228, via _cli_checks.py and _verifier_support.py. The cli seam cut straight through code covered by its per-file-ignores. Rather than copy S607 and PERF203 onto a second entry, both are fixed: systemctl is resolved with shutil.which, and the readiness poll no longer carries a try/except in its body now that the probe is its own function. Both ignores are deleted from meta/pyproject.toml and ruff passes without them - the repo has two fewer suppressions than before. The which() fix adds a systemctl-is-None branch, so the pass count goes 216 -> 217: one new test for it, no existing test touched. Coverage is back at 100% by covering the branch, not excluding it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
143 lines
5.3 KiB
Python
143 lines
5.3 KiB
Python
"""Prompts and source-snippet helpers for the lesson verifier.
|
|
|
|
Split out of :mod:`python_pkg.code_tutor._verifier` to keep it under the
|
|
250-line cap. ``Verifier`` itself stays there, because that is the name
|
|
``cli`` and ``_session`` construct and the tests patch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from python_pkg.code_tutor._analyzer import CodeItem
|
|
|
|
|
|
_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
|