Files
Conflict_Checker/backend/jobs.py
T
woogiandCursor a6b0c8fdfa
Docker Release / build-and-push (push) Successful in 1m27s
Docker Release / release (push) Skipped
Add per-job run logs and separate vision/text model selection.
Capture pipeline stdout into job.log + API/UI so failed runs can be reviewed, and let users pick OpenRouter vision vs text models independently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 14:56:48 -05:00

257 lines
9.1 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.
A teed stdout/stderr log is kept in memory and written to outputs/<job_id>/job.log
so failed or suspicious runs can be reviewed after the fact.
"""
import os
import time
import uuid
import shutil
import threading
from typing import Dict, List, Optional
from backend import config
from backend.job_log import capture_stdio, read_log_file, stamp_line
from backend.pipeline.runner import run_pipeline
from backend.email_sender import send_conflict_report
_jobs: Dict[str, Dict] = {}
_lock = threading.Lock()
_LOG_TAIL = 80
def _set(job_id: str, **fields) -> None:
with _lock:
_jobs[job_id].update(fields)
def _append_log(job_id: str, raw_line: str, log_path: str) -> None:
"""Stamp, store, and append one captured stdout/stderr line."""
entry = stamp_line(raw_line)
with _lock:
job = _jobs.get(job_id)
if job is not None:
job.setdefault("log", []).append(entry)
try:
os.makedirs(os.path.dirname(log_path), exist_ok=True)
with open(log_path, "a", encoding="utf-8") as f:
f.write(entry + "\n")
except OSError:
# Don't fail the job over log I/O; avoid print() here — it would
# re-enter the stdio tee while a job is capturing.
pass
def create_job(
pdf_path: str,
source_filename: str,
email: Optional[str] = None,
project_input: Optional[Dict] = None,
text_local: bool = False,
vision_model: Optional[str] = None,
text_model: Optional[str] = None,
) -> str:
"""Register a job and kick off its background thread. Returns the job_id."""
job_id = uuid.uuid4().hex[:12]
with _lock:
_jobs[job_id] = {
"job_id": job_id,
"status": "queued", # queued -> running -> done | error
"source": source_filename,
"email": email or None,
"project_input": project_input or {},
"text_local": text_local,
"vision_model": (vision_model or "").strip() or None,
"text_model": (text_model or "").strip() or None,
"stage": None,
"created_at": time.time(),
"finished_at": None,
"report": None,
"error": None,
"log": [],
}
threading.Thread(
target=_run,
args=(job_id, pdf_path, project_input, text_local, vision_model, text_model),
daemon=True,
).start()
return job_id
def _run(
job_id: str,
pdf_path: str,
project_input: Optional[Dict] = None,
text_local: bool = False,
vision_model: Optional[str] = None,
text_model: Optional[str] = None,
) -> None:
out_dir = os.path.join(config.OUTPUT_DIR, job_id)
log_path = os.path.join(out_dir, "job.log")
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)
# Truncate any leftover log if job_id somehow collided (shouldn't).
with open(log_path, "w", encoding="utf-8"):
pass
shutil.copy2(pdf_path, os.path.join(out_dir, "source.pdf"))
def on_line(raw: str) -> None:
_append_log(job_id, raw, log_path)
with capture_stdio(on_line):
report = run_pipeline(
pdf_path,
out_dir=out_dir,
on_stage=lambda name: _set(job_id, stage=name),
project_input=project_input,
source_name=_jobs[job_id].get("source"),
text_local=text_local,
vision_model=vision_model,
text_model=text_model,
)
_set(job_id, status="done", report=report, finished_at=time.time(), stage=None)
_notify(job_id, report, out_dir)
except Exception as e:
# Also land in the job log via print under the tee when possible.
try:
_append_log(job_id, f"[Jobs] Job {job_id} failed: {e}", log_path)
except Exception:
pass
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.
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"
f"Review the run log at: {config.APP_BASE_URL.rstrip('/')}/?job={job_id}\n\n"
"Generated by Conflict Checker"
)
_send(msg)
except Exception as e:
print(f"[Email] Failed to send error notice: {e}")
def _log_from_disk(job_id: str) -> List[str]:
return read_log_file(os.path.join(config.OUTPUT_DIR, job_id, "job.log"))
def get_job_log(job_id: str) -> Optional[List[str]]:
"""Full job log lines, from memory or disk. None if job unknown."""
with _lock:
job = _jobs.get(job_id)
if job is not None:
return list(job.get("log") or [])
log = _log_from_disk(job_id)
# Job exists on disk if we have a log or a report artifact.
report_path = os.path.join(config.OUTPUT_DIR, job_id, "conflicts.json")
if log or os.path.isfile(report_path):
return log
return None
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:
out = dict(job)
log = list(job.get("log") or [])
out["log_tail"] = log[-_LOG_TAIL:]
# Full log on terminal states so the UI can show it without a
# second fetch; keep polls light while running.
if out.get("status") in ("done", "error"):
out["log"] = log
else:
out.pop("log", None)
return out
# Try loading from disk
report_path = os.path.join(config.OUTPUT_DIR, job_id, "conflicts.json")
log = _log_from_disk(job_id)
if not os.path.isfile(report_path) and not log:
return None
try:
import json
report = None
if os.path.isfile(report_path):
with open(report_path, encoding="utf-8") as f:
report = json.load(f)
source_pdf = os.path.join(config.OUTPUT_DIR, job_id, "source.pdf")
status = "done" if report is not None else "error"
return {
"job_id": job_id,
"status": status,
"source": (report or {}).get("source", os.path.basename(report_path)),
"email": None,
"project_input": (report or {}).get("project_input", {}),
"text_local": (report or {}).get("summary", {}).get("text_backend") == "local",
"vision_model": None,
"text_model": None,
"stage": None,
"created_at": os.path.getmtime(source_pdf) if os.path.isfile(source_pdf) else None,
"finished_at": os.path.getmtime(report_path) if os.path.isfile(report_path) else None,
"report": report,
"error": None if report is not None else "Report missing; see job log",
"log": log,
"log_tail": log[-_LOG_TAIL:],
}
except Exception as e:
print(f"[Jobs] Failed to load job {job_id} from disk: {e}")
return None