Add required human review gate to the Agent pipeline.
Docker Release / build-and-push (push) Successful in 1m10s
Docker Release / release (push) Skipped

Agent web jobs now stop after Brain consolidation and enter needs_review
with a persisted review queue (blocking: high-severity, low-confidence,
sensitive-category findings; audit sample of clean clusters). Humans
decide confirm/reject/unsure/needs_clarification via new review API and
frontend queue; a finalizer applies decisions (rejections suppressed with
reason codes), performs bounded targeted reruns for clarifications,
drafts RFIs only for kept issues, and only then marks the job done and
sends the final email. Two-phase email (review-required, then final
report), per-decision feedback labels with redacted aggregate metrics,
restart recovery from job artifacts, and CLI --no-review bypass.
Classic pipeline unchanged. 65 non-LLM tests.
This commit is contained in:
John Wilganowski
2026-07-28 19:23:57 +00:00
parent ac328d34fd
commit 1c1d2ff21b
27 changed files with 3344 additions and 22 deletions
+1
View File
@@ -0,0 +1 @@
"""Human-review gate: decision schemas and review-trigger policy."""
+51
View File
@@ -0,0 +1,51 @@
"""Feedback labels: one label artifact per human-review decision, for metrics."""
import json
import os
from datetime import datetime, timezone
def _as_dict(value) -> dict:
return value if isinstance(value, dict) else {}
def decision_to_label(queue_item: dict, decision: dict, job: dict) -> dict:
"""Build one feedback label from a queue item, its decision, and the job.
All field access is defensive: missing fields degrade to None (or [] for
models_used) rather than raising.
"""
queue_item = _as_dict(queue_item)
decision = _as_dict(decision)
job = _as_dict(job)
payload = _as_dict(queue_item.get("payload"))
summary = _as_dict(_as_dict(job.get("report")).get("summary"))
return {
"review_item_id": queue_item.get("review_item_id"),
"job_id": job.get("job_id"),
"pipeline_mode": job.get("pipeline_mode"),
"source_stage": payload.get("source_stage"),
"category": payload.get("category"),
"severity": payload.get("severity"),
"confidence": payload.get("confidence"),
"decision": decision.get("decision"),
"reason_code": decision.get("reason_code"),
"location": payload.get("location"),
"disciplines": payload.get("disciplines"),
"sheets": payload.get("sheets"),
"drawing_type": payload.get("drawing_type"),
"models_used": summary.get("models_used") or [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
def write_label(out_dir: str, label: dict) -> None:
"""Append one label as a JSON line; never raises on I/O failure."""
try:
review_dir = os.path.join(out_dir, "review")
os.makedirs(review_dir, exist_ok=True)
path = os.path.join(review_dir, "feedback_labels.jsonl")
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(label) + "\n")
except OSError as e:
print(f"[Review] feedback label write failed: {e}")
+255
View File
@@ -0,0 +1,255 @@
"""ReviewFinalizer: apply human decisions, targeted reruns, RFIs, final artifacts.
All LLM-touching helpers degrade gracefully: a failed or empty targeted rerun
becomes a visible ``analysis_gap`` finding instead of raising, and RFI drafting
returns whatever was produced (possibly []). Finalization never crashes the job
on a single bad scope.
"""
import json
import os
from typing import Dict, List, Optional, Tuple
from backend import config
from backend.agents.base import AgentScope, AgentUsage
from backend.agents.conflict_critic import ConflictCriticAgent
from backend.agents.memory import ProjectMemory
from backend.agents.orchestrator import Orchestrator
from backend.agents.rfi_writer import RFIWriterAgent
# Private import, acceptable here: the runner's _finding_as_conflict is the
# canonical finding -> report["conflicts"] mapping; reusing it keeps the
# finalized report's conflicts in exactly the shape build_report produces.
from backend.agents.runner import _finding_as_conflict
from backend.pipeline.report import to_markdown
from backend.review.store import ReviewStore
def apply_decisions(prioritized: List[dict], decisions: Dict[str, dict]) -> Tuple[List[dict], List[dict]]:
kept: List[dict] = []
suppressed: List[dict] = []
for issue in prioritized:
review_id = f"finding:{issue.get('issue_id')}"
decision = decisions.get(review_id) or {}
action = decision.get("decision")
if action == "reject":
suppressed.append({
**issue,
"review_state": "rejected",
"reason_code": decision.get("reason_code"),
"review_comment": decision.get("comment") or "",
})
elif action == "unsure":
kept.append({**issue, "review_state": "unsure"})
else:
kept.append({**issue, "review_state": "confirmed" if action == "confirm" else "unreviewed"})
return kept, suppressed
def _gap_finding(index: int, scope_id: str, description: str) -> dict:
"""Same shape as the runner's gap_findings: low severity, high confidence."""
return {
"issue_id": f"AGENT-GAP-CLARIFY-{index + 1:03d}",
"source_stage": "qaqc",
"category": "analysis_gap",
"severity": "low",
"confidence": "high",
"location": scope_id.split(":", 2)[1] if ":" in scope_id else "",
"disciplines": [],
"sheets": [],
"description": description,
"evidence": [],
"recommended_resolution": "Review this scope manually or rerun the job.",
"code_reference": None,
"agent": "completeness",
"scope_id": scope_id,
}
def rerun_clarified_scopes(
memory_snapshot: dict,
decisions: Dict[str, dict],
prioritized: Optional[List[dict]] = None,
) -> List[dict]:
"""Bounded targeted reruns for ``needs_clarification`` decisions.
v1 reruns conflict scopes only: at most ONE ConflictCriticAgent scope per
clarified finding. The clarification answer is injected as a pseudo
"Reviewer" assertion prepended to the cluster's assertions so it reaches
the critic's evidence block (and survives front-truncation to
AGENT_CLUSTER_MAX_ASSERTIONS); ``page_to_b64`` is empty (cluster
assertions may carry their own
base64). Every per-scope failure degrades to an ``analysis_gap`` finding
and never raises. Non-conflict scopes are NOT rerun; they produce an
``analysis_gap`` noting the scope is not rerunnable in v1.
"""
findings_pool = list(prioritized or []) + list(memory_snapshot.get("findings") or [])
clusters = memory_snapshot.get("clusters") or []
out: List[dict] = []
for item_id, decision in (decisions or {}).items():
if (decision or {}).get("decision") != "needs_clarification":
continue
answer = str(decision.get("clarification_answer") or "").strip()
if not answer:
continue
issue_id = item_id.split(":", 1)[1] if item_id.startswith("finding:") else item_id
finding = next((f for f in findings_pool if f.get("issue_id") == issue_id), None)
scope_id = str((finding or {}).get("scope_id") or "")
if not scope_id.startswith("conflict:"):
out.append(_gap_finding(
len(out), scope_id or item_id,
f"Clarification rerun not supported in v1 for non-conflict scope "
f"{scope_id or item_id!r} (finding {issue_id}).",
))
continue
cluster_key = scope_id.split(":", 1)[1]
cluster = next((c for c in clusters if c.get("key") == cluster_key), None)
if cluster is None:
out.append(_gap_finding(
len(out), scope_id,
f"Clarification rerun failed: cluster {cluster_key!r} not found "
f"for scope {scope_id} (finding {issue_id}).",
))
continue
rerun_cluster = {
**cluster,
# Prepend: ConflictCriticAgent truncates assertions from the front
# (AGENT_CLUSTER_MAX_ASSERTIONS), so the clarification must come
# first or a full cluster would silently drop it.
"assertions": [{
"discipline": "Reviewer",
"sheet_number": "REVIEW",
"attribute": "clarification",
"value": answer,
"source_text": answer,
}] + list(cluster.get("assertions") or []),
}
scope = AgentScope(
scope_id=scope_id,
payload={"cluster": rerun_cluster, "page_to_b64": {}},
)
result = ConflictCriticAgent(AgentUsage()).run(scope)
if result.error or not result.artifacts:
out.append(_gap_finding(
len(out), scope_id,
f"Clarification rerun did not complete for scope {scope_id} "
f"(finding {issue_id}): {result.error or 'no findings produced'}.",
))
continue
for rerun_finding in result.artifacts:
rerun_finding["clarification_of"] = issue_id
out.append(rerun_finding)
return out
def _draft_rfis(kept: List[dict]) -> List[dict]:
"""Draft RFIs for kept issues only, mirroring the runner's wave 7."""
orchestrator = Orchestrator(ProjectMemory())
scopes = [
AgentScope(
scope_id=f"rfi:{finding.get('issue_id') or index + 1}",
payload={"finding": finding},
)
for index, finding in enumerate(kept)
]
try:
results = orchestrator.run_scopes(
RFIWriterAgent(AgentUsage()), scopes, config.AGENT_RFI_CONCURRENCY
)
except Exception:
return []
return [artifact for result in results for artifact in result.artifacts]
def _read_json(path: str, default):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return default
def _dump(out_dir: str, name: str, value) -> None:
with open(os.path.join(out_dir, name), "w", encoding="utf-8") as f:
json.dump(value, f, indent=2)
def finalize_review(job_id: str, out_dir: str) -> dict:
"""Apply review decisions and write the final report artifacts.
Raises ValueError("incomplete review") if any blocking queue item lacks a
decision. Never raises for rerun/RFI degradation.
"""
store = ReviewStore(out_dir)
queue = store.read_queue()
decisions = store.read_decisions()
for item in queue:
if item.get("blocking") and item.get("review_item_id") not in decisions:
raise ValueError("incomplete review")
report = _read_json(os.path.join(out_dir, "conflicts.json"), {}) or {}
snapshot = _read_json(os.path.join(out_dir, "agent", "memory.json"), {}) or {}
prioritized = list(report.get("validated_issues") or [])
rerun_findings = rerun_clarified_scopes(snapshot, decisions, prioritized)
replacements: Dict[str, List[dict]] = {}
for finding in rerun_findings:
origin = finding.get("clarification_of")
if origin:
replacements.setdefault(origin, []).append(finding)
else:
prioritized.append(finding) # gap findings stay as additions
for origin, new_findings in replacements.items():
for index, issue in enumerate(prioritized):
if issue.get("issue_id") == origin:
prioritized[index:index + 1] = new_findings
break
kept, suppressed = apply_decisions(prioritized, decisions)
for issue in kept:
if issue.get("clarification_of"):
issue["review_state"] = "clarified"
else:
decision = decisions.get(f"finding:{issue.get('issue_id')}") or {}
if decision.get("decision") == "needs_clarification":
issue["review_state"] = "clarification_failed"
rfis = _draft_rfis(kept)
report["validated_issues"] = kept
report["suppressed_issues"] = suppressed
report["rfis"] = rfis
summary = report.setdefault("summary", {})
summary["agent_status"] = "complete"
by_stage = summary.get("by_stage")
if isinstance(by_stage, dict):
if "validated" in by_stage:
by_stage["validated"] = len(kept)
if "rfis" in by_stage:
by_stage["rfis"] = len(rfis)
# Rebuild the conflicts view and headline counts from the KEPT
# conflict-stage findings so rejected findings no longer appear as
# conflicts in report.md / the UI (mirrors pipeline.report.build_report).
conflicts = [
_finding_as_conflict(finding)
for finding in kept
if finding.get("source_stage") == "conflict"
]
report["conflicts"] = conflicts
by_severity = {"high": 0, "medium": 0, "low": 0}
by_category: Dict[str, int] = {}
for conflict in conflicts:
by_severity[conflict["severity"]] = by_severity.get(conflict["severity"], 0) + 1
by_category[conflict["category"]] = by_category.get(conflict["category"], 0) + 1
summary["conflicts_found"] = len(conflicts)
summary["by_severity"] = by_severity
summary["by_category"] = by_category
summary["review"] = store.progress(queue)
os.makedirs(out_dir, exist_ok=True)
_dump(out_dir, "conflicts.json", report)
_dump(out_dir, "validated_issues.json", kept)
_dump(out_dir, "suppressed_issues.json", suppressed)
_dump(out_dir, "rfis.json", rfis)
with open(os.path.join(out_dir, "report.md"), "w", encoding="utf-8") as f:
f.write(to_markdown(report))
return report
+31
View File
@@ -0,0 +1,31 @@
"""ReviewGate: build the human-review queue from prioritized findings."""
from typing import Dict, List, Optional
from backend.review.policy import build_audit_sample, requires_review
def _finding_item(issue: Dict, blocking: bool, reasons: List[str], kind: str) -> Dict:
issue_id = issue.get("issue_id") or "unknown"
return {
"review_item_id": f"finding:{issue_id}",
"kind": kind,
"blocking": blocking,
"reasons": reasons,
"payload": issue,
}
def build_review_queue(memory_snapshot: Dict, prioritized: List[Dict], decisions: List[Dict],
limit: Optional[int] = None) -> List[Dict]:
queue: List[Dict] = []
for issue in prioritized:
reasons = requires_review(issue)
queue.append(_finding_item(issue, bool(reasons), reasons, "finding" if reasons else "audit_finding"))
if limit is None:
for item in build_audit_sample(memory_snapshot, prioritized):
queue.append(item)
else:
for item in build_audit_sample(memory_snapshot, prioritized, limit=limit):
queue.append(item)
return queue
+25
View File
@@ -0,0 +1,25 @@
"""Aggregate metrics over feedback labels.
Default aggregates exclude source_text, images, raw sheet content, and
reviewer free-text comments; include_text=True is the only path that embeds
the raw labels.
"""
from collections import Counter
from typing import Dict, List
def aggregate_labels(labels: List[dict], include_text: bool = False) -> Dict:
decisions = Counter(label.get("decision") or "unknown" for label in labels)
reasons = Counter(label.get("reason_code") or "none" for label in labels if label.get("decision") == "reject")
summary = {
"total": len(labels),
"decisions": dict(decisions),
"reject_reasons": dict(reasons),
}
for label in labels:
decision = label.get("decision") or "unknown"
summary[decision] = summary.get(decision, 0) + 1
if include_text:
summary["labels"] = labels
return summary
+70
View File
@@ -0,0 +1,70 @@
"""Review-trigger policy: which findings block on human review."""
from typing import Any, Dict, List
_SENSITIVE_CATEGORIES = {
"missing_element",
"ada",
"tas_tdlr",
"egress",
"fire_separation",
"occupancy",
"spatial_clash",
"clearance_conflict",
"penetration_conflict",
}
def requires_review(issue: Dict) -> List[str]:
"""Return trigger reasons that require human review for one issue."""
reasons: List[str] = []
severity = str(issue.get("severity") or "").lower()
confidence = str(issue.get("confidence") or "").lower()
category = str(issue.get("category") or "").lower()
if severity in {"critical", "high"}:
reasons.append("severity_high")
if confidence == "low":
reasons.append("confidence_low")
if category in _SENSITIVE_CATEGORIES or issue.get("source_stage") == "code":
reasons.append("sensitive_category")
return reasons
def build_audit_sample(
memory_snapshot: Dict,
prioritized: List[Dict],
limit: int = 5,
) -> List[Dict[str, Any]]:
"""Build non-blocking spot-check items for clean (finding-free) clusters."""
implicated = {
str(finding.get("scope_id") or "")
for finding in (memory_snapshot.get("findings") or []) + list(prioritized)
}
items: List[Dict[str, Any]] = []
for cluster in memory_snapshot.get("clusters") or []:
if len(items) >= limit:
break
assertions = cluster.get("assertions") or []
if len(assertions) < 2:
continue
cluster_key = cluster.get("key") or "unknown"
if any(cluster_key in scope_id for scope_id in implicated):
continue
items.append({
"review_item_id": f"clean_cluster:{cluster_key}",
"kind": "clean_cluster",
"blocking": False,
"reasons": ["audit_sample"],
"payload": _without_base64(cluster),
})
return items
def _without_base64(cluster: Dict) -> Dict:
return {
**cluster,
"assertions": [
{key: value for key, value in assertion.items() if key != "base64"}
for assertion in cluster.get("assertions") or []
],
}
+45
View File
@@ -0,0 +1,45 @@
"""Human-review decision schema and validation."""
from typing import Optional
DECISIONS = {"confirm", "reject", "unsure", "needs_clarification"}
REASON_CODES = {
"wrong_cluster_link",
"same_value_different_representation",
"not_a_contradiction",
"missing_evidence",
"extraction_misread",
"code_path_not_applicable",
"duplicate",
"severity_too_high",
"severity_too_low",
"other",
}
def validate_decision(raw: dict) -> Optional[dict]:
"""Normalize a reviewer decision payload, or return None if invalid."""
if not isinstance(raw, dict):
return None
decision = str(raw.get("decision") or "").strip()
if decision not in DECISIONS:
return None
reason_code = raw.get("reason_code")
if decision == "reject":
reason_code = str(reason_code or "").strip()
if reason_code not in REASON_CODES:
return None
elif reason_code is not None:
reason_code = str(reason_code).strip() or None
if reason_code and reason_code not in REASON_CODES:
return None
return {
"review_item_id": str(raw.get("review_item_id") or "").strip(),
"decision": decision,
"reason_code": reason_code,
"category_correction": raw.get("category_correction"),
"severity_correction": raw.get("severity_correction"),
"comment": str(raw.get("comment") or "").strip(),
"clarification_answer": raw.get("clarification_answer"),
"reviewed_at": raw.get("reviewed_at"),
}
+62
View File
@@ -0,0 +1,62 @@
"""Persistence for human-review queue and decisions within a job output dir."""
import json
import os
from typing import Dict, List
from backend.review.schemas import validate_decision
class ReviewStore:
def __init__(self, job_out_dir: str, create: bool = True) -> None:
self.review_dir = os.path.join(job_out_dir, "review")
if create:
os.makedirs(self.review_dir, exist_ok=True)
def _path(self, name: str) -> str:
return os.path.join(self.review_dir, name)
def _write_json(self, name: str, value) -> None:
path = self._path(name)
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(value, f, indent=2)
os.replace(tmp, path)
def write_queue(self, queue: List[dict]) -> None:
self._write_json("review_queue.json", queue)
def read_queue(self) -> List[dict]:
try:
with open(self._path("review_queue.json"), encoding="utf-8") as f:
value = json.load(f)
return value if isinstance(value, list) else []
except (OSError, json.JSONDecodeError):
return []
def append_decision(self, decision: dict) -> None:
valid = validate_decision(decision)
if not valid or not valid["review_item_id"]:
raise ValueError("invalid review decision")
decisions = self.read_decisions()
decisions[valid["review_item_id"]] = valid
self._write_json("review_decisions.json", decisions)
def read_decisions(self) -> Dict[str, dict]:
try:
with open(self._path("review_decisions.json"), encoding="utf-8") as f:
value = json.load(f)
return value if isinstance(value, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def progress(self, queue: List[dict]) -> dict:
decisions = self.read_decisions()
required = [item for item in queue if item.get("blocking")]
completed = [item for item in required if item.get("review_item_id") in decisions]
return {
"required": len(required),
"completed": len(completed),
"remaining": len(required) - len(completed),
"total": len(queue),
}