Add per-job run logs and separate vision/text model selection.
Docker Release / build-and-push (push) Successful in 1m27s
Docker Release / release (push) Skipped

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>
This commit is contained in:
2026-07-31 14:56:48 -05:00
co-authored by Cursor
parent e30522af9a
commit a6b0c8fdfa
8 changed files with 586 additions and 44 deletions
+124 -34
View File
@@ -9,6 +9,9 @@ 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
@@ -16,14 +19,16 @@ import time
import uuid
import shutil
import threading
from typing import Dict, Optional
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:
@@ -31,8 +36,32 @@ def _set(job_id: str, **fields) -> None:
_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) -> str:
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:
@@ -43,36 +72,64 @@ def create_job(pdf_path: str, source_filename: str, email: Optional[str] = None,
"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),
daemon=True).start()
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) -> None:
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"))
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,
)
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)
@@ -103,10 +160,6 @@ def _notify_error(job_id: str) -> None:
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
@@ -121,6 +174,7 @@ def _notify_error(job_id: str) -> None:
"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)
@@ -128,6 +182,25 @@ def _notify_error(job_id: str) -> None:
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.
@@ -137,29 +210,46 @@ def get_job(job_id: str) -> Optional[Dict]:
with _lock:
job = _jobs.get(job_id)
if job:
return dict(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")
if not os.path.isfile(report_path):
log = _log_from_disk(job_id)
if not os.path.isfile(report_path) and not log:
return None
try:
import json
with open(report_path, encoding="utf-8") as f:
report = json.load(f)
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": "done",
"source": report.get("source", os.path.basename(report_path)),
"email": None,
"project_input": report.get("project_input", {}),
"text_local": report.get("summary", {}).get("text_backend") == "local",
"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,
"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}")