Verbose per-call LLM logging, raw request/response dumps, and end-of-log cost summary.
- [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:
+62
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user