Merge main: dual model dropdowns + richer job logs, adapted for agent-mode.
- llm.py: set_model_overrides(vision, text) replaces the single job override;
UI picks still beat per-call agent model args, but never name the hybrid
local model (avoids main's hybrid footgun); local->cloud fallback uses the
text pick.
- jobs.py: timestamped line-split tee (job_log.py), in-memory log + log_tail
polls, full log on terminal states (done/error/needs_review/finalization_error),
log-only disk recovery, error email links to the run log, and failed runs now
append the full traceback to job.log. Keeps pipeline_mode, job.json, and the
review gate.
- models.py: vision/text split via architecture modalities, pricing kept;
/models returns {vision, text, defaults}; /check takes vision_model/text_model
(replacing model); /health adds text_model. models_catalog.py dropped.
- UI: two priced dropdowns (OpenRouter compute only) + live run-log panel.
- Tests updated for dual overrides and the /models shape; new coverage for
traceback capture and local-model immunity.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
job_log.py - Capture pipeline stdout/stderr into a per-job log.
|
||||
|
||||
The pipeline already prints stage progress via print(). For a reviewable
|
||||
post-run log we tee those lines into memory + outputs/<job_id>/job.log
|
||||
without rewriting every call site.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Callable, Iterator, List, Optional, TextIO
|
||||
|
||||
|
||||
class _LineSplitter:
|
||||
"""Accumulate write() chunks and emit complete lines."""
|
||||
|
||||
def __init__(self, on_line: Callable[[str], None]):
|
||||
self._buf = ""
|
||||
self._on_line = on_line
|
||||
|
||||
def write(self, s: str) -> None:
|
||||
if not s:
|
||||
return
|
||||
self._buf += s
|
||||
while "\n" in self._buf:
|
||||
line, self._buf = self._buf.split("\n", 1)
|
||||
# Strip trailing CR from Windows-ish streams; keep content intact.
|
||||
self._on_line(line.rstrip("\r"))
|
||||
|
||||
def flush_remainder(self) -> None:
|
||||
if self._buf:
|
||||
self._on_line(self._buf.rstrip("\r"))
|
||||
self._buf = ""
|
||||
|
||||
|
||||
class _Tee:
|
||||
"""Mirror writes to the original stream and a line callback."""
|
||||
|
||||
def __init__(self, stream: TextIO, on_line: Callable[[str], None]):
|
||||
self._stream = stream
|
||||
self._lines = _LineSplitter(on_line)
|
||||
|
||||
def write(self, s: str) -> int:
|
||||
n = self._stream.write(s)
|
||||
self._stream.flush()
|
||||
self._lines.write(s)
|
||||
return n
|
||||
|
||||
def flush(self) -> None:
|
||||
self._stream.flush()
|
||||
|
||||
def flush_remainder(self) -> None:
|
||||
self._lines.flush_remainder()
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self._stream, name)
|
||||
|
||||
|
||||
def stamp_line(line: str, t: Optional[float] = None) -> str:
|
||||
"""Prefix a log line with HH:MM:SS."""
|
||||
ts = time.strftime("%H:%M:%S", time.localtime(t if t is not None else time.time()))
|
||||
return f"[{ts}] {line}"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_stdio(on_line: Callable[[str], None]) -> Iterator[None]:
|
||||
"""
|
||||
Tee sys.stdout and sys.stderr into on_line(raw_line) for the duration.
|
||||
|
||||
Safe for the single-job-at-a-time usage of this app; overlapping jobs
|
||||
would interleave (same limitation as the LLM cost counters).
|
||||
"""
|
||||
old_out, old_err = sys.stdout, sys.stderr
|
||||
tee_out = _Tee(old_out, on_line)
|
||||
tee_err = _Tee(old_err, on_line)
|
||||
sys.stdout = tee_out # type: ignore[assignment]
|
||||
sys.stderr = tee_err # type: ignore[assignment]
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
tee_out.flush_remainder()
|
||||
tee_err.flush_remainder()
|
||||
sys.stdout = old_out
|
||||
sys.stderr = old_err
|
||||
|
||||
|
||||
def read_log_file(path: str) -> List[str]:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return [ln.rstrip("\n") for ln in f]
|
||||
except OSError:
|
||||
return []
|
||||
+143
-78
@@ -9,61 +9,33 @@ 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 json
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
import shutil
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from backend import config
|
||||
from backend import llm
|
||||
from backend.job_log import capture_stdio, read_log_file, stamp_line
|
||||
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()
|
||||
_LOG_TAIL = 80
|
||||
PIPELINE_MODES = {"classic", "agent"}
|
||||
|
||||
|
||||
class _Tee:
|
||||
"""Write to both the real stream and the job log file."""
|
||||
|
||||
def __init__(self, stream, log_file) -> None:
|
||||
self._stream = stream
|
||||
self._log = log_file
|
||||
|
||||
def write(self, data):
|
||||
self._stream.write(data)
|
||||
self._log.write(data)
|
||||
|
||||
def flush(self):
|
||||
self._stream.flush()
|
||||
self._log.flush()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _tee_log(log_path: str, header: str):
|
||||
"""Mirror stdout/stderr into a per-job log file for the duration of a run.
|
||||
|
||||
sys.stdout is process-global, so two concurrent jobs would interleave in
|
||||
each other's logs - acceptable for this single-user tool (same tradeoff as
|
||||
the LLM cost globals in llm.py).
|
||||
"""
|
||||
with open(log_path, "a", encoding="utf-8") as log_file:
|
||||
log_file.write(header + "\n")
|
||||
real_out, real_err = sys.stdout, sys.stderr
|
||||
sys.stdout, sys.stderr = _Tee(real_out, log_file), _Tee(real_err, log_file)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sys.stdout, sys.stderr = real_out, real_err
|
||||
# States where the job will produce no more log output; polls get the full log.
|
||||
_TERMINAL_STATES = {"done", "error", "needs_review", "finalization_error"}
|
||||
|
||||
|
||||
def _set(job_id: str, **fields) -> None:
|
||||
@@ -71,16 +43,39 @@ 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,
|
||||
pipeline_mode: str = "classic", model: Optional[str] = None) -> 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,
|
||||
pipeline_mode: str = "classic",
|
||||
vision_model: Optional[str] = None,
|
||||
text_model: Optional[str] = None,
|
||||
) -> 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")
|
||||
model = (model or "").strip() or None
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
with _lock:
|
||||
_jobs[job_id] = {
|
||||
@@ -91,35 +86,61 @@ def create_job(pdf_path: str, source_filename: str, email: Optional[str] = None,
|
||||
"project_input": project_input or {},
|
||||
"text_local": text_local,
|
||||
"pipeline_mode": pipeline_mode,
|
||||
"model": model,
|
||||
"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, pipeline_mode, model,
|
||||
),
|
||||
daemon=True).start()
|
||||
threading.Thread(
|
||||
target=_run,
|
||||
args=(job_id, pdf_path, project_input, text_local, pipeline_mode,
|
||||
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, pipeline_mode: str = "classic",
|
||||
model: Optional[str] = None) -> None:
|
||||
def _run(
|
||||
job_id: str,
|
||||
pdf_path: str,
|
||||
project_input: Optional[Dict] = None,
|
||||
text_local: bool = False,
|
||||
pipeline_mode: str = "classic",
|
||||
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
|
||||
header = (f"=== Job {job_id} | {pipeline_mode} | {_jobs[job_id].get('source')} | "
|
||||
f"model={model or 'default'} | "
|
||||
f"vision={vision_model or 'default'} text={text_model or 'default'} | "
|
||||
f"started {time.strftime('%Y-%m-%d %H:%M:%S %Z', time.gmtime())} UTC ===")
|
||||
with _tee_log(os.path.join(out_dir, "job.log"), header):
|
||||
_append_log(job_id, header, log_path)
|
||||
|
||||
def on_line(raw: str) -> None:
|
||||
_append_log(job_id, raw, log_path)
|
||||
|
||||
with capture_stdio(on_line):
|
||||
_run_pipeline(job_id, pdf_path, out_dir, project_input, text_local,
|
||||
pipeline_mode, model)
|
||||
pipeline_mode, vision_model, text_model)
|
||||
except Exception as e:
|
||||
# Land the failure AND its traceback in the job log so failed runs can
|
||||
# be diagnosed from the log alone (the stdio tee is already torn down).
|
||||
try:
|
||||
_append_log(job_id, f"[Jobs] Job {job_id} failed: {e}", log_path)
|
||||
for ln in traceback.format_exc().rstrip().splitlines():
|
||||
_append_log(job_id, ln, 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)
|
||||
@@ -132,7 +153,8 @@ def _run(job_id: str, pdf_path: str, project_input: Optional[Dict] = None,
|
||||
|
||||
def _run_pipeline(job_id: str, pdf_path: str, out_dir: str,
|
||||
project_input: Optional[Dict], text_local: bool,
|
||||
pipeline_mode: str, model: Optional[str]) -> None:
|
||||
pipeline_mode: str, vision_model: Optional[str],
|
||||
text_model: Optional[str]) -> None:
|
||||
"""The body of a job run; executes inside the job's tee'd log capture."""
|
||||
# Persist minimal job metadata so the disk fallback in get_job can
|
||||
# recover the recipient email / pipeline mode after a server restart
|
||||
@@ -143,7 +165,10 @@ def _run_pipeline(job_id: str, pdf_path: str, out_dir: str,
|
||||
"email": _jobs[job_id].get("email"),
|
||||
"pipeline_mode": pipeline_mode,
|
||||
"source": _jobs[job_id].get("source"),
|
||||
"vision_model": vision_model,
|
||||
"text_model": text_model,
|
||||
}, f, indent=2)
|
||||
# Keep a copy of the source PDF so its sheets can be viewed later.
|
||||
shutil.copy2(pdf_path, os.path.join(out_dir, "source.pdf"))
|
||||
runner = run_agent_pipeline if pipeline_mode == "agent" else run_pipeline
|
||||
runner_kwargs = {
|
||||
@@ -153,17 +178,22 @@ def _run_pipeline(job_id: str, pdf_path: str, out_dir: str,
|
||||
"source_name": _jobs[job_id].get("source"),
|
||||
}
|
||||
if pipeline_mode == "classic":
|
||||
# run_pipeline takes the picks as params and clears them in finally.
|
||||
runner_kwargs["text_local"] = text_local
|
||||
runner_kwargs["vision_model"] = vision_model
|
||||
runner_kwargs["text_model"] = text_model
|
||||
else:
|
||||
runner_kwargs["require_review"] = config.AGENT_REQUIRE_REVIEW
|
||||
if model:
|
||||
print(f"[Jobs] Model override for this run: {model}")
|
||||
llm.set_model_override(model)
|
||||
# The agent runner has no override params; set them module-level.
|
||||
if vision_model or text_model:
|
||||
print(f"[Jobs] Model overrides for this run: "
|
||||
f"vision={vision_model or '(default)'} text={text_model or '(default)'}")
|
||||
llm.set_model_overrides(vision_model, text_model)
|
||||
try:
|
||||
report = runner(pdf_path, **runner_kwargs)
|
||||
finally:
|
||||
if model:
|
||||
llm.set_model_override(None)
|
||||
if pipeline_mode == "agent":
|
||||
llm.set_model_overrides(None, None)
|
||||
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.
|
||||
@@ -197,11 +227,6 @@ def _notify_error(job_id: str) -> None:
|
||||
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
|
||||
@@ -216,6 +241,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)
|
||||
@@ -223,28 +249,63 @@ 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.
|
||||
|
||||
Falls back to the on-disk conflicts.json when the job isn't in the
|
||||
in-memory registry (e.g. after a server restart).
|
||||
Falls back to the on-disk artifacts (conflicts.json / job.log) 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)
|
||||
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 _TERMINAL_STATES:
|
||||
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:
|
||||
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"
|
||||
report = None
|
||||
if os.path.isfile(report_path):
|
||||
with open(report_path, encoding="utf-8") as f:
|
||||
report = json.load(f)
|
||||
summary = (report or {}).get("summary", {})
|
||||
if report is None:
|
||||
# Crashed before writing a report; the log is the only artifact.
|
||||
status = "error"
|
||||
else:
|
||||
# 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.
|
||||
@@ -261,16 +322,20 @@ def get_job(job_id: str) -> Optional[Dict]:
|
||||
job = {
|
||||
"job_id": job_id,
|
||||
"status": status,
|
||||
"source": meta.get("source") or report.get("source", os.path.basename(report_path)),
|
||||
"source": meta.get("source") or (report or {}).get("source", os.path.basename(report_path)),
|
||||
"email": meta.get("email"),
|
||||
"project_input": report.get("project_input", {}),
|
||||
"project_input": (report or {}).get("project_input", {}),
|
||||
"text_local": summary.get("text_backend") == "local",
|
||||
"pipeline_mode": meta.get("pipeline_mode") or summary.get("pipeline_mode", "classic"),
|
||||
"vision_model": meta.get("vision_model"),
|
||||
"text_model": meta.get("text_model"),
|
||||
"stage": 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) if os.path.isfile(report_path) else None,
|
||||
"report": report,
|
||||
"error": None,
|
||||
"error": None if report is not None else "Report missing; see job log",
|
||||
"log": log,
|
||||
"log_tail": log[-_LOG_TAIL:],
|
||||
}
|
||||
# Hydrate the in-memory registry so _set(...) transitions (reviewing,
|
||||
# finalizing, done) work for restart-recovered jobs.
|
||||
|
||||
+22
-12
@@ -23,16 +23,13 @@ _clients: Dict[str, OpenAI] = {}
|
||||
# call_json when routing a no-image (text) call. Module-global mirrors the
|
||||
# set_stage/cost pattern (single-user tool).
|
||||
_text_local = False
|
||||
|
||||
# Per-job model override (user picked a model in the UI). Same module-global
|
||||
# Per-job model overrides (user picked models in the UI). Same module-global
|
||||
# pattern: set by the job runner before the pipeline starts, cleared after.
|
||||
_model_override: Optional[str] = None
|
||||
|
||||
|
||||
def set_model_override(model: Optional[str]) -> None:
|
||||
"""Override the model for all OpenRouter calls (vision + text), or None to clear."""
|
||||
global _model_override
|
||||
_model_override = (model or "").strip() or None
|
||||
# Vision applies to image calls, text to no-image calls on OpenRouter (and to
|
||||
# the local->cloud fallback). The LOCAL endpoint's model name is never taken
|
||||
# from these overrides - hybrid local keeps LOCAL_TEXT_MODEL.
|
||||
_vision_model_override: Optional[str] = None
|
||||
_text_model_override: Optional[str] = None
|
||||
|
||||
|
||||
def set_text_backend(local: bool) -> None:
|
||||
@@ -40,6 +37,13 @@ def set_text_backend(local: bool) -> None:
|
||||
global _text_local
|
||||
_text_local = bool(local)
|
||||
|
||||
|
||||
def set_model_overrides(vision: Optional[str] = None, text: Optional[str] = None) -> None:
|
||||
"""Per-run OpenRouter vision/text model picks. None/blank clears to defaults."""
|
||||
global _vision_model_override, _text_model_override
|
||||
_vision_model_override = (vision or "").strip() or None
|
||||
_text_model_override = (text or "").strip() or None
|
||||
|
||||
# --- per-job cost accounting -------------------------------------------------
|
||||
# OpenRouter returns the real USD cost of each call when we request usage
|
||||
# accounting. We accumulate it in a module-level counter; the runner resets it
|
||||
@@ -177,17 +181,22 @@ def _resolve_backend(has_images: bool, model_override: Optional[str]) -> Dict[st
|
||||
return {
|
||||
"base_url": config.LOCAL_BASE_URL,
|
||||
"api_key": config.LOCAL_API_KEY,
|
||||
# Local model name comes from per-call args or LOCAL_TEXT_MODEL —
|
||||
# never the UI's OpenRouter picks, which a local server won't serve.
|
||||
"model": model_override or config.LOCAL_TEXT_MODEL or config.TEXT_MODEL,
|
||||
"usage": False, # local has no OpenRouter usage accounting
|
||||
"local": True,
|
||||
}
|
||||
# Vision, or text-on-OpenRouter (default / fallback). A per-job override
|
||||
# (user's UI model pick) wins over per-call and env defaults.
|
||||
default_model = config.MODEL if has_images else config.TEXT_MODEL
|
||||
if has_images:
|
||||
model = _vision_model_override or model_override or config.MODEL
|
||||
else:
|
||||
model = _text_model_override or model_override or config.TEXT_MODEL
|
||||
return {
|
||||
"base_url": config.AI_BASE_URL,
|
||||
"api_key": config.AI_API_KEY,
|
||||
"model": _model_override or model_override or default_model,
|
||||
"model": model,
|
||||
"usage": True,
|
||||
"local": False,
|
||||
}
|
||||
@@ -362,7 +371,8 @@ def call_json(
|
||||
_models["text_local"].add(be["model"])
|
||||
_models["fallback_count"] += 1
|
||||
be = {"base_url": config.AI_BASE_URL, "api_key": config.AI_API_KEY,
|
||||
"model": config.TEXT_MODEL, "usage": True, "local": False}
|
||||
"model": _text_model_override or config.TEXT_MODEL,
|
||||
"usage": True, "local": False}
|
||||
cache_key = None # don't cache fallback under the local-model key
|
||||
fell_back = True
|
||||
continue
|
||||
|
||||
+15
-5
@@ -35,6 +35,7 @@ _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "model": config.MODEL,
|
||||
"text_model": config.TEXT_MODEL,
|
||||
"version": config.APP_VERSION,
|
||||
"build": config.APP_BUILD,
|
||||
"key_configured": bool(config.AI_API_KEY),
|
||||
@@ -43,13 +44,15 @@ def health():
|
||||
|
||||
@app.get("/models")
|
||||
def list_models():
|
||||
"""Available OpenRouter models with per-1M-token pricing for the UI picker."""
|
||||
from backend.models import fetch_models
|
||||
"""Vision/text OpenRouter model lists with pricing for the UI dropdowns."""
|
||||
from backend.models import fetch_models, split_vision_text
|
||||
models = fetch_models()
|
||||
if models is None:
|
||||
raise HTTPException(status_code=502,
|
||||
detail="Could not fetch the model list from OpenRouter")
|
||||
return {"models": models, "default": config.MODEL, "default_text": config.TEXT_MODEL}
|
||||
vision, text = split_vision_text(models)
|
||||
return {"vision": vision, "text": text,
|
||||
"defaults": {"vision": config.MODEL, "text": config.TEXT_MODEL}}
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/log")
|
||||
@@ -72,7 +75,8 @@ async def check(
|
||||
work_type: Optional[str] = Form(None),
|
||||
text_local: bool = Form(False),
|
||||
pipeline_mode: str = Form("classic"),
|
||||
model: Optional[str] = Form(None),
|
||||
vision_model: Optional[str] = Form(None),
|
||||
text_model: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Accept a PDF, start a background conflict check, and return a job_id
|
||||
@@ -81,6 +85,9 @@ async def check(
|
||||
Optional intake fields (project_name/address/occupancy/work_type) feed the
|
||||
Stage 0 jurisdiction profile; anything left blank is derived from the cover
|
||||
sheet.
|
||||
|
||||
vision_model / text_model override the configured defaults for this run
|
||||
(vision always OpenRouter; text follows the OpenRouter vs hybrid choice).
|
||||
"""
|
||||
if not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Please upload a PDF.")
|
||||
@@ -106,9 +113,12 @@ async def check(
|
||||
}.items()
|
||||
if v and v.strip()
|
||||
}
|
||||
v_model = (vision_model or "").strip() or None
|
||||
t_model = (text_model or "").strip() or None
|
||||
job_id = create_job(tmp_path, source_filename=file.filename, email=email,
|
||||
project_input=project_input, text_local=text_local,
|
||||
pipeline_mode=pipeline_mode, model=model)
|
||||
pipeline_mode=pipeline_mode, vision_model=v_model,
|
||||
text_model=t_model)
|
||||
return JSONResponse({
|
||||
"job_id": job_id,
|
||||
"status": "queued",
|
||||
|
||||
+27
-2
@@ -3,11 +3,13 @@ models.py - Fetch the available OpenRouter model list with pricing (cached).
|
||||
|
||||
The /models endpoint is public (no API key needed). Results are normalized to
|
||||
per-1M-token USD costs for display and cached in memory for an hour; callers
|
||||
degrade gracefully when OpenRouter is unreachable.
|
||||
degrade gracefully when OpenRouter is unreachable. Each entry also carries a
|
||||
vision flag (accepts image input) so the UI can offer separate vision/text
|
||||
model dropdowns.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -25,6 +27,18 @@ def _per_mtok(rate) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _is_vision(item: dict) -> bool:
|
||||
"""True when the model accepts image input and produces text output."""
|
||||
arch = item.get("architecture") or {}
|
||||
inputs = arch.get("input_modalities") or []
|
||||
outputs = arch.get("output_modalities") or []
|
||||
# Legacy string form: "text+image->text"
|
||||
modality = (arch.get("modality") or "").lower()
|
||||
has_image_in = ("image" in inputs) or ("image" in modality.split("->")[0])
|
||||
has_text_out = ("text" in outputs) or ("->text" in modality) or (not outputs and not modality)
|
||||
return has_image_in and has_text_out
|
||||
|
||||
|
||||
def _fetch_openrouter_models() -> Optional[List[dict]]:
|
||||
"""Raw GET of the OpenRouter model list; None on any failure."""
|
||||
try:
|
||||
@@ -55,6 +69,7 @@ def fetch_models(force: bool = False) -> Optional[List[dict]]:
|
||||
"prompt_usd_per_mtok": _per_mtok((item.get("pricing") or {}).get("prompt")),
|
||||
"completion_usd_per_mtok": _per_mtok((item.get("pricing") or {}).get("completion")),
|
||||
"context_length": item.get("context_length"),
|
||||
"vision": _is_vision(item),
|
||||
}
|
||||
for item in data
|
||||
if item.get("id")
|
||||
@@ -63,3 +78,13 @@ def fetch_models(force: bool = False) -> Optional[List[dict]]:
|
||||
_cache["models"] = models
|
||||
_cache["at"] = time.time()
|
||||
return models
|
||||
|
||||
|
||||
def split_vision_text(models: List[dict]) -> Tuple[List[dict], List[dict]]:
|
||||
"""Partition the normalized catalog into (vision, text) lists for the UI.
|
||||
|
||||
Every catalog model takes text in/out, so vision models appear in both
|
||||
lists (same dicts, pricing included).
|
||||
"""
|
||||
vision = [m for m in models if m.get("vision")]
|
||||
return vision, list(models)
|
||||
|
||||
@@ -42,7 +42,9 @@ from backend.pipeline.risk import score_and_prioritize
|
||||
from backend.pipeline.rfi import generate_rfis
|
||||
from backend.pipeline.report import build_report, to_markdown
|
||||
from backend.pipeline._stage import validate_issue
|
||||
from backend.llm import reset_cost, get_cost, set_stage, set_text_backend
|
||||
from backend.llm import (
|
||||
reset_cost, get_cost, set_stage, set_text_backend, set_model_overrides,
|
||||
)
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
@@ -52,6 +54,8 @@ def run_pipeline(
|
||||
project_input: Optional[Dict] = None,
|
||||
source_name: Optional[str] = None,
|
||||
text_local: bool = False,
|
||||
vision_model: Optional[str] = None,
|
||||
text_model: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
Run the full QAQC pipeline on one PDF and return the report dict.
|
||||
@@ -59,6 +63,9 @@ def run_pipeline(
|
||||
project_input: optional intake fields (project_name, address, occupancy,
|
||||
work_type). Cover-sheet-derived values fill any gaps; intake fields win.
|
||||
|
||||
vision_model / text_model: optional per-run OpenRouter (or local text)
|
||||
model overrides from the UI. Blank/None keeps config defaults.
|
||||
|
||||
If out_dir is given, writes conflicts.json, report.md, and the intermediate
|
||||
artifacts (assertions.json, clusters.json, and one json per QAQC stage).
|
||||
"""
|
||||
@@ -70,7 +77,29 @@ def run_pipeline(
|
||||
|
||||
reset_cost()
|
||||
set_text_backend(text_local)
|
||||
set_model_overrides(vision_model, text_model)
|
||||
if vision_model or text_model:
|
||||
print(f"[Runner] model overrides: vision={vision_model or '(default)'} "
|
||||
f"text={text_model or '(default)'}")
|
||||
|
||||
try:
|
||||
return _run_stages(
|
||||
pdf_path, out_dir, stage, project_input, source_name, text_local,
|
||||
)
|
||||
finally:
|
||||
# Don't leak per-run picks into a later overlapping/CLI call.
|
||||
set_model_overrides(None, None)
|
||||
set_text_backend(False)
|
||||
|
||||
|
||||
def _run_stages(
|
||||
pdf_path: str,
|
||||
out_dir: Optional[str],
|
||||
stage: Callable[[str], None],
|
||||
project_input: Optional[Dict],
|
||||
source_name: Optional[str],
|
||||
text_local: bool,
|
||||
) -> Dict:
|
||||
stage("PDF -> images")
|
||||
pages = convert_pdf_to_images(pdf_path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user