diff --git a/backend/jobs.py b/backend/jobs.py index 2f8ad90..53c9d79 100644 --- a/backend/jobs.py +++ b/backend/jobs.py @@ -12,7 +12,9 @@ not. No external queue/DB. """ import json +import contextlib import os +import sys import time import uuid import shutil @@ -20,6 +22,7 @@ import threading from typing import Dict, Optional from backend import config +from backend import llm 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 @@ -29,6 +32,40 @@ _lock = threading.Lock() 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 + + def _set(job_id: str, **fields) -> None: with _lock: _jobs[job_id].update(fields) @@ -36,13 +73,14 @@ def _set(job_id: str, **fields) -> None: 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: + pipeline_mode: str = "classic", 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] = { @@ -53,6 +91,7 @@ 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, "stage": None, "created_at": time.time(), "finished_at": None, @@ -60,54 +99,26 @@ def create_job(pdf_path: str, source_filename: str, email: Optional[str] = None, "error": None, } threading.Thread(target=_run, args=( - job_id, pdf_path, project_input, text_local, pipeline_mode, + job_id, pdf_path, project_input, text_local, pipeline_mode, 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") -> None: + text_local: bool = False, pipeline_mode: str = "classic", + model: Optional[str] = None) -> 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) + header = (f"=== Job {job_id} | {pipeline_mode} | {_jobs[job_id].get('source')} | " + f"model={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): + _run_pipeline(job_id, pdf_path, out_dir, project_input, text_local, + pipeline_mode, model) except Exception as e: print(f"[Jobs] Job {job_id} failed: {e}") _set(job_id, status="error", error=str(e), finished_at=time.time()) @@ -119,6 +130,54 @@ def _run(job_id: str, pdf_path: str, project_input: Optional[Dict] = None, pass +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: + """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 + # (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 + if model: + print(f"[Jobs] Model override for this run: {model}") + llm.set_model_override(model) + try: + report = runner(pdf_path, **runner_kwargs) + finally: + if model: + llm.set_model_override(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. + _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) + + def _notify(job_id: str, report: Dict, out_dir: str) -> None: email = _jobs[job_id].get("email") if not email: diff --git a/backend/llm.py b/backend/llm.py index cf53cad..4e2151d 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -24,6 +24,16 @@ _clients: Dict[str, OpenAI] = {} # set_stage/cost pattern (single-user tool). _text_local = False +# Per-job model override (user picked a model 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 + def set_text_backend(local: bool) -> None: """Choose whether text (no-image) calls go to the local endpoint this run.""" @@ -171,12 +181,13 @@ def _resolve_backend(has_images: bool, model_override: Optional[str]) -> Dict[st "usage": False, # local has no OpenRouter usage accounting "local": True, } - # Vision, or text-on-OpenRouter (default / fallback). + # 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 return { "base_url": config.AI_BASE_URL, "api_key": config.AI_API_KEY, - "model": model_override or default_model, + "model": _model_override or model_override or default_model, "usage": True, "local": False, } diff --git a/backend/main.py b/backend/main.py index 3fd9881..d3f2201 100644 --- a/backend/main.py +++ b/backend/main.py @@ -41,6 +41,27 @@ def health(): "email_configured": bool(config.SMTP_HOST and config.SMTP_USER and config.SMTP_PASSWORD)} +@app.get("/models") +def list_models(): + """Available OpenRouter models with per-1M-token pricing for the UI picker.""" + from backend.models import fetch_models + 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} + + +@app.get("/jobs/{job_id}/log") +def job_log(job_id: str): + """The full captured stdout/stderr log of a job run (persists on disk).""" + path = os.path.join(config.OUTPUT_DIR, job_id, "job.log") + if not os.path.isfile(path): + raise HTTPException(status_code=404, detail="Log not found for this job") + with open(path, encoding="utf-8", errors="replace") as f: + return Response(content=f.read(), media_type="text/plain") + + @app.post("/check") async def check( file: UploadFile = File(...), @@ -51,6 +72,7 @@ async def check( work_type: Optional[str] = Form(None), text_local: bool = Form(False), pipeline_mode: str = Form("classic"), + model: Optional[str] = Form(None), ): """ Accept a PDF, start a background conflict check, and return a job_id @@ -86,7 +108,7 @@ async def check( } job_id = create_job(tmp_path, source_filename=file.filename, email=email, project_input=project_input, text_local=text_local, - pipeline_mode=pipeline_mode) + pipeline_mode=pipeline_mode, model=model) return JSONResponse({ "job_id": job_id, "status": "queued", diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..d9e09c6 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,65 @@ +""" +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. +""" + +import time +from typing import List, Optional + +import httpx + +from backend import config + +_CACHE_TTL_SECONDS = 3600 +_cache = {"at": 0.0, "models": None} + + +def _per_mtok(rate) -> float: + """OpenRouter pricing is USD per token (as a string); display is per 1M.""" + try: + return round(float(rate) * 1_000_000, 4) + except (TypeError, ValueError): + return 0.0 + + +def _fetch_openrouter_models() -> Optional[List[dict]]: + """Raw GET of the OpenRouter model list; None on any failure.""" + try: + response = httpx.get(f"{config.AI_BASE_URL.rstrip('/')}/models", timeout=10) + response.raise_for_status() + data = response.json().get("data") + return data if isinstance(data, list) else None + except Exception as e: + print(f"[Models] OpenRouter /models fetch failed: {e}") + return None + + +def fetch_models(force: bool = False) -> Optional[List[dict]]: + """Normalized model list for the UI picker, or None when unavailable.""" + if ( + not force + and _cache["models"] is not None + and time.time() - _cache["at"] < _CACHE_TTL_SECONDS + ): + return _cache["models"] + data = _fetch_openrouter_models() + if data is None: + return None + models = [ + { + "id": item.get("id") or "", + "name": item.get("name") or item.get("id") or "", + "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"), + } + for item in data + if item.get("id") + ] + models.sort(key=lambda m: m["id"]) + _cache["models"] = models + _cache["at"] = time.time() + return models diff --git a/frontend/index.html b/frontend/index.html index 262734e..fdb4c9e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -120,6 +120,10 @@ OpenRouter — all stages (fastest, paid) +