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
+111 -1
View File
@@ -11,15 +11,21 @@ ever needs concurrency.
import os
import tempfile
import threading
import time
from typing import Optional
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
import backend.jobs
from backend import config
from backend.jobs import PIPELINE_MODES, create_job, get_job
from backend.jobs import PIPELINE_MODES, create_job, get_job, _set
from backend.pipeline.pdf_processor import render_page_jpeg
from backend.review.feedback import decision_to_label, write_label
from backend.review.finalizer import finalize_review
from backend.review.store import ReviewStore
app = FastAPI(title=config.APP_TITLE, version=config.APP_VERSION)
@@ -95,6 +101,110 @@ def job_status(job_id: str):
return JSONResponse(job)
@app.get("/jobs/{job_id}/review")
def review_queue(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
out_dir = job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
# Read-only endpoint: don't create review/ dirs just by looking at them
# (readers already degrade to empty on missing files).
store = ReviewStore(out_dir, create=False)
queue = store.read_queue()
return {"queue": queue, "progress": store.progress(queue),
"decisions": store.read_decisions()}
@app.post("/jobs/{job_id}/review-decisions")
def save_review_decisions(job_id: str, payload: dict):
job = get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.get("status") not in ("needs_review", "reviewing"):
# Positive state guard, mirroring the finalize endpoint: only jobs
# sitting at (or working through) the review gate accept decisions.
raise HTTPException(status_code=409, detail={
"detail": f"cannot save review decisions for a job in status {job.get('status')}",
})
out_dir = job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
store = ReviewStore(out_dir)
queue = store.read_queue()
items_by_id = {item.get("review_item_id"): item for item in queue}
saved = 0
try:
for decision in payload.get("decisions") or []:
store.append_decision(decision)
queue_item = items_by_id.get(decision.get("review_item_id"))
if queue_item is not None:
write_label(out_dir, decision_to_label(queue_item, decision, job))
saved += 1
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
progress = store.progress(queue)
if job.get("status") == "needs_review" and saved > 0 and progress["remaining"] > 0:
try:
_set(job_id, status="reviewing")
except KeyError:
pass # job not in the in-memory registry (e.g. loaded from disk)
return {"progress": progress}
def _finalize_job(job_id: str, out_dir: str) -> None:
"""Background finalization: the ONE place the final report email may fire."""
try:
report = finalize_review(job_id, out_dir)
except Exception as e:
try:
_set(job_id, status="finalization_error", error=str(e),
finished_at=time.time(), stage=None)
except KeyError:
pass # job not in the in-memory registry
return
try:
_set(job_id, status="done", report=report,
finished_at=time.time(), stage=None)
except KeyError:
pass
try:
backend.jobs._notify(job_id, report, out_dir)
except Exception as e:
print(f"[Jobs] Final notification for {job_id} failed: {e}")
@app.post("/jobs/{job_id}/finalize-review")
def finalize_review_endpoint(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.get("status") in ("done", "finalizing"):
raise HTTPException(status_code=409, detail={
"detail": f"job is already {job['status']}",
})
if job.get("status") not in ("needs_review", "reviewing", "finalization_error"):
# Positive state-machine guard: finalization (and the final email) is
# only reachable after the job has passed through the review gate.
raise HTTPException(status_code=409, detail={
"detail": f"cannot finalize a job in status {job.get('status')}",
})
out_dir = job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
store = ReviewStore(out_dir)
queue = store.read_queue()
decisions = store.read_decisions()
if any(item.get("blocking") and item.get("review_item_id") not in decisions
for item in queue):
# 409 detail shape: {"detail": <message>, "progress": <store.progress()>}
raise HTTPException(status_code=409, detail={
"detail": "incomplete review",
"progress": store.progress(queue),
})
try:
_set(job_id, status="finalizing")
except KeyError:
pass # job not in the in-memory registry (e.g. loaded from disk)
threading.Thread(target=_finalize_job, args=(job_id, out_dir), daemon=True).start()
return {"status": "finalizing"}
@app.get("/jobs/{job_id}/sheet-image/{page}")
def sheet_image(job_id: str, page: int):
"""Render one page of a completed job's source PDF as JPEG (sheet viewer)."""