Expose /audit and /api/audit for local validator previews using final_report_validator and review schema checks. Operators can paste reports, auto-detect task kind, and get structured findings with suggested next prompts and issue-comment drafts. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
176 lines
6.8 KiB
Python
176 lines
6.8 KiB
Python
"""Final-report audit preview for the web UI (#431)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from final_report_validator import FINAL_REPORT_TASK_KINDS, assess_final_report_validator
|
|
|
|
_TASK_KIND_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
("review_pr", re.compile(r"\b(?:review\s+pr|task\s*:\s*review|review-merge-pr)\b", re.I)),
|
|
(
|
|
"reconcile_already_landed",
|
|
re.compile(
|
|
r"\b(?:reconcile\s+already[- ]landed|already[- ]landed\s+pr|"
|
|
r"reconcile-landed-pr)\b",
|
|
re.I,
|
|
),
|
|
),
|
|
("work_issue", re.compile(r"\b(?:work[- ]issue|task\s*:\s*work|selected\s+issue\s*:)\b", re.I)),
|
|
("issue_filing", re.compile(r"\b(?:issue\s+filing|create\s+issue|task\s*:\s*file)\b", re.I)),
|
|
("issue_selection", re.compile(r"\b(?:issue\s+selection|select\s+next\s+issue)\b", re.I)),
|
|
("inventory", re.compile(r"\b(?:issue\s+inventory|queue\s+inventory)\b", re.I)),
|
|
)
|
|
|
|
_SUGGESTED_PROMPTS: dict[str, str] = {
|
|
"review_pr": (
|
|
"Review the next eligible open PR in this project. Merge it only if every "
|
|
"gate passes. Post a final report matching review-merge-pr schema."
|
|
),
|
|
"work_issue": (
|
|
"Find the next eligible issue in this project, work on it only if all gates "
|
|
"pass, and create a PR when complete."
|
|
),
|
|
"reconcile_already_landed": (
|
|
"Reconcile the oldest eligible already-landed open PR. Post read-only audit "
|
|
"first; authorize cleanup only when required."
|
|
),
|
|
"issue_filing": "File a new Gitea issue using the canonical issue-filing workflow.",
|
|
"issue_selection": "Build a complete open-issue inventory and select the next eligible issue.",
|
|
"inventory": "Produce a proof-backed inventory of open PRs or issues without mutating state.",
|
|
}
|
|
|
|
|
|
def infer_task_kind(report_text: str, explicit: str | None = None) -> str:
|
|
"""Infer workflow task kind from explicit selection or report content."""
|
|
if explicit:
|
|
normalized = explicit.strip().lower().replace(" ", "_")
|
|
if normalized in FINAL_REPORT_TASK_KINDS:
|
|
return normalized
|
|
text = report_text or ""
|
|
for kind, pattern in _TASK_KIND_PATTERNS:
|
|
if pattern.search(text):
|
|
return kind
|
|
if re.search(r"\brole\s*:\s*reviewer\b", text, re.I):
|
|
return "review_pr"
|
|
if re.search(r"\brole\s*:\s*author\b", text, re.I):
|
|
return "work_issue"
|
|
return "review_pr"
|
|
|
|
|
|
def _merge_findings(*result_sets: dict[str, Any]) -> list[dict[str, str]]:
|
|
seen: set[tuple[str, str, str]] = set()
|
|
merged: list[dict[str, str]] = []
|
|
for result in result_sets:
|
|
for finding in result.get("findings") or []:
|
|
key = (
|
|
str(finding.get("rule_id", "")),
|
|
str(finding.get("field", "")),
|
|
str(finding.get("reason", "")),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
merged.append(finding)
|
|
return merged
|
|
|
|
|
|
def _aggregate_result(task_kind: str, findings: list[dict[str, str]], base: dict[str, Any]) -> dict[str, Any]:
|
|
blocked = any(f.get("severity") == "block" for f in findings)
|
|
downgraded = any(f.get("severity") == "downgrade" for f in findings)
|
|
warning = any(f.get("severity") == "warning" for f in findings)
|
|
if blocked:
|
|
grade = "blocked"
|
|
elif downgraded:
|
|
grade = "downgraded"
|
|
elif warning:
|
|
grade = "warning"
|
|
else:
|
|
grade = base.get("grade") or "A"
|
|
safe_next_action = base.get("safe_next_action") or "proceed"
|
|
for finding in findings:
|
|
if finding.get("severity") in {"block", "downgrade"}:
|
|
safe_next_action = finding.get("safe_next_action") or safe_next_action
|
|
break
|
|
return {
|
|
"task_kind": task_kind,
|
|
"inferred_task_kind": task_kind,
|
|
"grade": grade,
|
|
"blocked": blocked or bool(base.get("blocked")),
|
|
"downgraded": downgraded or bool(base.get("downgraded")),
|
|
"findings": findings,
|
|
"reasons": [f"{f.get('rule_id')}: {f.get('reason')}" for f in findings],
|
|
"safe_next_action": safe_next_action,
|
|
"complete": grade == "A" and not findings,
|
|
"suggested_next_prompt": _SUGGESTED_PROMPTS.get(task_kind, ""),
|
|
"suggested_issue_comment": _build_issue_comment(findings, safe_next_action),
|
|
}
|
|
|
|
|
|
def _build_issue_comment(findings: list[dict[str, str]], safe_next_action: str) -> str:
|
|
if not findings:
|
|
return ""
|
|
lines = [
|
|
"## Report audit preview (local validator)",
|
|
"",
|
|
"Automated findings from pasted final report:",
|
|
"",
|
|
]
|
|
for finding in findings[:12]:
|
|
severity = finding.get("severity", "info")
|
|
rule_id = finding.get("rule_id", "unknown")
|
|
reason = finding.get("reason", "")
|
|
lines.append(f"- **{severity}** `{rule_id}` — {reason}")
|
|
if len(findings) > 12:
|
|
lines.append(f"- …and {len(findings) - 12} more finding(s)")
|
|
lines.extend(["", f"Safe next action: {safe_next_action}", ""])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def audit_report(report_text: str, *, task_kind: str | None = None) -> dict[str, Any]:
|
|
"""Run composable final-report validation on pasted text."""
|
|
text = (report_text or "").strip()
|
|
if not text:
|
|
return {
|
|
"task_kind": task_kind or "review_pr",
|
|
"inferred_task_kind": task_kind or "review_pr",
|
|
"grade": "blocked",
|
|
"blocked": True,
|
|
"downgraded": False,
|
|
"findings": [],
|
|
"reasons": ["empty report text"],
|
|
"safe_next_action": "paste a non-empty final report",
|
|
"complete": False,
|
|
"suggested_next_prompt": "",
|
|
"suggested_issue_comment": "",
|
|
}
|
|
|
|
resolved_kind = infer_task_kind(text, task_kind)
|
|
base = assess_final_report_validator(text, resolved_kind)
|
|
findings = list(base.get("findings") or [])
|
|
|
|
if resolved_kind == "review_pr":
|
|
from review_final_report_schema import assess_review_final_report_schema
|
|
|
|
schema = assess_review_final_report_schema(text)
|
|
findings = _merge_findings(base, schema)
|
|
|
|
return _aggregate_result(resolved_kind, findings, base)
|
|
|
|
|
|
def audit_to_dict(result: dict[str, Any]) -> dict[str, Any]:
|
|
"""JSON-safe export for /api/audit."""
|
|
return {
|
|
"task_kind": result.get("task_kind"),
|
|
"inferred_task_kind": result.get("inferred_task_kind"),
|
|
"grade": result.get("grade"),
|
|
"blocked": result.get("blocked"),
|
|
"downgraded": result.get("downgraded"),
|
|
"complete": result.get("complete"),
|
|
"safe_next_action": result.get("safe_next_action"),
|
|
"findings": result.get("findings") or [],
|
|
"reasons": result.get("reasons") or [],
|
|
"suggested_next_prompt": result.get("suggested_next_prompt") or "",
|
|
"suggested_issue_comment": result.get("suggested_issue_comment") or "",
|
|
} |