Files
ai-workflow-course/modules/24-assistive-agents/lab/reviewer.py
T
justin 3221f7abe8
CI / check (pull_request) Successful in 7s
Use python3 as the canonical command name course-wide (#104)
Most current systems (default Debian/Ubuntu, recent macOS) install Python
only as `python3`, with no bare `python` on PATH, so learners who copied
`python cli.py ...` into their host shell hit "command not found".

Convert host-shell `python <cmd>` -> `python3 <cmd>` across module/lab
READMEs, lab `.py` docstrings & usage strings, blog posts, lab prompt and
instruction files, the M04 verify.sh message, and the M10/M24 lab patches.
Module 01's convention note (and its blog/02 mirror) is rewritten so
`python3` is canonical and `python` is the documented fallback.

Stop-lines respected: Docker image tags (`python:3.12-slim`), `.venv/.../python`
and `...\.venv\Scripts\python.exe` paths, the M20 `"command": "python"`
teaching example and surrounding venv prose, container-internal invocations
(M16/M18 Dockerfiles, M16 README `docker run` examples), and CI-workflow
`run:` steps fed by `actions/setup-python` / `image: python:3.12` are left
as `python` on purpose.

pip was left out of scope: most occurrences are prose or CI/container-internal,
and `pip3` does not fix the PEP 668 externally-managed-environment refusal that
the course already addresses with venvs. The M01 note is worded to stay
consistent with bare `pip` (use whichever pip pairs with your Python).

Build (tools/build_wiki.py) and tools/check.sh both pass.

Closes #104

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAEzanEoGJT5o1VizQar47
2026-06-23 20:18:04 -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.
python3 reviewer.py prompt # assemble the prompt: rubric + diff, for the agent to review
python3 reviewer.py apply ai-review.sample.json # ingest the agent'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 stray
line of text) 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, with no extra text 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 for the agent to act on")
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:]))