Files
cleveragents-core/tools/_review_parser.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

282 lines
11 KiB
Python

"""Worker reply -> structured review verdict parser.
Extracted from ``_review_pipeline.py`` so the pipeline driver can
stay under the project's 500-line per-file budget. This module owns:
- :class:`WorkerJSONError` -- raised on any parse / shape error so
the dispatcher can surface a per-cycle telemetry entry with a
short message + a 500-char excerpt of the offending text.
- :func:`parse_worker_review_json` -- strict schema validator. The
dispatcher always re-runs this on the worker's raw reply rather
than trusting the pre-extracted ``parsed_json`` argument because
the generic JSON extractor in ``_opencode_worker`` is brace-balanced
but NOT schema-validated; a malformed ``event`` value would
otherwise reach :func:`_review_post.submit_review` and produce a
silent 422.
- :func:`_scan_top_level_objects` / :func:`_candidate_json_blobs`
-- string-aware brace counter and fenced-block scanner used by
the parser. Tolerates models that wrap the JSON in a code fence,
emit prose around it, or repeat the verdict twice.
Why a separate module rather than appending to
:mod:`_review_pipeline`: the parser is pure (no Forgejo I/O), and
isolating it keeps the failure-mode surface tiny — every caller is
a single :func:`parse_worker_review_json` invocation.
"""
from __future__ import annotations
import json
import re
from typing import Any
_REVIEW_EVENTS_REGULAR = ("APPROVED", "REQUEST_CHANGES", "COMMENT")
_REVIEW_EVENTS_CI_FLAG = ("REQUEST_CHANGES",)
class WorkerJSONError(ValueError):
"""Raised when the worker's final reply cannot be parsed into a
valid review verdict.
Carries a short ``message`` and an ``excerpt`` of the offending
text (truncated for log hygiene) so the dispatcher can surface
both to the cycle telemetry without leaking arbitrary worker
output into the wire-format API call.
"""
def __init__(self, message: str, excerpt: str = "") -> None:
super().__init__(message)
self.message = message
self.excerpt = excerpt[:500]
_JSON_OBJ_PATTERN = re.compile(r"\{[\s\S]*?\}")
def _scan_top_level_objects(text: str) -> list[str]:
"""Walk ``text`` and return every brace-balanced top-level JSON
object substring it contains, in order.
Implements a small string-aware brace counter (skips braces inside
string literals so a value like ``"description": "rate {200}/min"``
doesn't fool the matcher). Any backslash inside a string defers to
the next character so escaped quotes are handled.
Why hand-rolled instead of repeated ``json.JSONDecoder.raw_decode``:
``raw_decode`` raises on the first non-JSON byte, and our inputs
routinely include prose between JSON blocks. The balanced-brace
scanner is robust to any prose interleaving and has no failure
modes the parser cares about (the caller still validates each
candidate via ``json.loads``).
"""
out: list[str] = []
i = 0
n = len(text)
while i < n:
if text[i] != "{":
i += 1
continue
depth = 0
in_string = False
escape = False
start = i
while i < n:
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
else:
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
out.append(text[start : i + 1])
i += 1
break
i += 1
else:
# Reached end of text with unbalanced braces; abort the
# outer loop so we don't emit a partial object.
break
return out
def _candidate_json_blobs(text: str) -> list[str]:
"""Yield every plausible JSON object substring in ``text``.
The worker is instructed to emit *exactly one* JSON object as the
final reply. In practice models occasionally:
1. Wrap the JSON in a ``json`` code fence.
2. Repeat the JSON twice (once in a fence + once outside).
3. Emit a trailing prose paragraph after the JSON.
Strategy:
- Pull out every triple-backtick-fenced block whose content is a
single brace-balanced JSON object (case 1).
- Pull out every other brace-balanced top-level object found
anywhere in the text (case 2/3).
The caller iterates over the returned list in REVERSE so the LAST
plausible candidate wins, matching model UX expectations where the
final answer follows any earlier drafts.
"""
if not text:
return []
fenced: list[str] = []
fence_re = re.compile(r"```(?:json|JSON)?\s*([\s\S]*?)```", re.MULTILINE)
for match in fence_re.finditer(text):
candidate = match.group(1).strip()
if candidate.startswith("{") and candidate.rstrip().endswith("}"):
fenced.append(candidate)
# Brace-balanced scan over the whole text catches JSON outside any
# fence (case 2 / 3). The scanner is string-literal-aware so a
# ``"body": "hint {1}"`` value inside a comment doesn't trip it.
bare = _scan_top_level_objects(text)
return fenced + bare
def parse_worker_review_json(
raw_response: str,
*,
review_type: str,
head_sha: str,
) -> dict[str, Any]:
"""Extract and validate the worker's review verdict.
Returns a normalised dict with keys::
outcome: one of "review_drafted", "ci_flag_drafted",
"tier_1f_escalation", "skipped", "error"
review_type: echoed from input
review: present when outcome ∈ {"review_drafted",
"ci_flag_drafted"}; dict with event, body,
commit_id, comments
tier_1f_comment: present when outcome == "tier_1f_escalation"
notes: optional free-form note from the worker
files_touched: always [], echoed from worker
Raises ``WorkerJSONError`` on any parse / shape error. Schema
validation is strict because the dispatcher uses these values
directly in API call bodies — a malformed ``event`` could land
a malformed POST that produces a 400 with no recovery path.
"""
if not raw_response or not raw_response.strip():
raise WorkerJSONError("worker emitted no response", "")
candidates = _candidate_json_blobs(raw_response)
if not candidates:
raise WorkerJSONError(
"worker reply contained no JSON object", raw_response[-500:]
)
last_error: Exception | None = None
for candidate in reversed(candidates):
try:
parsed = json.loads(candidate)
except (json.JSONDecodeError, ValueError) as exc:
last_error = exc
continue
if not isinstance(parsed, dict):
last_error = ValueError(
f"top-level JSON must be an object (got {type(parsed).__name__})"
)
continue
outcome = parsed.get("outcome")
if outcome not in (
"review_drafted",
"ci_flag_drafted",
"tier_1f_escalation",
"skipped",
"error",
):
last_error = ValueError(
f"invalid outcome={outcome!r}; "
"expected review_drafted | ci_flag_drafted | "
"tier_1f_escalation | skipped | error"
)
continue
normalised: dict[str, Any] = {
"outcome": outcome,
"review_type": parsed.get("review_type") or review_type,
"files_touched": parsed.get("files_touched") or [],
}
if parsed.get("notes"):
normalised["notes"] = str(parsed.get("notes"))
if outcome in ("review_drafted", "ci_flag_drafted"):
review = parsed.get("review")
if not isinstance(review, dict):
last_error = ValueError(f"outcome={outcome} requires a 'review' object")
continue
allowed_events = (
_REVIEW_EVENTS_CI_FLAG
if outcome == "ci_flag_drafted"
else _REVIEW_EVENTS_REGULAR
)
event = review.get("event")
if event not in allowed_events:
last_error = ValueError(
f"review.event={event!r} not in {allowed_events}"
)
continue
body = review.get("body")
if not isinstance(body, str) or not body.strip():
last_error = ValueError("review.body must be a non-empty string")
continue
commit_id = review.get("commit_id") or head_sha
if not isinstance(commit_id, str) or not commit_id:
last_error = ValueError("review.commit_id must be a non-empty string")
continue
comments_raw = review.get("comments") or []
if not isinstance(comments_raw, list):
last_error = ValueError("review.comments must be a list")
continue
normalised_comments: list[dict[str, Any]] = []
for entry in comments_raw:
if not isinstance(entry, dict):
continue
path = entry.get("path")
cbody = entry.get("body")
if not isinstance(path, str) or not isinstance(cbody, str):
continue
comment_obj = {"path": path, "body": cbody}
new_pos = entry.get("new_position")
if isinstance(new_pos, int):
comment_obj["new_position"] = new_pos
old_pos = entry.get("old_position")
if isinstance(old_pos, int):
comment_obj["old_position"] = old_pos
normalised_comments.append(comment_obj)
normalised["review"] = {
"event": event,
"body": body,
"commit_id": commit_id,
"comments": normalised_comments,
}
elif outcome == "tier_1f_escalation":
comment_text = parsed.get("tier_1f_comment")
if not isinstance(comment_text, str) or not comment_text.strip():
last_error = ValueError(
"outcome=tier_1f_escalation requires non-empty 'tier_1f_comment'"
)
continue
normalised["tier_1f_comment"] = comment_text
return normalised
raise WorkerJSONError(
f"could not extract a valid review verdict: {last_error}",
raw_response[-500:],
)
__all__ = (
"WorkerJSONError",
"parse_worker_review_json",
)