Files
Gitea-Tools/webui/audit_validator.py
sysadminandClaude Opus 4.8 0e701d578a feat: add final-report audit paste tool to web UI (Closes #431)
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]>
2026-07-07 15:20:47 -04:00

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 "",
}