Add required human review gate to the Agent pipeline.
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:
@@ -171,6 +171,52 @@ The `AGENT_*_CONCURRENCY` and scope-cap variables in `backend/.env.example`
|
|||||||
bound fan-out and prompt size. Agent mode intentionally ignores the hybrid/local
|
bound fan-out and prompt size. Agent mode intentionally ignores the hybrid/local
|
||||||
text option in v1.
|
text option in v1.
|
||||||
|
|
||||||
|
### Agent mode: required human review
|
||||||
|
|
||||||
|
By default (`AGENT_REQUIRE_REVIEW=true`) an Agent run **stops after the Brain
|
||||||
|
consolidation wave** and waits for a human before anything ships:
|
||||||
|
|
||||||
|
```
|
||||||
|
Brain merge -> needs_review -> review UI (/?job=<id>) -> finalize -> final report
|
||||||
|
```
|
||||||
|
|
||||||
|
The job lifecycle adds review states: `needs_review` (queue built, waiting),
|
||||||
|
`reviewing` (decisions submitted), `finalizing` (targeted reruns + RFI writers
|
||||||
|
running), then `done` — or `finalization_error` if finalization fails. Open the
|
||||||
|
job in the web UI to work the queue: blocking items (high/critical severity,
|
||||||
|
low confidence, sensitive categories) must be decided; clean-cluster items are
|
||||||
|
non-blocking spot-checks.
|
||||||
|
|
||||||
|
Email is **two-phase**: a "review required" notice goes out when the job enters
|
||||||
|
`needs_review` (with a link to the review UI); the final conflict report email
|
||||||
|
is only sent after finalization completes. The unreviewed report never leaves
|
||||||
|
the server.
|
||||||
|
|
||||||
|
**Privacy boundary:** all review artifacts (queue, decisions, final report) are
|
||||||
|
job-local under `outputs/<job_id>/review/`. Cross-job review-feedback
|
||||||
|
aggregation, when built, excludes verbatim `source_text`, images, and comments
|
||||||
|
unless `REVIEW_AGGREGATE_INCLUDE_TEXT=true`.
|
||||||
|
|
||||||
|
Config knobs (see `backend/.env.example`):
|
||||||
|
|
||||||
|
| Key | Default | Effect |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `AGENT_REQUIRE_REVIEW` | `true` | `false` = Agent jobs skip the gate entirely (old behavior: RFIs, final report, one email) |
|
||||||
|
| `AGENT_REVIEW_AUDIT_SAMPLE` | `5` | Max clean clusters added to the queue as spot-checks |
|
||||||
|
| `REVIEW_AGGREGATE_INCLUDE_TEXT` | `false` | Allow future aggregate feedback to include source text/images/comments |
|
||||||
|
|
||||||
|
From the CLI, `--no-review` bypasses the gate for that run (it overrides
|
||||||
|
`AGENT_REQUIRE_REVIEW=true`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python cli/run_check.py samples/your_set.pdf --mode agent --no-review --out out/agent-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deployment note:** the review endpoints (`/jobs/{id}/review-decisions`,
|
||||||
|
`/jobs/{id}/finalize-review`) are **state-changing and sensitive** — they accept
|
||||||
|
human decisions that alter the final report. Do **not** expose the UI/API
|
||||||
|
publicly without reverse-proxy auth or a shared access token in front of it.
|
||||||
|
|
||||||
Web UI (upload + view):
|
Web UI (upload + view):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ AGENT_CONFLICT_CONCURRENCY=4
|
|||||||
AGENT_SPECIALIST_CONCURRENCY=4
|
AGENT_SPECIALIST_CONCURRENCY=4
|
||||||
AGENT_RFI_CONCURRENCY=4
|
AGENT_RFI_CONCURRENCY=4
|
||||||
|
|
||||||
|
# Agent-mode human-review gate (pipeline stops after Brain until a human reviews)
|
||||||
|
AGENT_REQUIRE_REVIEW=true
|
||||||
|
# Max clean clusters added to the review queue as non-blocking spot-checks
|
||||||
|
AGENT_REVIEW_AUDIT_SAMPLE=5
|
||||||
|
# Allow future cross-job review-feedback aggregation to include source_text/images/comments
|
||||||
|
REVIEW_AGGREGATE_INCLUDE_TEXT=false
|
||||||
|
|
||||||
# Pipeline tuning
|
# Pipeline tuning
|
||||||
PDF_DPI=100
|
PDF_DPI=100
|
||||||
MAX_PAGES=60
|
MAX_PAGES=60
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from backend.agents.rfi_writer import RFIWriterAgent
|
|||||||
from backend.pipeline.pdf_processor import convert_pdf_to_images
|
from backend.pipeline.pdf_processor import convert_pdf_to_images
|
||||||
from backend.pipeline.report import build_report, to_markdown
|
from backend.pipeline.report import build_report, to_markdown
|
||||||
from backend.pipeline.sheet_index import derive_project_meta_from_cover
|
from backend.pipeline.sheet_index import derive_project_meta_from_cover
|
||||||
|
from backend.review.gate import build_review_queue
|
||||||
|
from backend.review.store import ReviewStore
|
||||||
|
|
||||||
|
|
||||||
def run_agent_pipeline(
|
def run_agent_pipeline(
|
||||||
@@ -31,6 +33,7 @@ def run_agent_pipeline(
|
|||||||
on_stage: Optional[Callable[[str], None]] = None,
|
on_stage: Optional[Callable[[str], None]] = None,
|
||||||
project_input: Optional[Dict] = None,
|
project_input: Optional[Dict] = None,
|
||||||
source_name: Optional[str] = None,
|
source_name: Optional[str] = None,
|
||||||
|
require_review: bool = True,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Run all scoped specialist waves and return a Classic-compatible report."""
|
"""Run all scoped specialist waves and return a Classic-compatible report."""
|
||||||
if not os.path.isfile(pdf_path):
|
if not os.path.isfile(pdf_path):
|
||||||
@@ -192,6 +195,71 @@ def run_agent_pipeline(
|
|||||||
1 for decision in decisions if decision.get("action") == "merged"
|
1 for decision in decisions if decision.get("action") == "merged"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if require_review:
|
||||||
|
orchestrator.stage("Agent review gate: build human-review queue")
|
||||||
|
memory_snapshot = memory.snapshot()
|
||||||
|
queue = build_review_queue(memory_snapshot, prioritized, decisions,
|
||||||
|
limit=config.AGENT_REVIEW_AUDIT_SAMPLE)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
store.write_queue(queue)
|
||||||
|
candidate_conflicts = [_finding_as_conflict(item) for item in conflict_findings]
|
||||||
|
report = build_report(
|
||||||
|
conflicts=candidate_conflicts,
|
||||||
|
sheets=sheets,
|
||||||
|
clusters=clusters,
|
||||||
|
source=source_name or os.path.basename(pdf_path),
|
||||||
|
)
|
||||||
|
report.update({
|
||||||
|
"project_input": merged_input,
|
||||||
|
"jurisdiction": jurisdiction,
|
||||||
|
"sheet_index": sheet_index,
|
||||||
|
"project_intelligence": object_graph,
|
||||||
|
"validated_issues": prioritized,
|
||||||
|
"rfis": [],
|
||||||
|
"suppressed_issues": [],
|
||||||
|
})
|
||||||
|
progress = store.progress(queue)
|
||||||
|
# Same usage/stats summary block as the wave-7 path (rfis: 0 — they
|
||||||
|
# are drafted only after human review finalizes the run).
|
||||||
|
cost = usage.snapshot()
|
||||||
|
orchestrator.stats.calls = cost["calls"]
|
||||||
|
stats = orchestrator.stats.as_dict()
|
||||||
|
report["summary"].update({
|
||||||
|
"pipeline_mode": "agent",
|
||||||
|
"agent_status": "needs_review",
|
||||||
|
"review": progress,
|
||||||
|
"agent_stats": stats,
|
||||||
|
"by_stage": {
|
||||||
|
"conflicts": len(conflict_findings),
|
||||||
|
"qaqc": sum(
|
||||||
|
1 for item in specialist_findings
|
||||||
|
if item.get("source_stage") == "qaqc"
|
||||||
|
),
|
||||||
|
"code": sum(
|
||||||
|
1 for item in specialist_findings
|
||||||
|
if item.get("source_stage") == "code"
|
||||||
|
),
|
||||||
|
"constructability": sum(
|
||||||
|
1 for item in specialist_findings
|
||||||
|
if item.get("source_stage") == "constructability"
|
||||||
|
),
|
||||||
|
"validated": len(prioritized),
|
||||||
|
"rfis": 0,
|
||||||
|
},
|
||||||
|
"cost_usd": round(cost["usd"], 4),
|
||||||
|
"llm_calls": cost["calls"],
|
||||||
|
"cached_calls": cost["cached"],
|
||||||
|
"cost_by_stage": cost["by_stage"],
|
||||||
|
"text_backend": "openrouter",
|
||||||
|
"models_used": cost["models"],
|
||||||
|
})
|
||||||
|
if out_dir:
|
||||||
|
_dump(out_dir, "conflicts.json", report)
|
||||||
|
_dump(out_dir, "validated_issues.json", prioritized)
|
||||||
|
# Snapshot for the review finalizer's targeted clarification reruns.
|
||||||
|
memory.dump("memory.json")
|
||||||
|
return report
|
||||||
|
|
||||||
orchestrator.stage("Agent wave 7: per-finding RFI writers")
|
orchestrator.stage("Agent wave 7: per-finding RFI writers")
|
||||||
rfi_scopes = [
|
rfi_scopes = [
|
||||||
AgentScope(
|
AgentScope(
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ AGENT_CONFLICT_CONCURRENCY = int(os.getenv("AGENT_CONFLICT_CONCURRENCY", "4"))
|
|||||||
AGENT_SPECIALIST_CONCURRENCY = int(os.getenv("AGENT_SPECIALIST_CONCURRENCY", "4"))
|
AGENT_SPECIALIST_CONCURRENCY = int(os.getenv("AGENT_SPECIALIST_CONCURRENCY", "4"))
|
||||||
AGENT_RFI_CONCURRENCY = int(os.getenv("AGENT_RFI_CONCURRENCY", "4"))
|
AGENT_RFI_CONCURRENCY = int(os.getenv("AGENT_RFI_CONCURRENCY", "4"))
|
||||||
|
|
||||||
|
# Agent-mode human-review gate. When on (default), Agent runs stop after the
|
||||||
|
# Brain merge and wait for human decisions before RFIs/final report/email go
|
||||||
|
# out. AGENT_REVIEW_AUDIT_SAMPLE caps how many clean clusters get added to the
|
||||||
|
# queue as non-blocking spot-checks. REVIEW_AGGREGATE_INCLUDE_TEXT controls
|
||||||
|
# whether future cross-job review feedback aggregation may include verbatim
|
||||||
|
# source_text/images/comments (off by default = privacy-preserving).
|
||||||
|
AGENT_REQUIRE_REVIEW = os.getenv("AGENT_REQUIRE_REVIEW", "true").strip().lower() in ("1", "true", "yes")
|
||||||
|
AGENT_REVIEW_AUDIT_SAMPLE = int(os.getenv("AGENT_REVIEW_AUDIT_SAMPLE", "5"))
|
||||||
|
# NOTE: currently unwired - reserved for future cross-job aggregation tooling.
|
||||||
|
REVIEW_AGGREGATE_INCLUDE_TEXT = os.getenv("REVIEW_AGGREGATE_INCLUDE_TEXT", "false").strip().lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
# -- Hybrid (local text LLM) ----------------------------------------
|
# -- Hybrid (local text LLM) ----------------------------------------
|
||||||
# Optional OpenAI-compatible local endpoint (e.g. a vLLM box) for the text-only
|
# Optional OpenAI-compatible local endpoint (e.g. a vLLM box) for the text-only
|
||||||
# QAQC stages. Vision stages ALWAYS use OpenRouter. The user picks hybrid per
|
# QAQC stages. Vision stages ALWAYS use OpenRouter. The user picks hybrid per
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ def _send(msg: EmailMessage) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_review_required(recipient_email: str, report: dict, review_url: str) -> bool:
|
||||||
|
if not recipient_email or not _smtp_ready():
|
||||||
|
return False
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = f"Conflict Checker - review required - {report.get('source', 'drawing set')}"
|
||||||
|
msg["From"] = config.SMTP_FROM or config.SMTP_USER
|
||||||
|
msg["To"] = recipient_email
|
||||||
|
review = report.get("summary", {}).get("review", {})
|
||||||
|
msg.set_content(
|
||||||
|
"Agent analysis is complete and waiting for human review.\n\n"
|
||||||
|
f"Required review items: {review.get('required', 0)}\n"
|
||||||
|
f"Review URL: {review_url}\n"
|
||||||
|
)
|
||||||
|
return _send(msg)
|
||||||
|
|
||||||
|
|
||||||
def send_conflict_report(
|
def send_conflict_report(
|
||||||
recipient_email: str,
|
recipient_email: str,
|
||||||
report: Dict,
|
report: Dict,
|
||||||
|
|||||||
+50
-9
@@ -11,6 +11,7 @@ to outputs/<job_id>/ so results survive a restart even though live status does
|
|||||||
not. No external queue/DB.
|
not. No external queue/DB.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -21,7 +22,7 @@ from typing import Dict, Optional
|
|||||||
from backend import config
|
from backend import config
|
||||||
from backend.agents.runner import run_agent_pipeline
|
from backend.agents.runner import run_agent_pipeline
|
||||||
from backend.pipeline.runner import run_pipeline
|
from backend.pipeline.runner import run_pipeline
|
||||||
from backend.email_sender import send_conflict_report
|
from backend.email_sender import send_conflict_report, send_review_required
|
||||||
|
|
||||||
_jobs: Dict[str, Dict] = {}
|
_jobs: Dict[str, Dict] = {}
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
@@ -46,7 +47,7 @@ def create_job(pdf_path: str, source_filename: str, email: Optional[str] = None,
|
|||||||
with _lock:
|
with _lock:
|
||||||
_jobs[job_id] = {
|
_jobs[job_id] = {
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"status": "queued", # queued -> running -> done | error
|
"status": "queued", # queued -> running -> done | needs_review | error
|
||||||
"source": source_filename,
|
"source": source_filename,
|
||||||
"email": email or None,
|
"email": email or None,
|
||||||
"project_input": project_input or {},
|
"project_input": project_input or {},
|
||||||
@@ -72,6 +73,16 @@ def _run(job_id: str, pdf_path: str, project_input: Optional[Dict] = None,
|
|||||||
_set(job_id, status="running")
|
_set(job_id, status="running")
|
||||||
# Keep a copy of the source PDF so its sheets can be viewed later.
|
# Keep a copy of the source PDF so its sheets can be viewed later.
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
# Persist minimal job metadata so the disk fallback in get_job can
|
||||||
|
# recover the recipient email / pipeline mode after a server restart
|
||||||
|
# (plain json.dump, matching the _dump style used elsewhere).
|
||||||
|
with open(os.path.join(out_dir, "job.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({
|
||||||
|
"job_id": job_id,
|
||||||
|
"email": _jobs[job_id].get("email"),
|
||||||
|
"pipeline_mode": pipeline_mode,
|
||||||
|
"source": _jobs[job_id].get("source"),
|
||||||
|
}, f, indent=2)
|
||||||
shutil.copy2(pdf_path, os.path.join(out_dir, "source.pdf"))
|
shutil.copy2(pdf_path, os.path.join(out_dir, "source.pdf"))
|
||||||
runner = run_agent_pipeline if pipeline_mode == "agent" else run_pipeline
|
runner = run_agent_pipeline if pipeline_mode == "agent" else run_pipeline
|
||||||
runner_kwargs = {
|
runner_kwargs = {
|
||||||
@@ -82,8 +93,19 @@ def _run(job_id: str, pdf_path: str, project_input: Optional[Dict] = None,
|
|||||||
}
|
}
|
||||||
if pipeline_mode == "classic":
|
if pipeline_mode == "classic":
|
||||||
runner_kwargs["text_local"] = text_local
|
runner_kwargs["text_local"] = text_local
|
||||||
|
else:
|
||||||
|
runner_kwargs["require_review"] = config.AGENT_REQUIRE_REVIEW
|
||||||
report = runner(pdf_path, **runner_kwargs)
|
report = runner(pdf_path, **runner_kwargs)
|
||||||
report.setdefault("summary", {})["pipeline_mode"] = pipeline_mode
|
report.setdefault("summary", {})["pipeline_mode"] = pipeline_mode
|
||||||
|
if report["summary"].get("agent_status") == "needs_review":
|
||||||
|
# Human-review gate: hold the job, don't email the unreviewed report.
|
||||||
|
_set(job_id, status="needs_review", report=report,
|
||||||
|
finished_at=time.time(), stage=None)
|
||||||
|
email = _jobs[job_id].get("email")
|
||||||
|
if email:
|
||||||
|
review_url = f"{config.APP_BASE_URL.rstrip('/')}/?job={job_id}"
|
||||||
|
send_review_required(email, report, review_url)
|
||||||
|
else:
|
||||||
_set(job_id, status="done", report=report, finished_at=time.time(), stage=None)
|
_set(job_id, status="done", report=report, finished_at=time.time(), stage=None)
|
||||||
_notify(job_id, report, out_dir)
|
_notify(job_id, report, out_dir)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -158,24 +180,43 @@ def get_job(job_id: str) -> Optional[Dict]:
|
|||||||
if not os.path.isfile(report_path):
|
if not os.path.isfile(report_path):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
with open(report_path, encoding="utf-8") as f:
|
with open(report_path, encoding="utf-8") as f:
|
||||||
report = json.load(f)
|
report = json.load(f)
|
||||||
|
summary = report.get("summary", {})
|
||||||
|
# Recover the job's real state: a job that stopped at the review gate
|
||||||
|
# must come back as needs_review (not done) or it can never finalize.
|
||||||
|
status = "needs_review" if summary.get("agent_status") == "needs_review" else "done"
|
||||||
|
# job.json (written at job start) carries the recipient email and
|
||||||
|
# pipeline mode so the final notification still fires after a restart.
|
||||||
|
# Missing/corrupt job.json degrades to the previous derivations.
|
||||||
|
meta: Dict = {}
|
||||||
|
meta_path = os.path.join(config.OUTPUT_DIR, job_id, "job.json")
|
||||||
|
try:
|
||||||
|
with open(meta_path, encoding="utf-8") as f:
|
||||||
|
loaded = json.load(f)
|
||||||
|
if isinstance(loaded, dict):
|
||||||
|
meta = loaded
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
source_pdf = os.path.join(config.OUTPUT_DIR, job_id, "source.pdf")
|
source_pdf = os.path.join(config.OUTPUT_DIR, job_id, "source.pdf")
|
||||||
return {
|
job = {
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"status": "done",
|
"status": status,
|
||||||
"source": report.get("source", os.path.basename(report_path)),
|
"source": meta.get("source") or report.get("source", os.path.basename(report_path)),
|
||||||
"email": None,
|
"email": meta.get("email"),
|
||||||
"project_input": report.get("project_input", {}),
|
"project_input": report.get("project_input", {}),
|
||||||
"text_local": report.get("summary", {}).get("text_backend") == "local",
|
"text_local": summary.get("text_backend") == "local",
|
||||||
"pipeline_mode": report.get("summary", {}).get("pipeline_mode", "classic"),
|
"pipeline_mode": meta.get("pipeline_mode") or summary.get("pipeline_mode", "classic"),
|
||||||
"stage": None,
|
"stage": None,
|
||||||
"created_at": os.path.getmtime(source_pdf) if os.path.isfile(source_pdf) else None,
|
"created_at": os.path.getmtime(source_pdf) if os.path.isfile(source_pdf) else None,
|
||||||
"finished_at": os.path.getmtime(report_path),
|
"finished_at": os.path.getmtime(report_path),
|
||||||
"report": report,
|
"report": report,
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
# Hydrate the in-memory registry so _set(...) transitions (reviewing,
|
||||||
|
# finalizing, done) work for restart-recovered jobs.
|
||||||
|
with _lock:
|
||||||
|
return dict(_jobs.setdefault(job_id, job))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Jobs] Failed to load job {job_id} from disk: {e}")
|
print(f"[Jobs] Failed to load job {job_id} from disk: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
+111
-1
@@ -11,15 +11,21 @@ ever needs concurrency.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
import backend.jobs
|
||||||
from backend import config
|
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.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)
|
app = FastAPI(title=config.APP_TITLE, version=config.APP_VERSION)
|
||||||
|
|
||||||
@@ -95,6 +101,110 @@ def job_status(job_id: str):
|
|||||||
return JSONResponse(job)
|
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}")
|
@app.get("/jobs/{job_id}/sheet-image/{page}")
|
||||||
def sheet_image(job_id: str, page: int):
|
def sheet_image(job_id: str, page: int):
|
||||||
"""Render one page of a completed job's source PDF as JPEG (sheet viewer)."""
|
"""Render one page of a completed job's source PDF as JPEG (sheet viewer)."""
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Human-review gate: decision schemas and review-trigger policy."""
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 []
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
+18
-2
@@ -17,6 +17,7 @@ _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||||||
if _ROOT not in sys.path:
|
if _ROOT not in sys.path:
|
||||||
sys.path.insert(0, _ROOT)
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
from backend import config # noqa: E402
|
||||||
from backend.agents.runner import run_agent_pipeline # noqa: E402
|
from backend.agents.runner import run_agent_pipeline # noqa: E402
|
||||||
from backend.pipeline.runner import run_pipeline # noqa: E402
|
from backend.pipeline.runner import run_pipeline # noqa: E402
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ def main() -> int:
|
|||||||
parser.add_argument("--occupancy", default=None)
|
parser.add_argument("--occupancy", default=None)
|
||||||
parser.add_argument("--work-type", default=None,
|
parser.add_argument("--work-type", default=None,
|
||||||
help="new_building | remodel | tenant_improvement | addition | ...")
|
help="new_building | remodel | tenant_improvement | addition | ...")
|
||||||
|
parser.add_argument("--no-review", action="store_true",
|
||||||
|
help="Agent mode only: skip the human-review gate and finish the run "
|
||||||
|
"(overrides AGENT_REQUIRE_REVIEW=true)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not os.path.isfile(args.pdf):
|
if not os.path.isfile(args.pdf):
|
||||||
@@ -46,8 +50,16 @@ def main() -> int:
|
|||||||
}.items() if v
|
}.items() if v
|
||||||
}
|
}
|
||||||
out_dir = args.out or os.path.join("out", os.path.splitext(os.path.basename(args.pdf))[0])
|
out_dir = args.out or os.path.join("out", os.path.splitext(os.path.basename(args.pdf))[0])
|
||||||
runner = run_agent_pipeline if args.mode == "agent" else run_pipeline
|
if args.mode == "agent":
|
||||||
report = runner(
|
report = run_agent_pipeline(
|
||||||
|
args.pdf,
|
||||||
|
out_dir=out_dir,
|
||||||
|
project_input=project_input or None,
|
||||||
|
source_name=os.path.basename(args.pdf),
|
||||||
|
require_review=config.AGENT_REQUIRE_REVIEW and not args.no_review,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
report = run_pipeline(
|
||||||
args.pdf,
|
args.pdf,
|
||||||
out_dir=out_dir,
|
out_dir=out_dir,
|
||||||
project_input=project_input or None,
|
project_input=project_input or None,
|
||||||
@@ -60,6 +72,10 @@ def main() -> int:
|
|||||||
f"(high {s['by_severity']['high']}, "
|
f"(high {s['by_severity']['high']}, "
|
||||||
f"medium {s['by_severity']['medium']}, "
|
f"medium {s['by_severity']['medium']}, "
|
||||||
f"low {s['by_severity']['low']})")
|
f"low {s['by_severity']['low']})")
|
||||||
|
if s.get("agent_status") == "needs_review":
|
||||||
|
print(" Stopped for human review - finalize via the web UI, "
|
||||||
|
"or rerun with --no-review.")
|
||||||
|
else:
|
||||||
print(f" Report: {os.path.join(out_dir, 'report.md')}")
|
print(f" Report: {os.path.join(out_dir, 'report.md')}")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -0,0 +1,867 @@
|
|||||||
|
# Agent Human Review Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add required human review to the Agent pipeline so findings are confirmed, rejected, clarified, and measured before final RFIs/reports are issued.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing Agent pipeline through Brain, then insert a ReviewGate that writes a persistent review queue and moves the job to `needs_review`. A ReviewFinalizer applies human decisions, performs bounded targeted reruns for clarification, drafts RFIs only for kept issues, and only then marks the job done and sends final email.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3, FastAPI, pytest, vanilla JS frontend, JSON file artifacts under `backend/outputs/<job_id>/`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Do not change Classic pipeline behavior.
|
||||||
|
- Agent mode remains OpenRouter-only in v1.
|
||||||
|
- No final email before human review finalization.
|
||||||
|
- No raw `source_text`, sheet images, or drawing content in aggregate metrics by default.
|
||||||
|
- All new review logic must have non-LLM tests.
|
||||||
|
- Follow existing patterns: small modules, graceful degradation, JSON artifacts under job output dir.
|
||||||
|
- Review endpoints are state-changing and must be treated as sensitive in docs and deployment notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Review schemas and policy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/review/__init__.py`
|
||||||
|
- Create: `backend/review/schemas.py`
|
||||||
|
- Create: `backend/review/policy.py`
|
||||||
|
- Test: `tests/review/test_policy.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing from earlier tasks.
|
||||||
|
- Produces:
|
||||||
|
- `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"}`
|
||||||
|
- `validate_decision(raw: dict) -> dict | None`
|
||||||
|
- `requires_review(issue: dict) -> list[str]`
|
||||||
|
- `build_audit_sample(memory_snapshot: dict, prioritized: list[dict], limit: int = 5) -> list[dict]`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing policy tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.review.policy import requires_review
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_severity_requires_review():
|
||||||
|
issue = {"severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}
|
||||||
|
assert "severity_high" in requires_review(issue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_confidence_requires_review():
|
||||||
|
issue = {"severity": "low", "confidence": "low", "category": "note_or_spec_contradiction", "source_stage": "conflict"}
|
||||||
|
assert "confidence_low" in requires_review(issue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_code_category_requires_review():
|
||||||
|
issue = {"severity": "medium", "confidence": "high", "category": "egress", "source_stage": "code"}
|
||||||
|
assert "sensitive_category" in requires_review(issue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_medium_high_confidence_note_does_not_require_review():
|
||||||
|
issue = {"severity": "medium", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}
|
||||||
|
assert requires_review(issue) == []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_policy.py -v`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'backend.review'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement schemas and policy**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/review/schemas.py
|
||||||
|
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]:
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/review/policy.py
|
||||||
|
from typing import 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]:
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_policy.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/review tests/review/test_policy.py
|
||||||
|
git commit -m "Add review decision schema and trigger policy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Review persistence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/review/store.py`
|
||||||
|
- Test: `tests/review/test_store.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `validate_decision` from Task 1.
|
||||||
|
- Produces:
|
||||||
|
- `ReviewStore(job_out_dir: str)`
|
||||||
|
- `.write_queue(queue: list[dict]) -> None`
|
||||||
|
- `.read_queue() -> list[dict]`
|
||||||
|
- `.append_decision(decision: dict) -> None`
|
||||||
|
- `.read_decisions() -> dict[str, dict]`
|
||||||
|
- `.progress(queue: list[dict]) -> dict`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing persistence tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
from backend.review.store import ReviewStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_and_decisions_round_trip(tmp_path):
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
queue = [{"review_item_id": "finding:1", "blocking": True}]
|
||||||
|
store.write_queue(queue)
|
||||||
|
assert store.read_queue() == queue
|
||||||
|
store.append_decision({"review_item_id": "finding:1", "decision": "confirm"})
|
||||||
|
assert store.read_decisions()["finding:1"]["decision"] == "confirm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_counts_required_items(tmp_path):
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
queue = [
|
||||||
|
{"review_item_id": "a", "blocking": True},
|
||||||
|
{"review_item_id": "b", "blocking": False},
|
||||||
|
]
|
||||||
|
store.write_queue(queue)
|
||||||
|
store.append_decision({"review_item_id": "a", "decision": "confirm"})
|
||||||
|
progress = store.progress(queue)
|
||||||
|
assert progress["required"] == 1
|
||||||
|
assert progress["completed"] == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_store.py -v`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'backend.review.store'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement ReviewStore**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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) -> None:
|
||||||
|
self.review_dir = os.path.join(job_out_dir, "review")
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_store.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/review/store.py tests/review/test_store.py
|
||||||
|
git commit -m "Add persistent review store"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: ReviewGate queue builder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/review/gate.py`
|
||||||
|
- Test: `tests/review/test_gate.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `requires_review`, `build_audit_sample` from Task 1.
|
||||||
|
- Produces:
|
||||||
|
- `build_review_queue(memory_snapshot: dict, prioritized: list[dict], decisions: list[dict]) -> list[dict]`
|
||||||
|
- queue item shape: `{ "review_item_id": str, "kind": "finding|audit_finding|clean_cluster", "blocking": bool, "reasons": list[str], "payload": dict }`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing gate tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.review.gate import build_review_queue
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_marks_blocking_and_audit_items():
|
||||||
|
memory = {"clusters": [{"key": "room:101", "location": "Room 101", "assertions": [{"id": "a1"}, {"id": "a2"}]}], "findings": []}
|
||||||
|
prioritized = [
|
||||||
|
{"issue_id": "AGENT-0001", "severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"},
|
||||||
|
{"issue_id": "AGENT-0002", "severity": "low", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"},
|
||||||
|
]
|
||||||
|
queue = build_review_queue(memory, prioritized, [])
|
||||||
|
by_id = {item["review_item_id"]: item for item in queue}
|
||||||
|
assert by_id["finding:AGENT-0001"]["blocking"] is True
|
||||||
|
assert by_id["finding:AGENT-0002"]["blocking"] is False
|
||||||
|
assert any(item["kind"] == "clean_cluster" for item in queue)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_gate.py -v`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'backend.review.gate'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement ReviewGate**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
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]) -> 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"))
|
||||||
|
for item in build_audit_sample(memory_snapshot, prioritized):
|
||||||
|
queue.append(item)
|
||||||
|
return queue
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_gate.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/review/gate.py tests/review/test_gate.py
|
||||||
|
git commit -m "Add review gate queue builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Agent runner stops after Brain
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/agents/runner.py`
|
||||||
|
- Modify: `cli/run_check.py`
|
||||||
|
- Test: `tests/agents/test_runner_review_gate.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `build_review_queue`, `ReviewStore`.
|
||||||
|
- Produces:
|
||||||
|
- `run_agent_pipeline(..., require_review: bool = True) -> dict`
|
||||||
|
- candidate report contains `summary.agent_status = "needs_review"` and `summary.review = {"required": int, "completed": 0, "blocking": int}` when review is required.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing runner gate test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.agents.runner import run_agent_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_runner_can_enter_review_mode(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.agents.runner.convert_pdf_to_images", lambda path: [{"page_number": 1, "base64": "x"}])
|
||||||
|
monkeypatch.setattr("backend.agents.runner.BrainAgent", lambda usage: type("B", (), {"run": lambda self, findings, sheet_index, jurisdiction: ([{"issue_id": "AGENT-0001", "severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}], [])})())
|
||||||
|
report = run_agent_pipeline("dummy.pdf", out_dir=str(tmp_path), require_review=True)
|
||||||
|
assert report["summary"]["agent_status"] == "needs_review"
|
||||||
|
assert report["summary"]["review"]["required"] == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pytest tests/agents/test_runner_review_gate.py -v`
|
||||||
|
Expected: FAIL because `require_review` is not a supported argument.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement review-mode branch in runner**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.review.gate import build_review_queue
|
||||||
|
from backend.review.store import ReviewStore
|
||||||
|
|
||||||
|
|
||||||
|
def run_agent_pipeline(..., require_review: bool = True) -> Dict:
|
||||||
|
# existing waves through Brain remain unchanged
|
||||||
|
if require_review:
|
||||||
|
memory_snapshot = memory.snapshot()
|
||||||
|
queue = build_review_queue(memory_snapshot, prioritized, decisions)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
store.write_queue(queue)
|
||||||
|
candidate_conflicts = [_finding_as_conflict(item) for item in conflict_findings]
|
||||||
|
report = build_report(
|
||||||
|
conflicts=candidate_conflicts,
|
||||||
|
sheets=sheets,
|
||||||
|
clusters=clusters,
|
||||||
|
source=source_name or os.path.basename(pdf_path),
|
||||||
|
)
|
||||||
|
report.update({
|
||||||
|
"project_input": merged_input,
|
||||||
|
"jurisdiction": jurisdiction,
|
||||||
|
"sheet_index": sheet_index,
|
||||||
|
"project_intelligence": object_graph,
|
||||||
|
"validated_issues": prioritized,
|
||||||
|
"rfis": [],
|
||||||
|
"suppressed_issues": [],
|
||||||
|
})
|
||||||
|
progress = store.progress(queue)
|
||||||
|
report["summary"].update({
|
||||||
|
"pipeline_mode": "agent",
|
||||||
|
"agent_status": "needs_review",
|
||||||
|
"review": progress,
|
||||||
|
})
|
||||||
|
if out_dir:
|
||||||
|
_dump(out_dir, "conflicts.json", report)
|
||||||
|
_dump(out_dir, "validated_issues.json", prioritized)
|
||||||
|
return report
|
||||||
|
# existing RFI/report path remains for require_review=False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `pytest tests/agents/test_runner_review_gate.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/agents/runner.py cli/run_check.py tests/agents/test_runner_review_gate.py
|
||||||
|
git commit -m "Gate agent runs behind required human review"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Job states and review API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/jobs.py`
|
||||||
|
- Modify: `backend/main.py`
|
||||||
|
- Test: `tests/api/test_review_api.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ReviewStore`, `validate_decision`.
|
||||||
|
- Produces:
|
||||||
|
- statuses: `needs_review`, `reviewing`, `finalizing`, `finalization_error`
|
||||||
|
- `GET /jobs/{job_id}/review -> {"queue": list[dict], "progress": dict}`
|
||||||
|
- `POST /jobs/{job_id}/review-decisions`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing API tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_queue_and_decision_save(monkeypatch, tmp_path):
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
|
||||||
|
queue_response = client.get("/jobs/job1/review")
|
||||||
|
assert queue_response.status_code == 200
|
||||||
|
decision_response = client.post("/jobs/job1/review-decisions", json={"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}]})
|
||||||
|
assert decision_response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/api/test_review_api.py -v`
|
||||||
|
Expected: FAIL with 404 because review endpoints do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement job status and endpoints**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/main.py
|
||||||
|
from backend.review.store import ReviewStore
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
queue = store.read_queue()
|
||||||
|
return {"queue": queue, "progress": store.progress(queue)}
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
out_dir = job.get("out_dir") or os.path.join(config.OUTPUT_DIR, job_id)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
for decision in payload.get("decisions") or []:
|
||||||
|
store.append_decision(decision)
|
||||||
|
return {"progress": store.progress(store.read_queue())}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/api/test_review_api.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/jobs.py backend/main.py tests/api/test_review_api.py
|
||||||
|
git commit -m "Add review job states and API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Review finalizer and targeted rerun
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/review/finalizer.py`
|
||||||
|
- Modify: `backend/agents/runner.py`
|
||||||
|
- Modify: `backend/main.py`
|
||||||
|
- Test: `tests/review/test_finalizer.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ReviewStore`, queue items from Task 3, Agent runner helpers.
|
||||||
|
- Produces:
|
||||||
|
- `finalize_review(job_id: str, out_dir: str) -> dict`
|
||||||
|
- `apply_decisions(prioritized: list[dict], decisions: dict[str, dict]) -> tuple[list[dict], list[dict]]`
|
||||||
|
- `rerun_clarified_scopes(memory_snapshot: dict, decisions: dict[str, dict]) -> list[dict]`
|
||||||
|
- `POST /jobs/{job_id}/finalize-review` returns `409` until blocking decisions are complete
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing finalizer tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.review.finalizer import apply_decisions
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_suppresses_with_reason():
|
||||||
|
prioritized = [{"issue_id": "AGENT-0001", "severity": "high"}]
|
||||||
|
decisions = {"finding:AGENT-0001": {"decision": "reject", "reason_code": "duplicate"}}
|
||||||
|
kept, suppressed = apply_decisions(prioritized, decisions)
|
||||||
|
assert kept == []
|
||||||
|
assert suppressed[0]["review_state"] == "rejected"
|
||||||
|
assert suppressed[0]["reason_code"] == "duplicate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsure_is_kept_but_flagged():
|
||||||
|
prioritized = [{"issue_id": "AGENT-0002", "severity": "medium"}]
|
||||||
|
decisions = {"finding:AGENT-0002": {"decision": "unsure"}}
|
||||||
|
kept, suppressed = apply_decisions(prioritized, decisions)
|
||||||
|
assert kept[0]["review_state"] == "unsure"
|
||||||
|
assert suppressed == []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_finalizer.py -v`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'backend.review.finalizer'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement finalizer decision application**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_finalizer.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/review/finalizer.py backend/agents/runner.py tests/review/test_finalizer.py
|
||||||
|
git commit -m "Finalize reviewed agent findings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Feedback labels and metrics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/review/feedback.py`
|
||||||
|
- Create: `backend/review/metrics.py`
|
||||||
|
- Test: `tests/review/test_feedback.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: queue items and validated decisions.
|
||||||
|
- Produces:
|
||||||
|
- `decision_to_label(queue_item: dict, decision: dict, job: dict) -> dict`
|
||||||
|
- `write_label(out_dir: str, label: dict) -> None`
|
||||||
|
- `aggregate_labels(labels: list[dict], include_text: bool = False) -> dict`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing feedback tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.review.metrics import aggregate_labels
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_redacts_text_by_default():
|
||||||
|
labels = [{"decision": "reject", "reason_code": "missing_evidence", "comment": "secret", "payload": {"evidence": [{"source_text": "secret"}]}}]
|
||||||
|
summary = aggregate_labels(labels)
|
||||||
|
assert summary["reject"] == 1
|
||||||
|
assert "secret" not in str(summary)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_feedback.py -v`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'backend.review.metrics'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement label writing and aggregation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_feedback.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/review/feedback.py backend/review/metrics.py tests/review/test_feedback.py
|
||||||
|
git commit -m "Add review feedback labels and aggregate metrics"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Two-phase email
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/email_sender.py`
|
||||||
|
- Modify: `backend/jobs.py`
|
||||||
|
- Test: `tests/api/test_review_email_flow.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: existing `_smtp_ready` and `_send` helpers.
|
||||||
|
- Produces:
|
||||||
|
- `send_review_required(recipient_email: str, report: dict, review_url: str) -> bool`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing email flow test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.email_sender import send_review_required
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_required_email_skips_without_smtp(monkeypatch):
|
||||||
|
monkeypatch.setattr("backend.email_sender._smtp_ready", lambda: False)
|
||||||
|
assert send_review_required("user@example.com", {"source": "set.pdf", "summary": {}}, "http://localhost:8099/?job=abc") is False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/api/test_review_email_flow.py -v`
|
||||||
|
Expected: FAIL with `ImportError: cannot import name 'send_review_required'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement review-required email**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def send_review_required(recipient_email: str, report: dict, review_url: str) -> bool:
|
||||||
|
if not recipient_email or not _smtp_ready():
|
||||||
|
return False
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = f"Conflict Checker - review required - {report.get('source', 'drawing set')}"
|
||||||
|
msg["From"] = config.SMTP_FROM or config.SMTP_USER
|
||||||
|
msg["To"] = recipient_email
|
||||||
|
review = report.get("summary", {}).get("review", {})
|
||||||
|
msg.set_content(
|
||||||
|
"Agent analysis is complete and waiting for human review.\n\n"
|
||||||
|
f"Required review items: {review.get('required', 0)}\n"
|
||||||
|
f"Review URL: {review_url}\n"
|
||||||
|
)
|
||||||
|
return _send(msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/api/test_review_email_flow.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/email_sender.py backend/jobs.py tests/api/test_review_email_flow.py
|
||||||
|
git commit -m "Send review-required email before final report"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Frontend review queue
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/index.html`
|
||||||
|
- Test: `tests/api/test_review_api.py` plus manual browser check
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `GET /jobs/{id}`, `GET /jobs/{id}/review`, `POST /jobs/{id}/review-decisions`, `POST /jobs/{id}/finalize-review`.
|
||||||
|
- Produces: browser flow for `needs_review` jobs.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing API expectation for review progress field**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_job_includes_review_progress(monkeypatch):
|
||||||
|
# Extend tests/api/test_review_api.py to assert get_job returns report.summary.review.
|
||||||
|
assert "review" in {"summary": {"review": {"required": 1, "completed": 0}}}["summary"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify current behavior**
|
||||||
|
|
||||||
|
Run: `pytest tests/api/test_review_api.py -v`
|
||||||
|
Expected: PASS for API fields added in Task 5.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal review UI**
|
||||||
|
|
||||||
|
Add a `renderReview(job)` path in `frontend/index.html` that:
|
||||||
|
- fetches `/jobs/${jobId}/review`,
|
||||||
|
- renders blocking items first,
|
||||||
|
- shows `payload.description`, `payload.location`, `payload.category`, `payload.severity`, `payload.confidence`, and `payload.evidence`,
|
||||||
|
- requires a reason code when `reject` is selected,
|
||||||
|
- posts decisions to `/jobs/${jobId}/review-decisions`,
|
||||||
|
- calls `/jobs/${jobId}/finalize-review` only when `progress.remaining === 0`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual browser check**
|
||||||
|
|
||||||
|
Run: `uvicorn backend.main:app --reload --port 8099`
|
||||||
|
Expected: a synthetic `needs_review` job shows the queue, decisions persist across refresh, and finalize is blocked until required items are decided.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/index.html tests/api/test_review_api.py
|
||||||
|
git commit -m "Add frontend human review queue"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Config, docs, and rollout
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/config.py`
|
||||||
|
- Modify: `backend/.env.example`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Test: `tests/review/test_policy.py`, `tests/review/test_store.py`, `tests/review/test_gate.py`, `tests/agents/test_runner_review_gate.py`, `tests/api/test_review_api.py`, `tests/review/test_finalizer.py`, `tests/review/test_feedback.py`, `tests/api/test_review_email_flow.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all previous tasks.
|
||||||
|
- Produces:
|
||||||
|
- `AGENT_REQUIRE_REVIEW = true`
|
||||||
|
- `AGENT_REVIEW_AUDIT_SAMPLE = 5`
|
||||||
|
- `REVIEW_AGGREGATE_INCLUDE_TEXT = false`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add config assertions to existing policy test file**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend import config
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_defaults():
|
||||||
|
assert config.AGENT_REQUIRE_REVIEW is True
|
||||||
|
assert config.AGENT_REVIEW_AUDIT_SAMPLE == 5
|
||||||
|
assert config.REVIEW_AGGREGATE_INCLUDE_TEXT is False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/review/test_policy.py::test_review_defaults -v`
|
||||||
|
Expected: FAIL with `AttributeError` for missing config values.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement config and docs**
|
||||||
|
|
||||||
|
Add to `backend/config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
AGENT_REQUIRE_REVIEW = os.getenv("AGENT_REQUIRE_REVIEW", "true").strip().lower() in ("1", "true", "yes")
|
||||||
|
AGENT_REVIEW_AUDIT_SAMPLE = int(os.getenv("AGENT_REVIEW_AUDIT_SAMPLE", "5"))
|
||||||
|
REVIEW_AGGREGATE_INCLUDE_TEXT = os.getenv("REVIEW_AGGREGATE_INCLUDE_TEXT", "false").strip().lower() in ("1", "true", "yes")
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the same keys to `backend/.env.example` and document the two-email flow and privacy boundary in `README.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run full test suite**
|
||||||
|
|
||||||
|
Run: `pytest -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/config.py backend/.env.example README.md tests
|
||||||
|
git commit -m "Configure required agent human review"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Handoff
|
||||||
|
|
||||||
|
Plan complete and saved to `docs/superpowers/plans/2026-07-28-agent-human-review.md`. Two execution options:
|
||||||
|
|
||||||
|
**1. Subagent-Driven (recommended)** - Dispatch a fresh subagent per task, review between tasks, fast iteration.
|
||||||
|
|
||||||
|
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints.
|
||||||
|
|
||||||
|
Which approach?
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# Required Human Review for Agent Pipeline Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-28
|
||||||
|
**Status:** Approved
|
||||||
|
**Owner:** Conflict Checker Agent pipeline
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make Agent mode produce higher-quality findings by requiring structured human review before final RFIs/reports are issued, and by turning review decisions into usable feedback for future prompt, rule, threshold, and evaluation improvements.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Agent mode is intended to replace Classic mode. Its advantage is the holistic project picture: sheet extraction, sheet index, jurisdiction, semantic linking, specialist findings, Brain consolidation, and RFI generation. The main quality risks are missed real conflicts, false positives, weak or unsupported findings, and silent stage/scope degradation.
|
||||||
|
|
||||||
|
There are not enough known-good golden sets to rely only on golden-set regression. Human review becomes the feedback mechanism. The human is not expected to review every raw extraction; the human reviews a curated queue after Brain consolidation and before final report/RFI issuance.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional requirements
|
||||||
|
|
||||||
|
1. Agent web jobs must not reach `done` until required human review is complete.
|
||||||
|
2. The Agent pipeline runs through Brain, then enters `needs_review`.
|
||||||
|
3. RFI generation happens only after review finalization.
|
||||||
|
4. Required review items include:
|
||||||
|
- all critical/high severity findings,
|
||||||
|
- all low-confidence findings,
|
||||||
|
- sensitive categories: missing element, code/ADA/egress/fire separation, spatial clash/clearance,
|
||||||
|
- a small audit sample of medium/low findings and clean/no-finding clusters.
|
||||||
|
5. Review decisions support `confirm`, `reject`, `unsure`, and `needs_clarification`.
|
||||||
|
6. Rejections require a reason code.
|
||||||
|
7. Review progress persists to disk and survives server restart.
|
||||||
|
8. Rejected findings are suppressed, not deleted.
|
||||||
|
9. Clarifications are stored as first-class artifacts.
|
||||||
|
10. Where practical, clarification triggers targeted rerun of only the affected scope.
|
||||||
|
11. Aggregate feedback must not contain raw drawing text/images by default.
|
||||||
|
12. Classic mode remains unchanged.
|
||||||
|
|
||||||
|
### Non-functional requirements
|
||||||
|
|
||||||
|
- No automatic prompt mutation from human labels.
|
||||||
|
- No final email before review completion.
|
||||||
|
- Review endpoints must be treated as state-changing and sensitive.
|
||||||
|
- Review logic must be testable without LLM calls, PDFs, OpenRouter, or network access.
|
||||||
|
- Targeted reruns must degrade gracefully and must not crash finalization.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Add three small components.
|
||||||
|
|
||||||
|
### ReviewGate
|
||||||
|
|
||||||
|
Runs after Brain and before RFI/report finalization.
|
||||||
|
|
||||||
|
Consumes:
|
||||||
|
|
||||||
|
- `ProjectMemory` snapshot
|
||||||
|
- Brain prioritized issues
|
||||||
|
- Brain decisions
|
||||||
|
- review policy
|
||||||
|
|
||||||
|
Produces:
|
||||||
|
|
||||||
|
- `review/review_queue.json`
|
||||||
|
- candidate report with `summary.agent_status = "needs_review"`
|
||||||
|
- job transition to `needs_review`
|
||||||
|
|
||||||
|
### ReviewStore
|
||||||
|
|
||||||
|
Owns review persistence under the job output directory.
|
||||||
|
|
||||||
|
Stores:
|
||||||
|
|
||||||
|
- `review/review_queue.json`
|
||||||
|
- `review/review_decisions.json`
|
||||||
|
- `review/review_progress.json`
|
||||||
|
|
||||||
|
Writes must be atomic using a temporary file plus `os.replace`, matching the existing LLM cache/report artifact style.
|
||||||
|
|
||||||
|
### ReviewFinalizer
|
||||||
|
|
||||||
|
Runs after required decisions are submitted.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- validate completeness,
|
||||||
|
- apply decisions,
|
||||||
|
- perform bounded targeted reruns for clarification where supported,
|
||||||
|
- re-run Brain only for affected findings,
|
||||||
|
- draft RFIs only for kept/confirmed issues,
|
||||||
|
- write final artifacts,
|
||||||
|
- transition to `done`,
|
||||||
|
- send final email.
|
||||||
|
|
||||||
|
## Job lifecycle
|
||||||
|
|
||||||
|
Current lifecycle:
|
||||||
|
|
||||||
|
`queued -> running -> done -> email`
|
||||||
|
|
||||||
|
New Agent lifecycle:
|
||||||
|
|
||||||
|
`queued -> running -> needs_review -> reviewing -> finalizing -> done -> email`
|
||||||
|
|
||||||
|
Additional failure state:
|
||||||
|
|
||||||
|
- `finalization_error`
|
||||||
|
|
||||||
|
If the server restarts while a job is in `needs_review` or `reviewing`, the backend rebuilds state from `outputs/<job_id>/conflicts.json`, `outputs/<job_id>/review/review_queue.json`, and `outputs/<job_id>/review/review_decisions.json`.
|
||||||
|
|
||||||
|
## Email behavior
|
||||||
|
|
||||||
|
If email is enabled, Agent mode sends two emails:
|
||||||
|
|
||||||
|
1. **Review required** when the job enters `needs_review`.
|
||||||
|
2. **Final report** only after review finalization.
|
||||||
|
|
||||||
|
If SMTP is not configured, the UI still shows `needs_review` and no email failure crashes the job.
|
||||||
|
|
||||||
|
## Review queue policy
|
||||||
|
|
||||||
|
Blocking review items are findings that meet any of these rules:
|
||||||
|
|
||||||
|
- severity is `critical` or `high`,
|
||||||
|
- confidence is `low`,
|
||||||
|
- category is `missing_element`,
|
||||||
|
- source stage is `code`,
|
||||||
|
- category is in `ada`, `tas_tdlr`, `egress`, `fire_separation`, `occupancy`, `spatial_clash`, `clearance_conflict`, or `penetration_conflict`.
|
||||||
|
|
||||||
|
Audit sample items are selected deterministically from:
|
||||||
|
|
||||||
|
- medium/low findings not already blocking,
|
||||||
|
- clean clusters with no findings,
|
||||||
|
- no-finding scopes when available.
|
||||||
|
|
||||||
|
Default audit sample size is 5 items.
|
||||||
|
|
||||||
|
## Review decision schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"review_item_id": "finding:AGENT-0007",
|
||||||
|
"decision": "reject",
|
||||||
|
"reason_code": "same_value_different_representation",
|
||||||
|
"category_correction": null,
|
||||||
|
"severity_correction": null,
|
||||||
|
"comment": "9'-0\" AFF and 108 inches are the same value here.",
|
||||||
|
"clarification_answer": null,
|
||||||
|
"reviewed_at": "2026-07-28T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed 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`
|
||||||
|
|
||||||
|
## Finalization rules
|
||||||
|
|
||||||
|
- All blocking review items must have a valid decision before finalization.
|
||||||
|
- Confirmed findings become final `validated_issues`.
|
||||||
|
- Unsure findings remain included but are flagged as `review_state = "unsure"`.
|
||||||
|
- Rejected findings become `suppressed_issues` with reason code and comment.
|
||||||
|
- Clarification answers are stored and, when the affected scope is rerunnable, trigger a targeted rerun.
|
||||||
|
- Targeted rerun failure creates an `analysis_gap` finding and does not block finalization unless the reviewer chooses to reject the affected item.
|
||||||
|
- RFIs are drafted only for final kept issues.
|
||||||
|
|
||||||
|
## Feedback labels
|
||||||
|
|
||||||
|
Every decision emits a label artifact for metrics:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"review_item_id": "finding:AGENT-0007",
|
||||||
|
"job_id": "abc123",
|
||||||
|
"pipeline_mode": "agent",
|
||||||
|
"source_stage": "conflict",
|
||||||
|
"category": "elevation_disagreement",
|
||||||
|
"severity": "high",
|
||||||
|
"confidence": "medium",
|
||||||
|
"decision": "reject",
|
||||||
|
"reason_code": "same_value_different_representation",
|
||||||
|
"location": "Room 204 / Level 2",
|
||||||
|
"disciplines": ["Architectural", "Mechanical"],
|
||||||
|
"sheets": ["A2.1", "M2.1"],
|
||||||
|
"drawing_type": "floor_plan",
|
||||||
|
"models_used": ["google/gemini-2.5-pro"],
|
||||||
|
"created_at": "2026-07-28T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default aggregate metrics exclude `source_text`, images, raw sheet content, and reviewer free-text comments.
|
||||||
|
|
||||||
|
## API shape
|
||||||
|
|
||||||
|
- `GET /jobs/{job_id}` includes `needs_review`, `reviewing`, `finalizing`, `done`, `error`, or `finalization_error` plus review progress.
|
||||||
|
- `GET /jobs/{job_id}/review` returns `{ "queue": [...], "progress": {...} }`.
|
||||||
|
- `POST /jobs/{job_id}/review-decisions` saves one or more decisions.
|
||||||
|
- `POST /jobs/{job_id}/finalize-review` validates completeness and finalizes the job.
|
||||||
|
|
||||||
|
## Security and privacy
|
||||||
|
|
||||||
|
Review endpoints are more sensitive than read-only report endpoints because they mutate job state and expose evidence. Before required review is enabled beyond a trusted LAN, the app should have reverse-proxy auth, a shared access token, or explicit deployment documentation stating that the UI/API must not be exposed publicly.
|
||||||
|
|
||||||
|
Review artifacts stay job-local by default. Cross-job aggregate metrics use metadata and reason codes only unless richer retention is explicitly enabled later.
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
Tests must not require PDFs, LLMs, OpenRouter, or network access.
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
|
||||||
|
- required-review trigger policy,
|
||||||
|
- review queue construction,
|
||||||
|
- decision validation and reason codes,
|
||||||
|
- finalization behavior for confirm/reject/unsure/clarification,
|
||||||
|
- restart recovery from review artifacts,
|
||||||
|
- targeted rerun failure degradation,
|
||||||
|
- metrics redaction,
|
||||||
|
- API state transitions,
|
||||||
|
- email flow blocking until finalization.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
- Classic mode is unchanged.
|
||||||
|
- Agent web jobs default to required human review.
|
||||||
|
- CLI supports an explicit bypass flag, `--no-review`, for tuning/debug runs.
|
||||||
|
- Review state and decisions are always written to job artifacts.
|
||||||
|
- Aggregate feedback is metadata-only by default.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- An Agent web job cannot reach `done` or send the final email while required review items are undecided.
|
||||||
|
- Rejected findings are suppressed with reason codes and remain auditable.
|
||||||
|
- Review progress survives server restart.
|
||||||
|
- Clarification failures degrade to visible `analysis_gap`, not job failure.
|
||||||
|
- Aggregate feedback contains no raw drawing text/images by default.
|
||||||
|
- New tests cover the review gate without requiring LLM calls.
|
||||||
+187
-2
@@ -64,6 +64,14 @@
|
|||||||
.note { background:var(--panel); border:1px solid var(--line); border-radius:10px;
|
.note { background:var(--panel); border:1px solid var(--line); border-radius:10px;
|
||||||
padding:16px 18px; margin:18px 0; }
|
padding:16px 18px; margin:18px 0; }
|
||||||
.note b { color:var(--text); }
|
.note b { color:var(--text); }
|
||||||
|
.review-controls { margin-top:10px; padding-top:10px; border-top:1px solid var(--line); font-size:13px; }
|
||||||
|
.review-controls label { margin-right:14px; cursor:pointer; white-space:nowrap; }
|
||||||
|
.review-controls select, .review-controls input[type=text] { background:#0c0e13; color:var(--text);
|
||||||
|
border:1px solid var(--line); border-radius:6px; padding:6px 8px; font-size:13px; margin-top:6px; }
|
||||||
|
.review-controls input[type=text] { width:100%; }
|
||||||
|
.review-controls .hidden { display:none; }
|
||||||
|
.pill.blocking { background:rgba(255,93,87,.15); color:var(--hi); }
|
||||||
|
.pill.audit { background:rgba(91,140,255,.15); color:var(--accent); }
|
||||||
.pill.critical { background:rgba(255,93,87,.28); color:#fff; }
|
.pill.critical { background:rgba(255,93,87,.28); color:#fff; }
|
||||||
.sheetlink { color:var(--accent); cursor:pointer; text-decoration:underline dotted; }
|
.sheetlink { color:var(--accent); cursor:pointer; text-decoration:underline dotted; }
|
||||||
#viewer { position:fixed; inset:0; background:rgba(0,0,0,.88); display:none;
|
#viewer { position:fixed; inset:0; background:rgba(0,0,0,.88); display:none;
|
||||||
@@ -133,7 +141,7 @@ const drop=document.getElementById('drop'), fileInput=document.getElementById('f
|
|||||||
runBtn=document.getElementById('run'), statusEl=document.getElementById('status'),
|
runBtn=document.getElementById('run'), statusEl=document.getElementById('status'),
|
||||||
results=document.getElementById('results'), dropLabel=document.getElementById('dropLabel'),
|
results=document.getElementById('results'), dropLabel=document.getElementById('dropLabel'),
|
||||||
emailEl=document.getElementById('email');
|
emailEl=document.getElementById('email');
|
||||||
let chosen=null, polling=null, currentJobId=null, sheetPage={}, viewerZoom=1;
|
let chosen=null, polling=null, currentJobId=null, sheetPage={}, viewerZoom=1, reviewDirty=false;
|
||||||
|
|
||||||
function setFile(f){ chosen=f; dropLabel.textContent=f?('Selected: '+f.name):'Drop a PDF drawing set here, or click to choose';
|
function setFile(f){ chosen=f; dropLabel.textContent=f?('Selected: '+f.name):'Drop a PDF drawing set here, or click to choose';
|
||||||
runBtn.disabled=!f; }
|
runBtn.disabled=!f; }
|
||||||
@@ -185,6 +193,13 @@ function poll(jobId){
|
|||||||
' · you can leave this page';
|
' · you can leave this page';
|
||||||
} else if(job.status==='done'){
|
} else if(job.status==='done'){
|
||||||
clearInterval(polling); polling=null; runBtn.disabled=false; render(job.report);
|
clearInterval(polling); polling=null; runBtn.disabled=false; render(job.report);
|
||||||
|
} else if(job.status==='needs_review'||job.status==='reviewing'){
|
||||||
|
clearInterval(polling); polling=null; runBtn.disabled=false; renderReview(job);
|
||||||
|
} else if(job.status==='finalizing'){
|
||||||
|
statusEl.innerHTML='<span class="spinner"></span>Finalizing reviewed report...';
|
||||||
|
} else if(job.status==='finalization_error'){
|
||||||
|
clearInterval(polling); polling=null; runBtn.disabled=false;
|
||||||
|
statusEl.textContent='Finalization failed: '+(job.error||'unknown error');
|
||||||
} else if(job.status==='error'){
|
} else if(job.status==='error'){
|
||||||
clearInterval(polling); polling=null; runBtn.disabled=false;
|
clearInterval(polling); polling=null; runBtn.disabled=false;
|
||||||
statusEl.textContent='Run failed: '+(job.error||'unknown error');
|
statusEl.textContent='Run failed: '+(job.error||'unknown error');
|
||||||
@@ -196,6 +211,7 @@ function poll(jobId){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function esc(s){ return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
function esc(s){ return (s==null?'':String(s)).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
||||||
|
function escAttr(s){ return esc(s).replace(/"/g,'"'); }
|
||||||
|
|
||||||
function syncPipelineOptions(){
|
function syncPipelineOptions(){
|
||||||
const agent=(document.querySelector('input[name="pipeline_mode"]:checked')||{}).value==='agent';
|
const agent=(document.querySelector('input[name="pipeline_mode"]:checked')||{}).value==='agent';
|
||||||
@@ -284,10 +300,13 @@ function render(rep){
|
|||||||
html+='<details open style="margin-top:24px"><summary><b>QAQC issues ('+issues.length+')</b> '+
|
html+='<details open style="margin-top:24px"><summary><b>QAQC issues ('+issues.length+')</b> '+
|
||||||
'<span class="opt">conflicts + full-set + code/ADA + constructability, deduplicated</span></summary>';
|
'<span class="opt">conflicts + full-set + code/ADA + constructability, deduplicated</span></summary>';
|
||||||
for(const c of issues){
|
for(const c of issues){
|
||||||
|
const rs=c.review_state;
|
||||||
html+='<div class="conflict '+esc(c.severity)+'">'+
|
html+='<div class="conflict '+esc(c.severity)+'">'+
|
||||||
'<div class="row"><span class="cat">'+esc(c.source_stage)+' · '+esc(c.category)+'</span>'+
|
'<div class="row"><span class="cat">'+esc(c.source_stage)+' · '+esc(c.category)+'</span>'+
|
||||||
'<span class="pill '+esc(c.severity)+'">'+esc(c.severity)+
|
'<span class="pill '+esc(c.severity)+'">'+esc(c.severity)+
|
||||||
(c.risk_score!=null?(' · risk '+esc(c.risk_score)):'')+'</span></div>'+
|
(c.risk_score!=null?(' · risk '+esc(c.risk_score)):'')+'</span>'+
|
||||||
|
(rs&&['unsure','clarified','clarification_failed'].includes(rs)?
|
||||||
|
' <span class="pill audit">'+esc(rs.replace(/_/g,' '))+'</span>':'')+'</div>'+
|
||||||
'<div class="loc">'+esc(c.location)+'</div>'+
|
'<div class="loc">'+esc(c.location)+'</div>'+
|
||||||
((c.sheets||[]).length?('<div class="meta">Sheets: '+sheetList(c.sheets)+'</div>'):'')+
|
((c.sheets||[]).length?('<div class="meta">Sheets: '+sheetList(c.sheets)+'</div>'):'')+
|
||||||
'<div class="desc">'+esc(c.description)+'</div>';
|
'<div class="desc">'+esc(c.description)+'</div>';
|
||||||
@@ -317,6 +336,172 @@ function render(rep){
|
|||||||
}
|
}
|
||||||
function stat(v,l){ return '<div class="stat"><b>'+esc(v)+'</b><span>'+esc(l)+'</span></div>'; }
|
function stat(v,l){ return '<div class="stat"><b>'+esc(v)+'</b><span>'+esc(l)+'</span></div>'; }
|
||||||
|
|
||||||
|
// --- human review queue (agent pipeline) ---
|
||||||
|
const REVIEW_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'];
|
||||||
|
const REVIEW_DECISIONS=['confirm','reject','unsure','needs_clarification'];
|
||||||
|
|
||||||
|
async function renderReview(job){
|
||||||
|
const jobId=job.job_id||currentJobId;
|
||||||
|
currentJobId=jobId;
|
||||||
|
statusEl.textContent='Analysis complete \u2014 human review required.';
|
||||||
|
let data;
|
||||||
|
try{
|
||||||
|
const res=await fetch('/jobs/'+jobId+'/review');
|
||||||
|
if(!res.ok) throw new Error('could not load review queue');
|
||||||
|
data=await res.json();
|
||||||
|
}catch(err){ statusEl.textContent='Error: '+err.message; return; }
|
||||||
|
const queue=data.queue||[], prog=data.progress||{}, prior=data.decisions||{};
|
||||||
|
let html='<div class="note"><b>Analysis complete \u2014 human review required.</b><br>'+
|
||||||
|
esc(prog.completed||0)+' of '+esc(prog.required||0)+' required items decided.'+
|
||||||
|
((prog.remaining||0)>0?' Decide all blocking items, save, then finalize.':
|
||||||
|
' All required items decided \u2014 you can finalize.')+'</div>';
|
||||||
|
const blocking=queue.filter(i=>i.blocking), audit=queue.filter(i=>!i.blocking);
|
||||||
|
blocking.forEach((item,i)=>{ html+=reviewItemHtml(item,'b'+i,prior[item.review_item_id]); });
|
||||||
|
if(audit.length){
|
||||||
|
html+='<details style="margin-top:16px"><summary><b>Audit items ('+audit.length+')</b> '+
|
||||||
|
'<span class="opt">non-blocking — decisions optional</span></summary>';
|
||||||
|
audit.forEach((item,i)=>{ html+=reviewItemHtml(item,'a'+i,prior[item.review_item_id]); });
|
||||||
|
html+='</details>';
|
||||||
|
}
|
||||||
|
html+='<div style="margin:18px 0">'+
|
||||||
|
'<button class="btn" id="saveReviewBtn">Save decisions</button> '+
|
||||||
|
'<button class="btn" id="finalizeBtn"'+((prog.remaining||0)===0?'':' disabled')+
|
||||||
|
'>Finalize & send report</button></div>'+
|
||||||
|
'<div class="status" id="reviewMsg"></div>';
|
||||||
|
results.innerHTML=html;
|
||||||
|
results.querySelectorAll('.review-item input[type=radio]').forEach(r=>{
|
||||||
|
r.addEventListener('change',()=>syncReviewControls(r.closest('.review-item')));
|
||||||
|
});
|
||||||
|
reviewDirty=false;
|
||||||
|
results.querySelectorAll('.review-item input,.review-item select').forEach(el=>{
|
||||||
|
el.addEventListener('change',()=>{ reviewDirty=true; });
|
||||||
|
});
|
||||||
|
document.getElementById('saveReviewBtn').addEventListener('click',saveReviewDecisions);
|
||||||
|
document.getElementById('finalizeBtn').addEventListener('click',finalizeReview);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewItemHtml(item,uid,prev){
|
||||||
|
prev=prev||{};
|
||||||
|
const p=item.payload||{};
|
||||||
|
const sev=p.severity||'medium';
|
||||||
|
let html='<div class="conflict '+escAttr(sev)+' review-item" data-id="'+escAttr(item.review_item_id)+'">'+
|
||||||
|
'<div class="row"><span class="cat">'+esc(p.category||item.kind)+'</span>'+
|
||||||
|
'<span><span class="pill '+(item.blocking?'blocking':'audit')+'">'+
|
||||||
|
(item.blocking?'blocking':'audit')+'</span> '+
|
||||||
|
(p.severity?'<span class="pill '+escAttr(sev)+'">'+esc(sev)+'</span>':'')+'</span></div>';
|
||||||
|
if(item.kind==='clean_cluster'){
|
||||||
|
html+='<div class="loc">'+esc(p.location||p.key||'(cluster)')+'</div>'+
|
||||||
|
'<div class="meta">Cluster '+esc(p.key||'')+' · '+
|
||||||
|
esc((p.assertions||[]).length)+' assertions</div>';
|
||||||
|
}else{
|
||||||
|
html+='<div class="loc">'+esc(p.location||'')+'</div>'+
|
||||||
|
'<div class="desc">'+esc(p.description||'')+'</div>'+
|
||||||
|
(p.confidence?'<div class="meta">Confidence: '+esc(p.confidence)+'</div>':'');
|
||||||
|
if(p.evidence&&p.evidence.length){
|
||||||
|
html+='<div class="ev">'+p.evidence.map(e=>
|
||||||
|
'<div><span class="d">'+esc(e.discipline)+'</span> ('+sheetSpan(e.sheet)+'): "'+
|
||||||
|
esc(e.source_text)+'"</div>').join('')+'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if((item.reasons||[]).length){
|
||||||
|
html+='<div class="meta">Review triggers: '+esc(item.reasons.join(', '))+'</div>';
|
||||||
|
}
|
||||||
|
html+='<div class="review-controls">'+
|
||||||
|
REVIEW_DECISIONS.map(d=>'<label><input type="radio" name="dec-'+uid+'" value="'+d+'"'+
|
||||||
|
(prev.decision===d?' checked':'')+'> '+esc(d.replace(/_/g,' '))+'</label>').join('')+
|
||||||
|
'<select class="reason'+(prev.decision==='reject'?'':' hidden')+'">'+
|
||||||
|
'<option value="">Reason code (required for reject)...</option>'+
|
||||||
|
REVIEW_REASON_CODES.map(c=>'<option value="'+c+'"'+(prev.reason_code===c?' selected':'')+
|
||||||
|
'>'+esc(c.replace(/_/g,' '))+'</option>').join('')+'</select>'+
|
||||||
|
'<input type="text" class="comment" placeholder="Comment (optional)" value="'+escAttr(prev.comment||'')+'">'+
|
||||||
|
'<input type="text" class="clar'+(prev.decision==='needs_clarification'?'':' hidden')+
|
||||||
|
'" placeholder="Clarification answer" value="'+escAttr(prev.clarification_answer||'')+'">'+
|
||||||
|
'</div></div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReviewControls(el){
|
||||||
|
const sel=el.querySelector('input[type=radio]:checked');
|
||||||
|
const v=sel?sel.value:'';
|
||||||
|
el.querySelector('.reason').classList.toggle('hidden',v!=='reject');
|
||||||
|
if(v!=='reject') el.querySelector('.reason').value='';
|
||||||
|
el.querySelector('.clar').classList.toggle('hidden',v!=='needs_clarification');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewMsg(m,isErr){
|
||||||
|
const el=document.getElementById('reviewMsg');
|
||||||
|
if(el){ el.style.color=isErr?'var(--hi)':'var(--muted)'; el.textContent=m; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectReviewDecisions(){
|
||||||
|
const decisions=[], missingReason=[];
|
||||||
|
results.querySelectorAll('.review-item').forEach(el=>{
|
||||||
|
const id=el.getAttribute('data-id');
|
||||||
|
const sel=el.querySelector('input[type=radio]:checked');
|
||||||
|
if(!sel) return;
|
||||||
|
const reason=el.querySelector('.reason').value;
|
||||||
|
if(sel.value==='reject'&&!reason){ missingReason.push(id); return; }
|
||||||
|
const d={review_item_id:id, decision:sel.value,
|
||||||
|
comment:el.querySelector('.comment').value.trim()};
|
||||||
|
if(sel.value==='reject') d.reason_code=reason;
|
||||||
|
else if(reason) d.reason_code=reason;
|
||||||
|
if(sel.value==='needs_clarification')
|
||||||
|
d.clarification_answer=el.querySelector('.clar').value.trim()||null;
|
||||||
|
decisions.push(d);
|
||||||
|
});
|
||||||
|
return {decisions, missingReason};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postReviewDecisions(decisions){
|
||||||
|
const res=await fetch('/jobs/'+currentJobId+'/review-decisions',
|
||||||
|
{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({decisions})});
|
||||||
|
if(!res.ok){
|
||||||
|
const err=await res.json().catch(()=>({detail:res.statusText}));
|
||||||
|
const detail=typeof err.detail==='string'?err.detail:JSON.stringify(err.detail);
|
||||||
|
throw new Error(detail||'Request failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReviewDecisions(){
|
||||||
|
const {decisions,missingReason}=collectReviewDecisions();
|
||||||
|
if(missingReason.length){
|
||||||
|
reviewMsg('Reject requires a reason code: '+missingReason.join(', '),true); return;
|
||||||
|
}
|
||||||
|
if(!decisions.length){ reviewMsg('No decisions set yet.',true); return; }
|
||||||
|
try{
|
||||||
|
await postReviewDecisions(decisions);
|
||||||
|
renderReview({job_id:currentJobId});
|
||||||
|
}catch(err){ reviewMsg('Save failed: '+err.message,true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeReview(){
|
||||||
|
reviewMsg('');
|
||||||
|
try{
|
||||||
|
if(reviewDirty){
|
||||||
|
// Auto-save unsaved control edits so they aren't lost at finalize.
|
||||||
|
const {decisions,missingReason}=collectReviewDecisions();
|
||||||
|
if(missingReason.length){
|
||||||
|
reviewMsg('Reject requires a reason code: '+missingReason.join(', '),true); return;
|
||||||
|
}
|
||||||
|
if(decisions.length) await postReviewDecisions(decisions);
|
||||||
|
reviewDirty=false;
|
||||||
|
}
|
||||||
|
const res=await fetch('/jobs/'+currentJobId+'/finalize-review',{method:'POST'});
|
||||||
|
if(res.status===409){
|
||||||
|
const err=await res.json().catch(()=>({}));
|
||||||
|
const d=err.detail||{};
|
||||||
|
const prog=d.progress?(' ('+(d.progress.remaining||0)+' required items undecided)'):'';
|
||||||
|
reviewMsg('Cannot finalize: '+(d.detail||'conflict')+prog,true); return;
|
||||||
|
}
|
||||||
|
if(!res.ok) throw new Error('Request failed ('+res.status+')');
|
||||||
|
statusEl.innerHTML='<span class="spinner"></span>Finalizing reviewed report...';
|
||||||
|
poll(currentJobId);
|
||||||
|
}catch(err){ reviewMsg('Finalize failed: '+err.message,true); }
|
||||||
|
}
|
||||||
|
|
||||||
// If opened from an email link (/?job=<id>), load that job's results directly.
|
// If opened from an email link (/?job=<id>), load that job's results directly.
|
||||||
(function init(){
|
(function init(){
|
||||||
const jobId=new URLSearchParams(location.search).get('job');
|
const jobId=new URLSearchParams(location.search).get('job');
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from backend.agents.base import AgentResult
|
||||||
|
from backend.agents.runner import run_agent_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_brain(monkeypatch):
|
||||||
|
monkeypatch.setattr("backend.agents.runner.convert_pdf_to_images", lambda path: [{"page_number": 1, "base64": "x"}])
|
||||||
|
monkeypatch.setattr("backend.agents.runner.BrainAgent", lambda usage: type("B", (), {"run": lambda self, findings, sheet_index, jurisdiction: ([{"issue_id": "AGENT-0001", "severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}], [])})())
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_runner_can_enter_review_mode(monkeypatch, tmp_path):
|
||||||
|
_patch_brain(monkeypatch)
|
||||||
|
pdf = tmp_path / "dummy.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4\n")
|
||||||
|
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path), require_review=True)
|
||||||
|
assert report["summary"]["agent_status"] == "needs_review"
|
||||||
|
assert report["summary"]["review"]["required"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_mode_writes_memory_snapshot(monkeypatch, tmp_path):
|
||||||
|
"""The finalizer needs agent/memory.json for targeted clarification reruns."""
|
||||||
|
_patch_brain(monkeypatch)
|
||||||
|
pdf = tmp_path / "dummy.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4\n")
|
||||||
|
run_agent_pipeline(str(pdf), out_dir=str(tmp_path), require_review=True)
|
||||||
|
assert (tmp_path / "agent" / "memory.json").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_mode_summary_includes_agent_observability(monkeypatch, tmp_path):
|
||||||
|
"""Review-mode candidate reports must carry the same usage/stats block as
|
||||||
|
the wave-7 path so finalizer fix-ups and feedback labels have real data."""
|
||||||
|
_patch_brain(monkeypatch)
|
||||||
|
pdf = tmp_path / "dummy.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4\n")
|
||||||
|
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path), require_review=True)
|
||||||
|
summary = report["summary"]
|
||||||
|
assert "agent_stats" in summary
|
||||||
|
assert summary["by_stage"]["rfis"] == 0
|
||||||
|
assert summary["by_stage"]["validated"] == 1
|
||||||
|
assert "conflicts" in summary["by_stage"]
|
||||||
|
assert "cost_usd" in summary
|
||||||
|
assert "llm_calls" in summary
|
||||||
|
assert "cached_calls" in summary
|
||||||
|
assert "cost_by_stage" in summary
|
||||||
|
assert "models_used" in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_runner_without_review_still_writes_rfis(monkeypatch, tmp_path):
|
||||||
|
_patch_brain(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.agents.runner.RFIWriterAgent",
|
||||||
|
lambda usage: type("R", (), {
|
||||||
|
"name": "rfi_writer",
|
||||||
|
"run": lambda self, scope: AgentResult(
|
||||||
|
scope_id=scope.scope_id,
|
||||||
|
artifacts=[{"issue_id": "AGENT-0001", "question": "Confirm intent?"}],
|
||||||
|
),
|
||||||
|
})(),
|
||||||
|
)
|
||||||
|
pdf = tmp_path / "dummy.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4\n")
|
||||||
|
report = run_agent_pipeline(str(pdf), out_dir=str(tmp_path), require_review=False)
|
||||||
|
assert report["summary"]["agent_status"] == "complete"
|
||||||
|
assert "review" not in report["summary"]
|
||||||
|
assert len(report["rfis"]) == 1
|
||||||
|
assert report["rfis"][0]["issue_id"] == "AGENT-0001"
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"""API tests for the human-review endpoints and review-aware job states."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import backend.jobs
|
||||||
|
from backend.main import app
|
||||||
|
from backend.review.store import ReviewStore
|
||||||
|
|
||||||
|
|
||||||
|
class _SyncThread:
|
||||||
|
"""Drop-in threading.Thread replacement that runs the target inline."""
|
||||||
|
|
||||||
|
def __init__(self, target=None, args=(), **kwargs):
|
||||||
|
self._target = target
|
||||||
|
self._args = args
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._target(*self._args)
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_item(item_id: str) -> dict:
|
||||||
|
return {"review_item_id": item_id, "kind": "finding",
|
||||||
|
"blocking": True, "reasons": ["high_severity"], "payload": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_queue_and_decision_save(monkeypatch, tmp_path):
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
store.write_queue([_queue_item("finding:AGENT-0001")])
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
|
||||||
|
queue_response = client.get("/jobs/job1/review")
|
||||||
|
assert queue_response.status_code == 200
|
||||||
|
decision_response = client.post("/jobs/job1/review-decisions", json={"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}]})
|
||||||
|
assert decision_response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_decision_emits_feedback_label(monkeypatch, tmp_path):
|
||||||
|
"""Every saved decision appends one feedback label under review/."""
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
store.write_queue([_queue_item("finding:AGENT-0001")])
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
|
||||||
|
response = client.post("/jobs/job1/review-decisions", json={"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}]})
|
||||||
|
assert response.status_code == 200
|
||||||
|
path = os.path.join(str(tmp_path), "review", "feedback_labels.jsonl")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
labels = [json.loads(line) for line in f if line.strip()]
|
||||||
|
assert len(labels) == 1
|
||||||
|
assert labels[0]["review_item_id"] == "finding:AGENT-0001"
|
||||||
|
assert labels[0]["decision"] == "confirm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_endpoints_404_for_unknown_job(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/jobs/nope/review").status_code == 404
|
||||||
|
assert client.post("/jobs/nope/review-decisions", json={"decisions": []}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_decision_invalid_returns_422(monkeypatch, tmp_path):
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
|
||||||
|
response = client.post("/jobs/job1/review-decisions", json={"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "bogus"}]})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_review_moves_job_to_reviewing(monkeypatch, tmp_path):
|
||||||
|
"""Saving some but not all required decisions flips needs_review -> reviewing."""
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
store.write_queue([_queue_item("finding:AGENT-0001"), _queue_item("finding:AGENT-0002")])
|
||||||
|
job_id = "jobreviewing"
|
||||||
|
backend.jobs._jobs[job_id] = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": "needs_review",
|
||||||
|
"out_dir": str(tmp_path),
|
||||||
|
"report": {"summary": {}},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(f"/jobs/{job_id}/review-decisions", json={
|
||||||
|
"decisions": [{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}],
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["progress"]["remaining"] == 1
|
||||||
|
assert backend.jobs.get_job(job_id)["status"] == "reviewing"
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_job_needs_review_skips_notify(monkeypatch, tmp_path):
|
||||||
|
"""Carried finding from Task 4: an agent report needing review must not be emailed."""
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr("backend.jobs.run_agent_pipeline", lambda pdf_path, **kw: {
|
||||||
|
"summary": {"agent_status": "needs_review"}, "conflicts": [],
|
||||||
|
})
|
||||||
|
monkeypatch.setattr("backend.jobs.send_conflict_report", lambda *a, **kw: sent.append((a, kw)))
|
||||||
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
||||||
|
|
||||||
|
pdf = tmp_path / "upload.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4 dummy")
|
||||||
|
job_id = backend.jobs.create_job(str(pdf), source_filename="set.pdf",
|
||||||
|
email="arch@example.com", pipeline_mode="agent")
|
||||||
|
try:
|
||||||
|
job = backend.jobs.get_job(job_id)
|
||||||
|
assert job["status"] == "needs_review"
|
||||||
|
assert job["report"]["summary"]["agent_status"] == "needs_review"
|
||||||
|
assert sent == []
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_job_still_completes_and_notifies(monkeypatch, tmp_path):
|
||||||
|
"""Classic pipeline behavior is unchanged: done status + completion email."""
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr("backend.jobs.run_pipeline", lambda pdf_path, **kw: {
|
||||||
|
"summary": {}, "conflicts": [],
|
||||||
|
})
|
||||||
|
monkeypatch.setattr("backend.jobs.send_conflict_report", lambda *a, **kw: sent.append((a, kw)))
|
||||||
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
||||||
|
|
||||||
|
pdf = tmp_path / "upload.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4 dummy")
|
||||||
|
job_id = backend.jobs.create_job(str(pdf), source_filename="set.pdf",
|
||||||
|
email="arch@example.com", pipeline_mode="classic")
|
||||||
|
try:
|
||||||
|
assert backend.jobs.get_job(job_id)["status"] == "done"
|
||||||
|
assert len(sent) == 1
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_status_includes_review_progress(monkeypatch, tmp_path):
|
||||||
|
"""GET /jobs/{id} surfaces report.summary.review for a needs_review job."""
|
||||||
|
review = {"required": 1, "completed": 0, "remaining": 1, "total": 1}
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {
|
||||||
|
"job_id": job_id, "status": "needs_review",
|
||||||
|
"report": {"summary": {"agent_status": "needs_review", "review": review}},
|
||||||
|
"out_dir": str(tmp_path),
|
||||||
|
})
|
||||||
|
response = client.get("/jobs/job1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["status"] == "needs_review"
|
||||||
|
assert body["report"]["summary"]["review"] == review
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_response_includes_saved_decisions(monkeypatch, tmp_path):
|
||||||
|
"""GET /jobs/{id}/review also returns the decisions map for UI pre-population."""
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
store.write_queue([_queue_item("finding:AGENT-0001")])
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {"job_id": job_id, "status": "needs_review", "report": {"summary": {}}, "out_dir": str(tmp_path)})
|
||||||
|
post = client.post("/jobs/job1/review-decisions", json={"decisions": [
|
||||||
|
{"review_item_id": "finding:AGENT-0001", "decision": "confirm", "comment": "looks right"},
|
||||||
|
]})
|
||||||
|
assert post.status_code == 200
|
||||||
|
get = client.get("/jobs/job1/review")
|
||||||
|
assert get.status_code == 200
|
||||||
|
decisions = get.json()["decisions"]
|
||||||
|
assert decisions["finding:AGENT-0001"]["decision"] == "confirm"
|
||||||
|
assert decisions["finding:AGENT-0001"]["comment"] == "looks right"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_flow_via_disk_fallback(monkeypatch, tmp_path):
|
||||||
|
"""Smoke: a synthetic on-disk needs_review job served by the REAL get_job
|
||||||
|
(disk fallback), with decisions persisting across review GETs."""
|
||||||
|
job_id = "jobdisk"
|
||||||
|
out_dir = os.path.join(str(tmp_path), job_id)
|
||||||
|
os.makedirs(out_dir)
|
||||||
|
report = {
|
||||||
|
"source": "set.pdf",
|
||||||
|
"summary": {
|
||||||
|
"agent_status": "needs_review",
|
||||||
|
"review": {"required": 1, "completed": 0, "remaining": 1, "total": 1},
|
||||||
|
},
|
||||||
|
"conflicts": [],
|
||||||
|
}
|
||||||
|
with open(os.path.join(out_dir, "conflicts.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
store.write_queue([_queue_item("finding:AGENT-0001")])
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
job_response = client.get(f"/jobs/{job_id}")
|
||||||
|
assert job_response.status_code == 200
|
||||||
|
assert job_response.json()["report"]["summary"]["review"]["required"] == 1
|
||||||
|
|
||||||
|
review_response = client.get(f"/jobs/{job_id}/review")
|
||||||
|
assert review_response.status_code == 200
|
||||||
|
body = review_response.json()
|
||||||
|
assert [i["review_item_id"] for i in body["queue"]] == ["finding:AGENT-0001"]
|
||||||
|
assert body["progress"]["remaining"] == 1
|
||||||
|
assert body["decisions"] == {}
|
||||||
|
|
||||||
|
post = client.post(f"/jobs/{job_id}/review-decisions", json={"decisions": [
|
||||||
|
{"review_item_id": "finding:AGENT-0001", "decision": "reject",
|
||||||
|
"reason_code": "not_a_contradiction"},
|
||||||
|
]})
|
||||||
|
assert post.status_code == 200
|
||||||
|
assert post.json()["progress"]["remaining"] == 0
|
||||||
|
|
||||||
|
again = client.get(f"/jobs/{job_id}/review")
|
||||||
|
assert again.status_code == 200
|
||||||
|
saved = again.json()["decisions"]["finding:AGENT-0001"]
|
||||||
|
assert saved["decision"] == "reject"
|
||||||
|
assert saved["reason_code"] == "not_a_contradiction"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_restart_job(tmp_path, job_id, email=None):
|
||||||
|
"""On-disk needs_review job artifacts, as a pre-restart run would leave
|
||||||
|
them: candidate report + memory snapshot + review queue (+ job.json)."""
|
||||||
|
out_dir = os.path.join(str(tmp_path), job_id)
|
||||||
|
os.makedirs(os.path.join(out_dir, "agent"))
|
||||||
|
report = {
|
||||||
|
"source": "set.pdf",
|
||||||
|
"generated_at": "2026-07-28T00:00:00+00:00",
|
||||||
|
"summary": {
|
||||||
|
"sheets_analyzed": 0, "disciplines": [], "assertions_extracted": 0,
|
||||||
|
"clusters_checked": 0, "conflicts_found": 0,
|
||||||
|
"by_severity": {"high": 0, "medium": 0, "low": 0}, "by_category": {},
|
||||||
|
"pipeline_mode": "agent", "agent_status": "needs_review",
|
||||||
|
"review": {"required": 1, "completed": 0, "remaining": 1, "total": 1},
|
||||||
|
},
|
||||||
|
"conflicts": [], "sheets": [],
|
||||||
|
"validated_issues": [{"issue_id": "AGENT-0001", "severity": "high"}],
|
||||||
|
"suppressed_issues": [], "rfis": [],
|
||||||
|
}
|
||||||
|
with open(os.path.join(out_dir, "conflicts.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f)
|
||||||
|
with open(os.path.join(out_dir, "agent", "memory.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({}, f)
|
||||||
|
if email is not None:
|
||||||
|
with open(os.path.join(out_dir, "job.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"job_id": job_id, "email": email,
|
||||||
|
"pipeline_mode": "agent", "source": "set.pdf"}, f)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
store.write_queue([_queue_item("finding:AGENT-0001")])
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_recovered_needs_review_job_finalizes(monkeypatch, tmp_path):
|
||||||
|
"""CRITICAL: after a restart, a needs_review job recovered from disk keeps
|
||||||
|
its status (not "done"), hydrates the in-memory registry, and the whole
|
||||||
|
decide -> finalize flow completes to done via the real get_job."""
|
||||||
|
job_id = "jobrestart"
|
||||||
|
_write_restart_job(tmp_path, job_id)
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
monkeypatch.setattr("backend.jobs._notify", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
||||||
|
try:
|
||||||
|
# Real disk fallback: simulates a fresh post-restart process.
|
||||||
|
job = backend.jobs.get_job(job_id)
|
||||||
|
assert job["status"] == "needs_review"
|
||||||
|
assert job_id in backend.jobs._jobs # hydrated for _set() transitions
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
post = client.post(f"/jobs/{job_id}/review-decisions", json={"decisions": [
|
||||||
|
{"review_item_id": "finding:AGENT-0001", "decision": "confirm"},
|
||||||
|
]})
|
||||||
|
assert post.status_code == 200
|
||||||
|
|
||||||
|
fin = client.post(f"/jobs/{job_id}/finalize-review")
|
||||||
|
assert fin.status_code == 200
|
||||||
|
job = backend.jobs.get_job(job_id)
|
||||||
|
assert job["status"] == "done"
|
||||||
|
assert job["report"]["summary"]["agent_status"] == "complete"
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_recovered_job_final_email_uses_job_json(monkeypatch, tmp_path):
|
||||||
|
"""CRITICAL: job.json (written at job start) restores the recipient email
|
||||||
|
after a restart, so finalization still fires the final report email."""
|
||||||
|
job_id = "jobemail"
|
||||||
|
_write_restart_job(tmp_path, job_id, email="arch@example.com")
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr("backend.jobs.send_conflict_report",
|
||||||
|
lambda email, report, **kw: sent.append(email))
|
||||||
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
||||||
|
try:
|
||||||
|
job = backend.jobs.get_job(job_id)
|
||||||
|
assert job["status"] == "needs_review"
|
||||||
|
assert job["email"] == "arch@example.com"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
post = client.post(f"/jobs/{job_id}/review-decisions", json={"decisions": [
|
||||||
|
{"review_item_id": "finding:AGENT-0001", "decision": "confirm"},
|
||||||
|
]})
|
||||||
|
assert post.status_code == 200
|
||||||
|
fin = client.post(f"/jobs/{job_id}/finalize-review")
|
||||||
|
assert fin.status_code == 200
|
||||||
|
assert backend.jobs.get_job(job_id)["status"] == "done"
|
||||||
|
assert sent == ["arch@example.com"]
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_decisions_409_for_non_review_job(monkeypatch, tmp_path):
|
||||||
|
"""Positive state guard: only needs_review/reviewing jobs accept decisions."""
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {
|
||||||
|
"job_id": job_id, "status": "done", "out_dir": str(tmp_path),
|
||||||
|
})
|
||||||
|
response = client.post("/jobs/job1/review-decisions", json={"decisions": [
|
||||||
|
{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}]})
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "done" in response.json()["detail"]["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_queue_get_does_not_create_review_dir(monkeypatch, tmp_path):
|
||||||
|
"""The read-only GET endpoint must not create review/ dirs on read."""
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {
|
||||||
|
"job_id": job_id, "status": "needs_review",
|
||||||
|
"report": {"summary": {}}, "out_dir": str(tmp_path),
|
||||||
|
})
|
||||||
|
response = client.get("/jobs/job1/review")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["queue"] == []
|
||||||
|
assert response.json()["decisions"] == {}
|
||||||
|
assert not os.path.exists(os.path.join(str(tmp_path), "review"))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_finalizable_job(tmp_path, decisions):
|
||||||
|
"""Minimal review-mode artifacts: candidate report + queue + decisions."""
|
||||||
|
out_dir = str(tmp_path)
|
||||||
|
os.makedirs(os.path.join(out_dir, "agent"), exist_ok=True)
|
||||||
|
report = {
|
||||||
|
"source": "set.pdf",
|
||||||
|
"generated_at": "2026-07-28T00:00:00+00:00",
|
||||||
|
"summary": {
|
||||||
|
"sheets_analyzed": 0, "disciplines": [], "assertions_extracted": 0,
|
||||||
|
"clusters_checked": 0, "conflicts_found": 0,
|
||||||
|
"by_severity": {"high": 0, "medium": 0, "low": 0}, "by_category": {},
|
||||||
|
"pipeline_mode": "agent", "agent_status": "needs_review",
|
||||||
|
"review": {"required": 1, "completed": 0, "remaining": 1, "total": 1},
|
||||||
|
},
|
||||||
|
"conflicts": [], "sheets": [],
|
||||||
|
"validated_issues": [{"issue_id": "AGENT-0001", "severity": "high"}],
|
||||||
|
"suppressed_issues": [], "rfis": [],
|
||||||
|
}
|
||||||
|
with open(os.path.join(out_dir, "conflicts.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f)
|
||||||
|
with open(os.path.join(out_dir, "agent", "memory.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump({}, f)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
store.write_queue([_queue_item("finding:AGENT-0001")])
|
||||||
|
for decision in decisions:
|
||||||
|
store.append_decision(decision)
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_review_409_while_undecided(monkeypatch, tmp_path):
|
||||||
|
out_dir = _write_finalizable_job(tmp_path, decisions=[])
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {
|
||||||
|
"job_id": job_id, "status": "needs_review", "out_dir": out_dir,
|
||||||
|
})
|
||||||
|
response = client.post("/jobs/job1/finalize-review")
|
||||||
|
assert response.status_code == 409
|
||||||
|
body = response.json()
|
||||||
|
assert body["detail"]["detail"] == "incomplete review"
|
||||||
|
assert body["detail"]["progress"]["remaining"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_review_404_for_unknown_job(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.post("/jobs/nope/finalize-review").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_review_409_when_already_done(monkeypatch, tmp_path):
|
||||||
|
client = TestClient(app)
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {
|
||||||
|
"job_id": job_id, "status": "done", "out_dir": str(tmp_path),
|
||||||
|
})
|
||||||
|
assert client.post("/jobs/job1/finalize-review").status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_review_409_for_job_not_in_review(monkeypatch, tmp_path):
|
||||||
|
"""A running (or otherwise non-review) job must not be finalizable: no
|
||||||
|
finalization thread, no artifact clobbering, no final email."""
|
||||||
|
threads = []
|
||||||
|
notified = []
|
||||||
|
monkeypatch.setattr(threading, "Thread",
|
||||||
|
lambda *a, **kw: threads.append((a, kw)) or _SyncThread(*a, **kw))
|
||||||
|
monkeypatch.setattr("backend.jobs._notify",
|
||||||
|
lambda *a, **kw: notified.append(a))
|
||||||
|
monkeypatch.setattr("backend.main.get_job", lambda job_id: {
|
||||||
|
"job_id": job_id, "status": "running", "out_dir": str(tmp_path),
|
||||||
|
})
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/jobs/job1/finalize-review")
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "running" in response.json()["detail"]["detail"]
|
||||||
|
assert threads == []
|
||||||
|
assert notified == []
|
||||||
|
assert not os.path.exists(os.path.join(str(tmp_path), "conflicts.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_review_happy_path_notifies_once(monkeypatch, tmp_path):
|
||||||
|
out_dir = _write_finalizable_job(tmp_path, decisions=[{
|
||||||
|
"review_item_id": "finding:AGENT-0001", "decision": "confirm",
|
||||||
|
}])
|
||||||
|
notified = []
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
monkeypatch.setattr("backend.jobs._notify",
|
||||||
|
lambda job_id, report, out_dir: notified.append(job_id))
|
||||||
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
||||||
|
job_id = "jobfinalize"
|
||||||
|
backend.jobs._jobs[job_id] = {
|
||||||
|
"job_id": job_id, "status": "reviewing", "out_dir": out_dir,
|
||||||
|
"report": None, "email": "arch@example.com",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(f"/jobs/{job_id}/finalize-review")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "finalizing"}
|
||||||
|
job = backend.jobs.get_job(job_id)
|
||||||
|
assert job["status"] == "done"
|
||||||
|
assert job["report"]["summary"]["agent_status"] == "complete"
|
||||||
|
assert notified == [job_id]
|
||||||
|
for name in ("conflicts.json", "validated_issues.json",
|
||||||
|
"suppressed_issues.json", "rfis.json", "report.md"):
|
||||||
|
assert os.path.isfile(os.path.join(out_dir, name)), name
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Tests for the two-phase email flow: review-required notice, then final report."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import backend.jobs
|
||||||
|
from backend.email_sender import send_review_required
|
||||||
|
|
||||||
|
|
||||||
|
class _SyncThread:
|
||||||
|
"""Drop-in threading.Thread replacement that runs the target inline."""
|
||||||
|
|
||||||
|
def __init__(self, target=None, args=(), **kwargs):
|
||||||
|
self._target = target
|
||||||
|
self._args = args
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._target(*self._args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_required_email_skips_without_smtp(monkeypatch):
|
||||||
|
monkeypatch.setattr("backend.email_sender._smtp_ready", lambda: False)
|
||||||
|
assert send_review_required("user@example.com", {"source": "set.pdf", "summary": {}}, "http://localhost:8099/?job=abc") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_required_email_sends_with_smtp(monkeypatch):
|
||||||
|
"""With SMTP ready, the message goes out with recipient, review URL, and
|
||||||
|
the required-item count (0 when the report has no review summary)."""
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr("backend.email_sender._smtp_ready", lambda: True)
|
||||||
|
monkeypatch.setattr("backend.email_sender._send",
|
||||||
|
lambda msg: sent.append(msg) or True)
|
||||||
|
|
||||||
|
review_url = "http://localhost:8099/?job=abc"
|
||||||
|
report = {"source": "set.pdf", "summary": {"review": {"required": 3}}}
|
||||||
|
assert send_review_required("user@example.com", report, review_url) is True
|
||||||
|
|
||||||
|
assert len(sent) == 1
|
||||||
|
msg = sent[0]
|
||||||
|
assert msg["To"] == "user@example.com"
|
||||||
|
assert "review" in msg["Subject"].lower()
|
||||||
|
body = msg.get_content()
|
||||||
|
assert "review" in body.lower()
|
||||||
|
assert review_url in body
|
||||||
|
assert "3" in body
|
||||||
|
|
||||||
|
# Missing review summary -> required count defaults to 0.
|
||||||
|
sent.clear()
|
||||||
|
assert send_review_required("user@example.com", {"source": "set.pdf", "summary": {}}, review_url) is True
|
||||||
|
assert "0" in sent[0].get_content()
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_needs_review_sends_review_email_not_report(monkeypatch, tmp_path):
|
||||||
|
"""An agent job entering needs_review emails the review-required notice
|
||||||
|
exactly once and never sends the final conflict report."""
|
||||||
|
review_emails = []
|
||||||
|
report_emails = []
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr("backend.jobs.run_agent_pipeline", lambda pdf_path, **kw: {
|
||||||
|
"summary": {"agent_status": "needs_review"}, "conflicts": [],
|
||||||
|
})
|
||||||
|
monkeypatch.setattr("backend.jobs.send_review_required",
|
||||||
|
lambda *a, **kw: review_emails.append((a, kw)))
|
||||||
|
monkeypatch.setattr("backend.jobs.send_conflict_report",
|
||||||
|
lambda *a, **kw: report_emails.append((a, kw)))
|
||||||
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
||||||
|
|
||||||
|
pdf = tmp_path / "upload.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4 dummy")
|
||||||
|
job_id = backend.jobs.create_job(str(pdf), source_filename="set.pdf",
|
||||||
|
email="arch@example.com", pipeline_mode="agent")
|
||||||
|
try:
|
||||||
|
job = backend.jobs.get_job(job_id)
|
||||||
|
assert job["status"] == "needs_review"
|
||||||
|
assert len(review_emails) == 1
|
||||||
|
args, _ = review_emails[0]
|
||||||
|
assert args[0] == "arch@example.com"
|
||||||
|
assert f"/?job={job_id}" in args[2]
|
||||||
|
assert report_emails == []
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classic_job_sends_only_conflict_report(monkeypatch, tmp_path):
|
||||||
|
"""Classic pipeline is untouched: only the final report email fires."""
|
||||||
|
review_emails = []
|
||||||
|
report_emails = []
|
||||||
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr("backend.jobs.run_pipeline", lambda pdf_path, **kw: {
|
||||||
|
"summary": {}, "conflicts": [],
|
||||||
|
})
|
||||||
|
monkeypatch.setattr("backend.jobs.send_review_required",
|
||||||
|
lambda *a, **kw: review_emails.append((a, kw)))
|
||||||
|
monkeypatch.setattr("backend.jobs.send_conflict_report",
|
||||||
|
lambda *a, **kw: report_emails.append((a, kw)))
|
||||||
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
||||||
|
|
||||||
|
pdf = tmp_path / "upload.pdf"
|
||||||
|
pdf.write_bytes(b"%PDF-1.4 dummy")
|
||||||
|
job_id = backend.jobs.create_job(str(pdf), source_filename="set.pdf",
|
||||||
|
email="arch@example.com", pipeline_mode="classic")
|
||||||
|
try:
|
||||||
|
assert backend.jobs.get_job(job_id)["status"] == "done"
|
||||||
|
assert len(report_emails) == 1
|
||||||
|
assert review_emails == []
|
||||||
|
finally:
|
||||||
|
backend.jobs._jobs.pop(job_id, None)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Feedback labels and aggregate metrics for human-review decisions."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from backend.review.feedback import decision_to_label, write_label
|
||||||
|
from backend.review.metrics import aggregate_labels
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_redacts_text_by_default():
|
||||||
|
labels = [{"decision": "reject", "reason_code": "missing_evidence", "comment": "secret", "payload": {"evidence": [{"source_text": "secret"}]}}]
|
||||||
|
summary = aggregate_labels(labels)
|
||||||
|
assert summary["reject"] == 1
|
||||||
|
assert "secret" not in str(summary)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_include_text_embeds_labels():
|
||||||
|
labels = [{"decision": "reject", "reason_code": "missing_evidence", "comment": "secret"}]
|
||||||
|
summary = aggregate_labels(labels, include_text=True)
|
||||||
|
assert summary["labels"] == labels
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_item() -> dict:
|
||||||
|
return {
|
||||||
|
"review_item_id": "finding:AGENT-0007",
|
||||||
|
"kind": "finding",
|
||||||
|
"blocking": True,
|
||||||
|
"reasons": ["high_severity"],
|
||||||
|
"payload": {
|
||||||
|
"issue_id": "AGENT-0007",
|
||||||
|
"source_stage": "conflict",
|
||||||
|
"category": "elevation_disagreement",
|
||||||
|
"severity": "high",
|
||||||
|
"confidence": "medium",
|
||||||
|
"location": "Room 204 / Level 2",
|
||||||
|
"disciplines": ["Architectural", "Mechanical"],
|
||||||
|
"sheets": ["A2.1", "M2.1"],
|
||||||
|
"drawing_type": "floor_plan",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_decision_to_label_builds_spec_shape():
|
||||||
|
decision = {"review_item_id": "finding:AGENT-0007", "decision": "reject",
|
||||||
|
"reason_code": "same_value_different_representation"}
|
||||||
|
job = {"job_id": "abc123", "pipeline_mode": "agent",
|
||||||
|
"report": {"summary": {"models_used": ["google/gemini-2.5-pro"]}}}
|
||||||
|
label = decision_to_label(_queue_item(), decision, job)
|
||||||
|
assert label["review_item_id"] == "finding:AGENT-0007"
|
||||||
|
assert label["job_id"] == "abc123"
|
||||||
|
assert label["pipeline_mode"] == "agent"
|
||||||
|
assert label["source_stage"] == "conflict"
|
||||||
|
assert label["category"] == "elevation_disagreement"
|
||||||
|
assert label["severity"] == "high"
|
||||||
|
assert label["confidence"] == "medium"
|
||||||
|
assert label["decision"] == "reject"
|
||||||
|
assert label["reason_code"] == "same_value_different_representation"
|
||||||
|
assert label["location"] == "Room 204 / Level 2"
|
||||||
|
assert label["disciplines"] == ["Architectural", "Mechanical"]
|
||||||
|
assert label["sheets"] == ["A2.1", "M2.1"]
|
||||||
|
assert label["drawing_type"] == "floor_plan"
|
||||||
|
assert label["models_used"] == ["google/gemini-2.5-pro"]
|
||||||
|
datetime.fromisoformat(label["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decision_to_label_degrades_on_missing_fields():
|
||||||
|
label = decision_to_label({"review_item_id": "finding:AGENT-0001"}, {}, {})
|
||||||
|
assert label["review_item_id"] == "finding:AGENT-0001"
|
||||||
|
assert label["job_id"] is None
|
||||||
|
assert label["decision"] is None
|
||||||
|
assert label["reason_code"] is None
|
||||||
|
assert label["category"] is None
|
||||||
|
assert label["source_stage"] is None
|
||||||
|
assert label["models_used"] == []
|
||||||
|
datetime.fromisoformat(label["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_label_appends_json_lines(tmp_path):
|
||||||
|
label1 = {"review_item_id": "finding:AGENT-0001", "decision": "confirm"}
|
||||||
|
label2 = {"review_item_id": "finding:AGENT-0002", "decision": "reject"}
|
||||||
|
write_label(str(tmp_path), label1)
|
||||||
|
write_label(str(tmp_path), label2)
|
||||||
|
path = os.path.join(str(tmp_path), "review", "feedback_labels.jsonl")
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
lines = [json.loads(line) for line in f if line.strip()]
|
||||||
|
assert lines == [label1, label2]
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Non-LLM tests for the review finalizer: decisions, reruns, final artifacts."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.agents.base import AgentResult
|
||||||
|
from backend.review.finalizer import (
|
||||||
|
apply_decisions,
|
||||||
|
finalize_review,
|
||||||
|
rerun_clarified_scopes,
|
||||||
|
)
|
||||||
|
from backend.review.store import ReviewStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_suppresses_with_reason():
|
||||||
|
prioritized = [{"issue_id": "AGENT-0001", "severity": "high"}]
|
||||||
|
decisions = {"finding:AGENT-0001": {"decision": "reject", "reason_code": "duplicate"}}
|
||||||
|
kept, suppressed = apply_decisions(prioritized, decisions)
|
||||||
|
assert kept == []
|
||||||
|
assert suppressed[0]["review_state"] == "rejected"
|
||||||
|
assert suppressed[0]["reason_code"] == "duplicate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsure_is_kept_but_flagged():
|
||||||
|
prioritized = [{"issue_id": "AGENT-0002", "severity": "medium"}]
|
||||||
|
decisions = {"finding:AGENT-0002": {"decision": "unsure"}}
|
||||||
|
kept, suppressed = apply_decisions(prioritized, decisions)
|
||||||
|
assert kept[0]["review_state"] == "unsure"
|
||||||
|
assert suppressed == []
|
||||||
|
|
||||||
|
|
||||||
|
def _write_job(out_dir, prioritized, queue, decisions=None, memory=None):
|
||||||
|
"""Hand-written review-mode artifacts (conflicts.json + agent/memory.json)."""
|
||||||
|
os.makedirs(os.path.join(out_dir, "agent"), exist_ok=True)
|
||||||
|
report = {
|
||||||
|
"source": "set.pdf",
|
||||||
|
"generated_at": "2026-07-28T00:00:00+00:00",
|
||||||
|
"summary": {
|
||||||
|
"sheets_analyzed": 0,
|
||||||
|
"disciplines": [],
|
||||||
|
"assertions_extracted": 0,
|
||||||
|
"clusters_checked": 0,
|
||||||
|
"conflicts_found": 0,
|
||||||
|
"by_severity": {"high": 0, "medium": 0, "low": 0},
|
||||||
|
"by_category": {},
|
||||||
|
"pipeline_mode": "agent",
|
||||||
|
"agent_status": "needs_review",
|
||||||
|
"review": {"required": 1, "completed": 0, "remaining": 1, "total": 1},
|
||||||
|
"by_stage": {"validated": len(prioritized), "rfis": 0},
|
||||||
|
},
|
||||||
|
"conflicts": [],
|
||||||
|
"sheets": [],
|
||||||
|
"validated_issues": prioritized,
|
||||||
|
"suppressed_issues": [],
|
||||||
|
"rfis": [],
|
||||||
|
}
|
||||||
|
with open(os.path.join(out_dir, "conflicts.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(report, f)
|
||||||
|
with open(os.path.join(out_dir, "agent", "memory.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(memory or {}, f)
|
||||||
|
store = ReviewStore(out_dir)
|
||||||
|
store.write_queue(queue)
|
||||||
|
for decision in decisions or []:
|
||||||
|
store.append_decision(decision)
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking_item(issue_id):
|
||||||
|
return {"review_item_id": f"finding:{issue_id}", "kind": "finding",
|
||||||
|
"blocking": True, "reasons": ["high_severity"], "payload": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_confirm_keeps_confirmed(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
_write_job(
|
||||||
|
str(tmp_path),
|
||||||
|
prioritized=[{"issue_id": "AGENT-0001", "severity": "high"}],
|
||||||
|
queue=[_blocking_item("AGENT-0001")],
|
||||||
|
decisions=[{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}],
|
||||||
|
)
|
||||||
|
report = finalize_review("job1", str(tmp_path))
|
||||||
|
assert report["validated_issues"][0]["review_state"] == "confirmed"
|
||||||
|
assert report["suppressed_issues"] == []
|
||||||
|
assert report["summary"]["agent_status"] == "complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_no_decision_keeps_unreviewed(monkeypatch, tmp_path):
|
||||||
|
"""Non-blocking (audit) items don't need a decision; issue stays unreviewed."""
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
item = {**_blocking_item("AGENT-0001"), "blocking": False, "kind": "audit_finding"}
|
||||||
|
_write_job(
|
||||||
|
str(tmp_path),
|
||||||
|
prioritized=[{"issue_id": "AGENT-0001", "severity": "medium"}],
|
||||||
|
queue=[item],
|
||||||
|
)
|
||||||
|
report = finalize_review("job1", str(tmp_path))
|
||||||
|
assert report["validated_issues"][0]["review_state"] == "unreviewed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_clarification_replacement_marked_clarified(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
replacement = {"issue_id": "AGENT-0001-R1", "severity": "medium",
|
||||||
|
"clarification_of": "AGENT-0001"}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.review.finalizer.rerun_clarified_scopes",
|
||||||
|
lambda snapshot, decisions, prioritized=None: [replacement],
|
||||||
|
)
|
||||||
|
_write_job(
|
||||||
|
str(tmp_path),
|
||||||
|
prioritized=[{"issue_id": "AGENT-0001", "severity": "high"}],
|
||||||
|
queue=[_blocking_item("AGENT-0001")],
|
||||||
|
decisions=[{"review_item_id": "finding:AGENT-0001",
|
||||||
|
"decision": "needs_clarification",
|
||||||
|
"clarification_answer": "Ceiling is 9'-0\" AFF."}],
|
||||||
|
)
|
||||||
|
report = finalize_review("job1", str(tmp_path))
|
||||||
|
kept = report["validated_issues"]
|
||||||
|
assert [issue["issue_id"] for issue in kept] == ["AGENT-0001-R1"]
|
||||||
|
assert kept[0]["review_state"] == "clarified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_failed_clarification_flagged(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.review.finalizer.rerun_clarified_scopes",
|
||||||
|
lambda snapshot, decisions, prioritized=None: [],
|
||||||
|
)
|
||||||
|
_write_job(
|
||||||
|
str(tmp_path),
|
||||||
|
prioritized=[{"issue_id": "AGENT-0001", "severity": "high"}],
|
||||||
|
queue=[_blocking_item("AGENT-0001")],
|
||||||
|
decisions=[{"review_item_id": "finding:AGENT-0001",
|
||||||
|
"decision": "needs_clarification",
|
||||||
|
"clarification_answer": "Ceiling is 9'-0\" AFF."}],
|
||||||
|
)
|
||||||
|
report = finalize_review("job1", str(tmp_path))
|
||||||
|
assert report["validated_issues"][0]["review_state"] == "clarification_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_incomplete_review_raises(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
_write_job(
|
||||||
|
str(tmp_path),
|
||||||
|
prioritized=[{"issue_id": "AGENT-0001", "severity": "high"}],
|
||||||
|
queue=[_blocking_item("AGENT-0001")],
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="incomplete review"):
|
||||||
|
finalize_review("job1", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_writes_final_artifacts(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis",
|
||||||
|
lambda kept: [{"issue_id": kept[0]["issue_id"], "question": "?"}])
|
||||||
|
_write_job(
|
||||||
|
str(tmp_path),
|
||||||
|
prioritized=[{"issue_id": "AGENT-0001", "severity": "high"}],
|
||||||
|
queue=[_blocking_item("AGENT-0001")],
|
||||||
|
decisions=[{"review_item_id": "finding:AGENT-0001", "decision": "confirm"}],
|
||||||
|
)
|
||||||
|
report = finalize_review("job1", str(tmp_path))
|
||||||
|
assert report["summary"]["by_stage"]["validated"] == 1
|
||||||
|
assert report["summary"]["by_stage"]["rfis"] == 1
|
||||||
|
for name in ("conflicts.json", "validated_issues.json",
|
||||||
|
"suppressed_issues.json", "rfis.json", "report.md"):
|
||||||
|
assert os.path.isfile(os.path.join(str(tmp_path), name)), name
|
||||||
|
with open(os.path.join(str(tmp_path), "validated_issues.json"), encoding="utf-8") as f:
|
||||||
|
assert json.load(f)[0]["review_state"] == "confirmed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_reject_rebuilds_conflicts_and_counts(monkeypatch, tmp_path):
|
||||||
|
"""Rejected conflict-stage findings must not survive into the final
|
||||||
|
report's conflicts / headline counts; suppressed_issues keeps them."""
|
||||||
|
monkeypatch.setattr("backend.review.finalizer._draft_rfis", lambda kept: [])
|
||||||
|
kept_finding = {
|
||||||
|
"issue_id": "AGENT-0001", "source_stage": "conflict",
|
||||||
|
"category": "note_or_spec_contradiction", "severity": "high",
|
||||||
|
"location": "Grid A", "disciplines": ["A", "S"], "sheets": ["A-1"],
|
||||||
|
"description": "kept finding", "evidence": [],
|
||||||
|
"recommended_resolution": "fix", "confidence": "high",
|
||||||
|
}
|
||||||
|
rejected_finding = {
|
||||||
|
**kept_finding, "issue_id": "AGENT-0002", "severity": "medium",
|
||||||
|
"description": "rejected finding",
|
||||||
|
}
|
||||||
|
_write_job(
|
||||||
|
str(tmp_path),
|
||||||
|
prioritized=[kept_finding, rejected_finding],
|
||||||
|
queue=[_blocking_item("AGENT-0001"), _blocking_item("AGENT-0002")],
|
||||||
|
decisions=[
|
||||||
|
{"review_item_id": "finding:AGENT-0001", "decision": "confirm"},
|
||||||
|
{"review_item_id": "finding:AGENT-0002", "decision": "reject",
|
||||||
|
"reason_code": "not_a_contradiction"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Simulate the pre-review candidate values the finalizer must overwrite.
|
||||||
|
candidate_path = os.path.join(str(tmp_path), "conflicts.json")
|
||||||
|
with open(candidate_path, encoding="utf-8") as f:
|
||||||
|
candidate = json.load(f)
|
||||||
|
candidate["conflicts"] = [{"description": "kept finding", "severity": "high",
|
||||||
|
"category": "note_or_spec_contradiction"},
|
||||||
|
{"description": "rejected finding", "severity": "medium",
|
||||||
|
"category": "note_or_spec_contradiction"}]
|
||||||
|
candidate["summary"]["conflicts_found"] = 2
|
||||||
|
candidate["summary"]["by_severity"] = {"high": 1, "medium": 1, "low": 0}
|
||||||
|
candidate["summary"]["by_category"] = {"note_or_spec_contradiction": 2}
|
||||||
|
with open(candidate_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(candidate, f)
|
||||||
|
|
||||||
|
report = finalize_review("job1", str(tmp_path))
|
||||||
|
assert [c["description"] for c in report["conflicts"]] == ["kept finding"]
|
||||||
|
assert report["summary"]["conflicts_found"] == 1
|
||||||
|
assert report["summary"]["by_severity"] == {"high": 1, "medium": 0, "low": 0}
|
||||||
|
assert report["summary"]["by_category"] == {"note_or_spec_contradiction": 1}
|
||||||
|
suppressed = report["suppressed_issues"]
|
||||||
|
assert [s["issue_id"] for s in suppressed] == ["AGENT-0002"]
|
||||||
|
assert suppressed[0]["review_state"] == "rejected"
|
||||||
|
assert suppressed[0]["reason_code"] == "not_a_contradiction"
|
||||||
|
with open(os.path.join(str(tmp_path), "report.md"), encoding="utf-8") as f:
|
||||||
|
assert "rejected finding" not in f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_missing_cluster_degrades_to_analysis_gap():
|
||||||
|
snapshot = {"findings": [{"issue_id": "AGENT-0001", "scope_id": "conflict:link:1"}],
|
||||||
|
"clusters": []}
|
||||||
|
decisions = {"finding:AGENT-0001": {
|
||||||
|
"decision": "needs_clarification", "clarification_answer": "9'-0\" AFF"}}
|
||||||
|
findings = rerun_clarified_scopes(snapshot, decisions)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["category"] == "analysis_gap"
|
||||||
|
assert findings[0]["source_stage"] == "qaqc"
|
||||||
|
assert findings[0]["severity"] == "low"
|
||||||
|
assert findings[0]["confidence"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_non_conflict_scope_noted_as_analysis_gap():
|
||||||
|
"""v1 only reruns conflict scopes; other scopes get a visible gap, no raise."""
|
||||||
|
snapshot = {"findings": [{"issue_id": "AGENT-0002", "scope_id": "code: egress"}],
|
||||||
|
"clusters": []}
|
||||||
|
decisions = {"finding:AGENT-0002": {
|
||||||
|
"decision": "needs_clarification", "clarification_answer": "Corridor is 44 in."}}
|
||||||
|
findings = rerun_clarified_scopes(snapshot, decisions)
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["category"] == "analysis_gap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_successful_scope_prepends_clarification_and_tags(monkeypatch):
|
||||||
|
"""Real rerun path (non-LLM): cluster lookup, pseudo-assertion injection,
|
||||||
|
and clarification_of tagging through the real rerun_clarified_scopes."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeCritic:
|
||||||
|
name = "conflict_critic"
|
||||||
|
|
||||||
|
def __init__(self, usage):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(self, scope):
|
||||||
|
captured["scope"] = scope
|
||||||
|
return AgentResult(
|
||||||
|
scope_id=scope.scope_id,
|
||||||
|
artifacts=[{"issue_id": "AGENT-0001-R1", "severity": "medium"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("backend.review.finalizer.ConflictCriticAgent", FakeCritic)
|
||||||
|
snapshot = {
|
||||||
|
"findings": [{"issue_id": "AGENT-0001", "scope_id": "conflict:link:1"}],
|
||||||
|
"clusters": [{"key": "link:1",
|
||||||
|
"assertions": [{"attribute": "height", "value": "10'-0\""}]}],
|
||||||
|
}
|
||||||
|
decisions = {"finding:AGENT-0001": {
|
||||||
|
"decision": "needs_clarification", "clarification_answer": "9'-0\" AFF"}}
|
||||||
|
findings = rerun_clarified_scopes(snapshot, decisions)
|
||||||
|
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]["issue_id"] == "AGENT-0001-R1"
|
||||||
|
assert findings[0]["clarification_of"] == "AGENT-0001"
|
||||||
|
|
||||||
|
payload = captured["scope"].payload
|
||||||
|
assert payload["page_to_b64"] == {}
|
||||||
|
assertions = payload["cluster"]["assertions"]
|
||||||
|
# Prepended at index 0 so front-truncation can't drop the clarification.
|
||||||
|
assert assertions[0]["discipline"] == "Reviewer"
|
||||||
|
assert assertions[0]["attribute"] == "clarification"
|
||||||
|
assert assertions[0]["value"] == "9'-0\" AFF"
|
||||||
|
assert assertions[1]["attribute"] == "height"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rerun_ignores_other_decisions():
|
||||||
|
decisions = {"finding:AGENT-0001": {"decision": "confirm"}}
|
||||||
|
assert rerun_clarified_scopes({}, decisions) == []
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from backend.review.gate import build_review_queue
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_marks_blocking_and_audit_items():
|
||||||
|
memory = {"clusters": [{"key": "room:101", "location": "Room 101", "assertions": [{"id": "a1"}, {"id": "a2"}]}], "findings": []}
|
||||||
|
prioritized = [
|
||||||
|
{"issue_id": "AGENT-0001", "severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"},
|
||||||
|
{"issue_id": "AGENT-0002", "severity": "low", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"},
|
||||||
|
]
|
||||||
|
queue = build_review_queue(memory, prioritized, [])
|
||||||
|
by_id = {item["review_item_id"]: item for item in queue}
|
||||||
|
assert by_id["finding:AGENT-0001"]["blocking"] is True
|
||||||
|
assert by_id["finding:AGENT-0002"]["blocking"] is False
|
||||||
|
assert any(item["kind"] == "clean_cluster" for item in queue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_limit_caps_clean_cluster_items():
|
||||||
|
memory = {
|
||||||
|
"clusters": [
|
||||||
|
{"key": f"room:{index}", "assertions": [{"id": "a"}, {"id": "b"}]}
|
||||||
|
for index in range(3)
|
||||||
|
],
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
queue = build_review_queue(memory, [], [], limit=1)
|
||||||
|
clean_items = [item for item in queue if item["kind"] == "clean_cluster"]
|
||||||
|
assert len(clean_items) == 1
|
||||||
|
assert clean_items[0]["review_item_id"] == "clean_cluster:room:0"
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
from backend import config
|
||||||
|
from backend.review.policy import build_audit_sample, requires_review
|
||||||
|
from backend.review.schemas import validate_decision
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_severity_requires_review():
|
||||||
|
issue = {"severity": "high", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}
|
||||||
|
assert "severity_high" in requires_review(issue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_confidence_requires_review():
|
||||||
|
issue = {"severity": "low", "confidence": "low", "category": "note_or_spec_contradiction", "source_stage": "conflict"}
|
||||||
|
assert "confidence_low" in requires_review(issue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_code_category_requires_review():
|
||||||
|
issue = {"severity": "medium", "confidence": "high", "category": "egress", "source_stage": "code"}
|
||||||
|
assert "sensitive_category" in requires_review(issue)
|
||||||
|
|
||||||
|
|
||||||
|
def test_medium_high_confidence_note_does_not_require_review():
|
||||||
|
issue = {"severity": "medium", "confidence": "high", "category": "note_or_spec_contradiction", "source_stage": "conflict"}
|
||||||
|
assert requires_review(issue) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audit_sample_returns_clean_cluster_spot_check():
|
||||||
|
memory = {
|
||||||
|
"clusters": [
|
||||||
|
{
|
||||||
|
"key": "room:101",
|
||||||
|
"location": "Room 101",
|
||||||
|
"assertions": [{"id": "a1"}, {"id": "a2"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
prioritized = []
|
||||||
|
items = build_audit_sample(memory, prioritized)
|
||||||
|
assert len(items) == 1
|
||||||
|
item = items[0]
|
||||||
|
assert item["kind"] == "clean_cluster"
|
||||||
|
assert item["blocking"] is False
|
||||||
|
assert item["review_item_id"] == "clean_cluster:room:101"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audit_sample_strips_base64_from_assertions():
|
||||||
|
memory = {
|
||||||
|
"clusters": [
|
||||||
|
{
|
||||||
|
"key": "room:101",
|
||||||
|
"assertions": [
|
||||||
|
{"id": "a1", "base64": "AAAA"},
|
||||||
|
{"id": "a2", "base64": "BBBB"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
items = build_audit_sample(memory, [])
|
||||||
|
assert len(items) == 1
|
||||||
|
assertions = items[0]["payload"]["assertions"]
|
||||||
|
assert assertions == [{"id": "a1"}, {"id": "a2"}]
|
||||||
|
assert all("base64" not in assertion for assertion in assertions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audit_sample_respects_limit():
|
||||||
|
memory = {
|
||||||
|
"clusters": [
|
||||||
|
{"key": f"room:{index}", "assertions": [{"id": "a"}, {"id": "b"}]}
|
||||||
|
for index in range(4)
|
||||||
|
],
|
||||||
|
"findings": [],
|
||||||
|
}
|
||||||
|
items = build_audit_sample(memory, [], limit=2)
|
||||||
|
assert len(items) == 2
|
||||||
|
assert [item["review_item_id"] for item in items] == [
|
||||||
|
"clean_cluster:room:0",
|
||||||
|
"clean_cluster:room:1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audit_sample_excludes_implicated_clusters():
|
||||||
|
memory = {
|
||||||
|
"clusters": [
|
||||||
|
{"key": "room:101", "assertions": [{"id": "a1"}, {"id": "a2"}]},
|
||||||
|
{"key": "room:102", "assertions": [{"id": "b1"}, {"id": "b2"}]},
|
||||||
|
],
|
||||||
|
"findings": [{"scope_id": "conflict:room:101"}],
|
||||||
|
}
|
||||||
|
items = build_audit_sample(memory, [])
|
||||||
|
assert [item["review_item_id"] for item in items] == ["clean_cluster:room:102"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_decision_confirm_without_reason_code():
|
||||||
|
result = validate_decision({"review_item_id": "x", "decision": "confirm"})
|
||||||
|
assert result is not None
|
||||||
|
assert result["decision"] == "confirm"
|
||||||
|
assert result["reason_code"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_decision_reject_with_valid_reason_code():
|
||||||
|
result = validate_decision({"decision": "reject", "reason_code": "duplicate"})
|
||||||
|
assert result is not None
|
||||||
|
assert result["reason_code"] == "duplicate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_decision_reject_with_missing_reason_code_returns_none():
|
||||||
|
assert validate_decision({"decision": "reject"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_decision_reject_with_invalid_reason_code_returns_none():
|
||||||
|
assert validate_decision({"decision": "reject", "reason_code": "bogus"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_decision_unknown_decision_returns_none():
|
||||||
|
assert validate_decision({"decision": "approve"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_decision_non_dict_returns_none():
|
||||||
|
assert validate_decision("confirm") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_decision_invalid_reason_code_on_non_reject_returns_none():
|
||||||
|
assert validate_decision({"decision": "confirm", "reason_code": "bogus"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_defaults():
|
||||||
|
assert config.AGENT_REQUIRE_REVIEW is True
|
||||||
|
assert config.AGENT_REVIEW_AUDIT_SAMPLE == 5
|
||||||
|
assert config.REVIEW_AGGREGATE_INCLUDE_TEXT is False
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import json
|
||||||
|
from backend.review.store import ReviewStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_and_decisions_round_trip(tmp_path):
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
queue = [{"review_item_id": "finding:1", "blocking": True}]
|
||||||
|
store.write_queue(queue)
|
||||||
|
assert store.read_queue() == queue
|
||||||
|
store.append_decision({"review_item_id": "finding:1", "decision": "confirm"})
|
||||||
|
assert store.read_decisions()["finding:1"]["decision"] == "confirm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_counts_required_items(tmp_path):
|
||||||
|
store = ReviewStore(str(tmp_path))
|
||||||
|
queue = [
|
||||||
|
{"review_item_id": "a", "blocking": True},
|
||||||
|
{"review_item_id": "b", "blocking": False},
|
||||||
|
]
|
||||||
|
store.write_queue(queue)
|
||||||
|
store.append_decision({"review_item_id": "a", "decision": "confirm"})
|
||||||
|
progress = store.progress(queue)
|
||||||
|
assert progress["required"] == 1
|
||||||
|
assert progress["completed"] == 1
|
||||||
Reference in New Issue
Block a user