- 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.
116 lines
4.1 KiB
Python
116 lines
4.1 KiB
Python
import threading
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import backend.jobs as jobs
|
|
from backend.main import app
|
|
|
|
|
|
class _SyncThread:
|
|
"""Drop-in threading.Thread replacement that runs the target inline."""
|
|
|
|
def __init__(self, target=None, args=(), kwargs=None, **_ignored):
|
|
self._target = target
|
|
self._args = args
|
|
self._kwargs = kwargs or {}
|
|
|
|
def start(self):
|
|
self._target(*self._args, **self._kwargs)
|
|
|
|
|
|
@pytest.fixture
|
|
def job_env(monkeypatch, tmp_path):
|
|
monkeypatch.setattr("backend.config.OUTPUT_DIR", str(tmp_path))
|
|
monkeypatch.setattr(threading, "Thread", _SyncThread)
|
|
monkeypatch.setattr("backend.jobs.send_conflict_report", lambda *a, **k: True)
|
|
pdf = tmp_path / "set.pdf"
|
|
pdf.write_bytes(b"%PDF-1.4\n")
|
|
yield tmp_path
|
|
jobs._jobs.clear()
|
|
|
|
|
|
def test_job_log_captures_pipeline_output(job_env, monkeypatch):
|
|
def fake_runner(pdf_path, **kwargs):
|
|
print("STAGE banner: fake wave ran")
|
|
return {"source": "set.pdf", "summary": {"conflicts_found": 0}}
|
|
|
|
monkeypatch.setattr("backend.jobs.run_pipeline", fake_runner)
|
|
job_id = jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic")
|
|
|
|
log_path = job_env / job_id / "job.log"
|
|
assert log_path.is_file()
|
|
content = log_path.read_text()
|
|
assert "STAGE banner: fake wave ran" in content
|
|
assert job_id in content # header line
|
|
|
|
|
|
def test_job_log_endpoint_serves_log_and_404s(job_env, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"backend.jobs.run_pipeline",
|
|
lambda pdf_path, **kw: {"source": "s", "summary": {}},
|
|
)
|
|
job_id = jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic")
|
|
|
|
client = TestClient(app)
|
|
ok = client.get(f"/jobs/{job_id}/log")
|
|
assert ok.status_code == 200
|
|
assert ok.headers["content-type"].startswith("text/plain")
|
|
assert "Job " + job_id in ok.text
|
|
assert client.get("/jobs/nope/log").status_code == 404
|
|
|
|
|
|
def test_model_overrides_passed_to_classic_runner(job_env, monkeypatch):
|
|
"""Classic mode: per-run picks travel as run_pipeline kwargs (the runner
|
|
sets and clears llm.set_model_overrides itself)."""
|
|
seen = {}
|
|
|
|
def fake_runner(pdf_path, **kwargs):
|
|
seen.update(kwargs)
|
|
return {"source": "set.pdf", "summary": {}}
|
|
|
|
monkeypatch.setattr("backend.jobs.run_pipeline", fake_runner)
|
|
jobs.create_job(str(job_env / "set.pdf"), "set.pdf",
|
|
pipeline_mode="classic",
|
|
vision_model="openai/gpt-4o", text_model="openai/gpt-4o-mini")
|
|
|
|
assert seen["vision_model"] == "openai/gpt-4o"
|
|
assert seen["text_model"] == "openai/gpt-4o-mini"
|
|
|
|
|
|
def test_model_overrides_set_and_cleared_around_agent_run(job_env, monkeypatch):
|
|
"""Agent mode: the agent runner has no override params, so jobs.py sets
|
|
them module-level for the duration of the run."""
|
|
from backend import llm
|
|
|
|
seen = {}
|
|
|
|
def fake_agent_runner(pdf_path, **kwargs):
|
|
seen["vision"] = llm._vision_model_override
|
|
seen["text"] = llm._text_model_override
|
|
return {"source": "set.pdf", "summary": {}}
|
|
|
|
monkeypatch.setattr("backend.jobs.run_agent_pipeline", fake_agent_runner)
|
|
jobs.create_job(str(job_env / "set.pdf"), "set.pdf",
|
|
pipeline_mode="agent",
|
|
vision_model="openai/gpt-4o", text_model="openai/gpt-4o-mini")
|
|
|
|
assert seen["vision"] == "openai/gpt-4o"
|
|
assert seen["text"] == "openai/gpt-4o-mini"
|
|
assert llm._vision_model_override is None # cleared after the run
|
|
assert llm._text_model_override is None
|
|
|
|
|
|
def test_failed_run_logs_traceback(job_env, monkeypatch):
|
|
"""A crashed job must leave the traceback in job.log, not just str(e)."""
|
|
def boom(pdf_path, **kwargs):
|
|
raise RuntimeError("kaboom-stage-failure")
|
|
|
|
monkeypatch.setattr("backend.jobs.run_pipeline", boom)
|
|
job_id = jobs.create_job(str(job_env / "set.pdf"), "set.pdf", pipeline_mode="classic")
|
|
|
|
assert jobs._jobs[job_id]["status"] == "error"
|
|
content = (job_env / job_id / "job.log").read_text()
|
|
assert "Traceback (most recent call last)" in content
|
|
assert "RuntimeError: kaboom-stage-failure" in content
|