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.
223 lines
8.9 KiB
Python
223 lines
8.9 KiB
Python
"""
|
|
jobs.py - Lightweight async job registry for conflict checks.
|
|
|
|
A conflict check takes minutes, so the HTTP request must not block on it. Each
|
|
upload becomes a job that runs on a background thread; the client gets a job_id
|
|
immediately and can either poll GET /jobs/{id} or just close the page and wait
|
|
for the completion email.
|
|
|
|
State is in-memory (fine for a single-user tool); the report is also persisted
|
|
to outputs/<job_id>/ so results survive a restart even though live status does
|
|
not. No external queue/DB.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
import shutil
|
|
import threading
|
|
from typing import Dict, Optional
|
|
|
|
from backend import config
|
|
from backend.agents.runner import run_agent_pipeline
|
|
from backend.pipeline.runner import run_pipeline
|
|
from backend.email_sender import send_conflict_report, send_review_required
|
|
|
|
_jobs: Dict[str, Dict] = {}
|
|
_lock = threading.Lock()
|
|
PIPELINE_MODES = {"classic", "agent"}
|
|
|
|
|
|
def _set(job_id: str, **fields) -> None:
|
|
with _lock:
|
|
_jobs[job_id].update(fields)
|
|
|
|
|
|
def create_job(pdf_path: str, source_filename: str, email: Optional[str] = None,
|
|
project_input: Optional[Dict] = None, text_local: bool = False,
|
|
pipeline_mode: str = "classic") -> str:
|
|
"""Register a job and kick off its background thread. Returns the job_id."""
|
|
pipeline_mode = pipeline_mode.strip().lower()
|
|
if pipeline_mode not in PIPELINE_MODES:
|
|
raise ValueError(f"Unsupported pipeline mode: {pipeline_mode!r}")
|
|
# Agent mode v1 is OpenRouter-only.
|
|
text_local = bool(text_local and pipeline_mode == "classic")
|
|
job_id = uuid.uuid4().hex[:12]
|
|
with _lock:
|
|
_jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "queued", # queued -> running -> done | needs_review | error
|
|
"source": source_filename,
|
|
"email": email or None,
|
|
"project_input": project_input or {},
|
|
"text_local": text_local,
|
|
"pipeline_mode": pipeline_mode,
|
|
"stage": None,
|
|
"created_at": time.time(),
|
|
"finished_at": None,
|
|
"report": None,
|
|
"error": None,
|
|
}
|
|
threading.Thread(target=_run, args=(
|
|
job_id, pdf_path, project_input, text_local, pipeline_mode,
|
|
),
|
|
daemon=True).start()
|
|
return job_id
|
|
|
|
|
|
def _run(job_id: str, pdf_path: str, project_input: Optional[Dict] = None,
|
|
text_local: bool = False, pipeline_mode: str = "classic") -> None:
|
|
out_dir = os.path.join(config.OUTPUT_DIR, job_id)
|
|
try:
|
|
_set(job_id, status="running")
|
|
# Keep a copy of the source PDF so its sheets can be viewed later.
|
|
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"))
|
|
runner = run_agent_pipeline if pipeline_mode == "agent" else run_pipeline
|
|
runner_kwargs = {
|
|
"out_dir": out_dir,
|
|
"on_stage": lambda name: _set(job_id, stage=name),
|
|
"project_input": project_input,
|
|
"source_name": _jobs[job_id].get("source"),
|
|
}
|
|
if pipeline_mode == "classic":
|
|
runner_kwargs["text_local"] = text_local
|
|
else:
|
|
runner_kwargs["require_review"] = config.AGENT_REQUIRE_REVIEW
|
|
report = runner(pdf_path, **runner_kwargs)
|
|
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)
|
|
_notify(job_id, report, out_dir)
|
|
except Exception as e:
|
|
print(f"[Jobs] Job {job_id} failed: {e}")
|
|
_set(job_id, status="error", error=str(e), finished_at=time.time())
|
|
_notify_error(job_id)
|
|
finally:
|
|
try:
|
|
os.remove(pdf_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _notify(job_id: str, report: Dict, out_dir: str) -> None:
|
|
email = _jobs[job_id].get("email")
|
|
if not email:
|
|
return
|
|
results_url = f"{config.APP_BASE_URL.rstrip('/')}/?job={job_id}"
|
|
attachments = [
|
|
os.path.join(out_dir, "report.md"),
|
|
os.path.join(out_dir, "conflicts.json"),
|
|
os.path.join(out_dir, "validated_issues.json"),
|
|
os.path.join(out_dir, "rfis.json"),
|
|
]
|
|
send_conflict_report(email, report, results_url=results_url, attachments=attachments)
|
|
|
|
|
|
def _notify_error(job_id: str) -> None:
|
|
job = _jobs[job_id]
|
|
email = job.get("email")
|
|
if not email:
|
|
return
|
|
# Reuse the report mailer with a minimal error-shaped payload.
|
|
err_report = {
|
|
"source": job.get("source", ""),
|
|
"summary": {"conflicts_found": 0, "by_severity": {}, "disciplines": []},
|
|
}
|
|
try:
|
|
from backend.email_sender import _smtp_ready, _send
|
|
from email.message import EmailMessage
|
|
if not _smtp_ready():
|
|
print(f"[Email] SMTP not configured - skipping error notice to {email}")
|
|
return
|
|
msg = EmailMessage()
|
|
msg["Subject"] = f"Conflict Checker - {job.get('source','')} - run FAILED"
|
|
msg["From"] = config.SMTP_FROM or config.SMTP_USER
|
|
msg["To"] = email
|
|
msg.set_content(
|
|
"Your conflict check did not complete.\n\n"
|
|
f"Drawing set: {job.get('source','')}\n"
|
|
f"Error: {job.get('error','unknown')}\n\n"
|
|
"Generated by Conflict Checker"
|
|
)
|
|
_send(msg)
|
|
except Exception as e:
|
|
print(f"[Email] Failed to send error notice: {e}")
|
|
|
|
|
|
def get_job(job_id: str) -> Optional[Dict]:
|
|
"""Public job view. Includes the full report only when done.
|
|
|
|
Falls back to the on-disk conflicts.json when the job isn't in the
|
|
in-memory registry (e.g. after a server restart).
|
|
"""
|
|
with _lock:
|
|
job = _jobs.get(job_id)
|
|
if job:
|
|
return dict(job)
|
|
|
|
# Try loading from disk
|
|
report_path = os.path.join(config.OUTPUT_DIR, job_id, "conflicts.json")
|
|
if not os.path.isfile(report_path):
|
|
return None
|
|
try:
|
|
with open(report_path, encoding="utf-8") as 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")
|
|
job = {
|
|
"job_id": job_id,
|
|
"status": status,
|
|
"source": meta.get("source") or report.get("source", os.path.basename(report_path)),
|
|
"email": meta.get("email"),
|
|
"project_input": report.get("project_input", {}),
|
|
"text_local": summary.get("text_backend") == "local",
|
|
"pipeline_mode": meta.get("pipeline_mode") or summary.get("pipeline_mode", "classic"),
|
|
"stage": None,
|
|
"created_at": os.path.getmtime(source_pdf) if os.path.isfile(source_pdf) else None,
|
|
"finished_at": os.path.getmtime(report_path),
|
|
"report": report,
|
|
"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:
|
|
print(f"[Jobs] Failed to load job {job_id} from disk: {e}")
|
|
return None
|