0bc734c020
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>
282 lines
11 KiB
Python
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",
|
|
)
|