Verbose per-call LLM logging, raw request/response dumps, and end-of-log cost summary.
Docker Release / build-and-push (push) Successful in 1m13s
Docker Release / release (push) Skipped

- [LLM] line per call: stage, model, prompt size, output size, cost, parsed item counts
- LLM_RAW_DUMP: full prompt/response JSON per call under outputs/<job>/llm_raw/
- Cost block at tail of job.log (per-stage, per-model, cached vs live)
- Agent mode: reset llm cost counters per job; review finalization now teed into job.log + dumps
This commit is contained in:
2026-08-05 15:17:17 -05:00
parent f7e1b6bb7c
commit 7488cf68c5
7 changed files with 207 additions and 3 deletions
+62 -1
View File
@@ -21,7 +21,8 @@ import traceback
import uuid
import shutil
import threading
from typing import Dict, List, Optional
from contextlib import contextmanager
from typing import Dict, Iterator, List, Optional
from backend import config
from backend import llm
@@ -139,6 +140,12 @@ def _run(
_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)
# Cost-so-far for the failed run (counters are reset per job).
cost = llm.get_cost()
_append_log(job_id,
f"[Jobs] Estimated LLM cost before failure: "
f"${cost['usd']:.4f} over {cost['calls']} live calls "
f"({cost.get('cached', 0)} cached)", log_path)
except Exception:
pass
print(f"[Jobs] Job {job_id} failed: {e}")
@@ -170,6 +177,9 @@ def _run_pipeline(job_id: str, pdf_path: str, out_dir: str,
}, 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"))
# Raw per-call LLM request/response dumps land in <out_dir>/llm_raw/.
if config.LLM_RAW_DUMP:
llm.set_raw_dump_dir(os.path.join(out_dir, "llm_raw"))
runner = run_agent_pipeline if pipeline_mode == "agent" else run_pipeline
runner_kwargs = {
"out_dir": out_dir,
@@ -192,9 +202,11 @@ def _run_pipeline(job_id: str, pdf_path: str, out_dir: str,
try:
report = runner(pdf_path, **runner_kwargs)
finally:
llm.set_raw_dump_dir(None)
if pipeline_mode == "agent":
llm.set_model_overrides(None, None)
report.setdefault("summary", {})["pipeline_mode"] = pipeline_mode
_log_cost_summary(report.get("summary", {}))
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,
@@ -208,6 +220,55 @@ def _run_pipeline(job_id: str, pdf_path: str, out_dir: str,
_notify(job_id, report, out_dir)
def _log_cost_summary(summary: Dict, label: str = "this run") -> None:
"""
End-of-log estimated LLM cost block, printed inside the job's stdio tee so
it lands at the tail of job.log. Both runners populate the same summary
fields (cost_usd / llm_calls / cached_calls / cost_by_stage / models_used).
Hybrid note: local text calls carry no usage accounting, so the dollar
figure covers OpenRouter calls only (local call counts still appear).
"""
if summary.get("cost_usd") is None and not summary.get("llm_calls"):
return
print(f"\n=== Estimated LLM cost ({label}) ===")
print(f" Total: ${summary.get('cost_usd', 0.0):.4f} across "
f"{summary.get('llm_calls', 0)} live calls "
f"({summary.get('cached_calls', 0)} cached at $0)")
for name, bucket in (summary.get("cost_by_stage") or {}).items():
print(f" {name}: ${bucket.get('usd', 0.0):.4f} "
f"({bucket.get('calls', 0)} live, {bucket.get('cached', 0)} cached)")
mu = summary.get("models_used") or {}
if mu.get("vision"):
print(f" Vision models: {', '.join(mu['vision'])}")
if mu.get("text_local"):
print(f" Text models (local): {', '.join(mu['text_local'])}")
if mu.get("text_cloud"):
print(f" Text models (cloud): {', '.join(mu['text_cloud'])}")
if mu.get("fallback_count"):
print(f" Local->cloud fallbacks: {mu['fallback_count']}")
if mu.get("text_local"):
print(" Note: local calls have no cost accounting; "
"the dollar total covers OpenRouter usage only.")
@contextmanager
def capture_job_output(job_id: str, out_dir: str) -> Iterator[None]:
"""
Re-open the stdio tee + raw LLM dump dir for post-run work that still
belongs to this job (review finalization): lines append to job.log and
the in-memory log, raw dumps resume under <out_dir>/llm_raw/. The tee is
process-global — same overlapping-job caveat as the main run.
"""
log_path = os.path.join(out_dir, "job.log")
if config.LLM_RAW_DUMP:
llm.set_raw_dump_dir(os.path.join(out_dir, "llm_raw"))
try:
with capture_stdio(lambda raw: _append_log(job_id, raw, log_path)):
yield
finally:
llm.set_raw_dump_dir(None)
def _notify(job_id: str, report: Dict, out_dir: str) -> None:
email = _jobs[job_id].get("email")
if not email: