Files
ai-workflow-course/modules/24-assistive-agents/lab/reviewer.py
T
claude f98eacb196 fix(testing/ci/tooling): consistent unittest, venv guidance, runnable lab commands
- #9: standardize the test chain on stdlib unittest (nothing-to-install, which
  keeps M13's claims true and its planted bug intact). Aligned M5/M14/M16 prose,
  M14 lab/test_tasks.py, and ci/gitlab starters; ruff stays the only pip install.
- #20: add venv / PEP 668 / which-python guidance to M20 (+ M14/M15 local
  installs); point MCP config at the venv's absolute python.
- #21: replace M21 Part D's empty `git diff HEAD~1` with `git log -p` (no
  .gitignore added — device preserved).
- #22: add a dependency-install step before M23's green baseline on a fresh clone.
- #23: M24 reviewer/triage now tolerate code-fence-wrapped JSON (stdlib only);
  feature.patch trap untouched.
- #28: fix M27 Part D CI snippet path (working-directory) and require the gate to
  target a varying candidate; swapped_model regression kept as the fixture.

Closes #9
Closes #20
Closes #21
Closes #22
Closes #23
Closes #28

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TfzV5QvtPDz8LJS3Pu5VLT
2026-06-22 16:07:47 -04:00

117 lines
4.4 KiB
Python

"""Assistive AI reviewer — local simulation of a PR-reviewer bot.
This stands in for a forge-native reviewer (an app/bot triggered when a PR opens, running on a
runner from Module 19) without needing any hosted account. It does the two deterministic halves of
the job and leaves the one judgment call — what actually happens to the PR — to you.
python reviewer.py prompt # assemble the prompt: rubric + diff. Paste to your AI.
python reviewer.py apply ai-review.sample.json # ingest the AI's JSON, render it, gate it
The point of this module: the agent produces comments and a recommendation. It never approves,
never requests-changes-as-a-gate, never merges. The `apply` step ends at a HUMAN DECISION, every
time. Stdlib only — no pip install.
"""
import argparse
import json
import sys
from pathlib import Path
HERE = Path(__file__).parent
def load_json_response(path: Path):
"""Parse the JSON the AI returned.
Chat assistants very often wrap their output in a ```json ... ``` code fence (or add a line of
prose) even when told to "return only the JSON" — so a strict json.loads on the raw paste fails
on the most likely real output. Try a strict parse first; if that fails, fall back to the
outermost { ... } block, which survives a code fence or surrounding text. Stdlib only."""
raw = path.read_text()
try:
return json.loads(raw)
except json.JSONDecodeError:
start, end = raw.find("{"), raw.rfind("}")
if start != -1 and end > start:
return json.loads(raw[start : end + 1])
raise
PROMPT_HEADER = """\
You are an assistive code reviewer. Follow the rubric below exactly, then review the diff that
follows it. Return ONLY the JSON object the rubric specifies — no prose before or after.
================ REVIEW RUBRIC ================
{rubric}
================ DIFF UNDER REVIEW ============
{diff}
"""
def cmd_prompt(args: argparse.Namespace) -> int:
rubric = Path(args.rubric).read_text()
diff = Path(args.patch).read_text()
print(PROMPT_HEADER.format(rubric=rubric, diff=diff))
return 0
def cmd_apply(args: argparse.Namespace) -> int:
try:
review = load_json_response(Path(args.response))
except (json.JSONDecodeError, FileNotFoundError) as exc:
print(f"error: could not read a JSON review from {args.response}: {exc}")
return 1
summary = review.get("summary", "(no summary)")
recommendation = review.get("recommendation", "comment")
comments = review.get("comments", [])
print("=" * 70)
print("AI REVIEWER — first pass (advisory only)")
print("=" * 70)
print(f"\nSummary: {summary}\n")
if not comments:
print("No line comments.\n")
order = {"blocker": 0, "suggestion": 1, "nit": 2}
for c in sorted(comments, key=lambda c: order.get(c.get("severity", "nit"), 9)):
sev = c.get("severity", "nit").upper()
loc = f"{c.get('file', '?')}:{c.get('line', '?')}"
print(f" [{sev:10}] {loc}")
print(f" {c.get('comment', '')}\n")
print("-" * 70)
print(f"Agent's recommendation: {recommendation}")
print("-" * 70)
print(
"\nThis is the human decision gate. The agent did NOT merge, approve, or block.\n"
"It only commented. You decide what happens next:\n"
" - merge you read the comments, you disagree or they're addressed\n"
" - request changes you agree; push the fix on the branch and re-run\n"
" - dismiss the agent is wrong or noisy; ignore and move on\n"
"\nNothing in this repo changes until you act. That's the whole point of Module 24.\n"
)
return 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("prompt", help="assemble the review prompt to paste to your AI")
p.add_argument("--rubric", default=str(HERE / "review-rubric.md"))
p.add_argument("--patch", default=str(HERE / "feature.patch"))
p.set_defaults(func=cmd_prompt)
a = sub.add_parser("apply", help="ingest the AI's JSON review and render the decision gate")
a.add_argument("response", help="path to the JSON the AI returned")
a.set_defaults(func=cmd_apply)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))