wpl-discovery / trace.py
GameTheory's picture
Sync from GitHub
24c5932 verified
Raw
History Blame Contribute Delete
6.75 kB
"""
trace.py - capture a structured agent trace per turn.
Three payoffs:
• Open Trace badge - every answer's reasoning is logged to traces.jsonl in a
shareable schema and can be pushed to the Hub.
• Best Agent - the trace is also shown in-chat as a "How I answered" panel,
so the agent's route → tool → retrieve → synthesise loop is visible.
• Behaviour analytics - when TRACE_DATASET is set, every turn is persisted to
a Hugging Face Dataset so the data survives the Space's reboots. That's the
raw material for iterating on real usage: which questions fall through to the
help text (`route.tool == "none"`), when the LLM router fails over to keyword
matching (`route.router == "keyword"`), and which live sources are flaky
(a step with `ok == False`).
The schema is intentionally close to the hackathon's own trace datasets:
one JSON object per user turn, with ordered steps.
Persistence is **opt-in and best-effort**: set `TRACE_DATASET` (e.g.
"your-user/wpl-traces") and have a write-scoped `HF_TOKEN`; otherwise it's a
no-op and only the local traces.jsonl is written. Uploads run on a single
background worker (so they never add latency to an answer and never conflict on
the dataset's git history) and any failure is swallowed - analytics must never
break the assistant. The dataset is created **private** (questions are user
input and may contain personal detail).
"""
from __future__ import annotations
import json
import os
import queue
import re
import threading
import time
import uuid
from datetime import datetime, timezone
TRACE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "traces.jsonl")
# Opt-in persistence config. TRACE_DATASET unset -> local-file-only (no-op).
TRACE_DATASET = os.environ.get("TRACE_DATASET")
_HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
class Trace:
def __init__(self, question: str, model: str):
self.d = {
"trace_id": uuid.uuid4().hex[:12],
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"app": "worcs-libraries-live-assistant",
"question": question,
"model": model,
"route": {},
"steps": [],
"answer": "",
"sources": [],
"total_ms": 0,
}
self._t0 = time.time()
def set_route(self, tool: str, args: dict, router: str, ms: int):
self.d["route"] = {"tool": tool, "args": args, "router": router,
"latency_ms": ms}
def step(self, kind: str, **kw):
self.d["steps"].append({"type": kind, **kw})
return self
def finish(self, answer: str, sources: list):
self.d["answer"] = answer
self.d["sources"] = [s for s in sources if s]
self.d["total_ms"] = int((time.time() - self._t0) * 1000)
return self
def save(self, path: str = TRACE_PATH):
try:
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(self.d, ensure_ascii=False) + "\n")
except Exception:
pass
enqueue_trace(self.d)
return self
def to_markdown(self) -> str:
r = self.d["route"]
steps = " → ".join(s["type"] for s in self.d["steps"]) or "-"
src = self.d["sources"][0] if self.d["sources"] else ""
srcline = f" · [source]({src})" if src else ""
return (
"\n\n<details open><summary>How I answered (agent trace)</summary>\n\n"
f"- **Route:** `{r.get('tool','?')}` via {r.get('router','?')} "
f"({r.get('latency_ms',0)} ms)\n"
f"- **Steps:** {steps}\n"
f"- **Model:** {self.d['model']} · **Total:** {self.d['total_ms']} ms · "
f"{self.d['ts']}{srcline}\n\n"
"<sub>Logged openly to `traces.jsonl` for the Open Trace badge.</sub>\n"
"</details>"
)
def to_dict(self) -> dict:
return self.d
def push_to_hub(repo_id: str, token: str | None = None, path: str = TRACE_PATH):
"""Upload the whole local traces.jsonl as one dataset file (legacy/manual).
For continuous persistence that survives reboots, prefer the automatic
per-turn upload below (set TRACE_DATASET). This whole-file push overwrites
the dataset's traces.jsonl and only contains the current container's turns.
"""
from huggingface_hub import HfApi
HfApi(token=token).upload_file(
path_or_fileobj=path, path_in_repo="traces.jsonl",
repo_id=repo_id, repo_type="dataset")
# --------------------------------------------------------------------------- #
# Continuous persistence - one small file per turn, on a single background
# worker. Per-turn files (never an append) sidestep read-modify-write races and
# can't lose earlier turns when the Space is rebooted, since each is its own
# object in the dataset repo.
# --------------------------------------------------------------------------- #
_queue: "queue.Queue[dict]" = queue.Queue(maxsize=2000)
_worker_started = False
_worker_lock = threading.Lock()
def _worker():
import io
from huggingface_hub import HfApi
api = HfApi(token=_HF_TOKEN)
try: # idempotent; private because questions are user input
api.create_repo(repo_id=TRACE_DATASET, repo_type="dataset",
private=True, exist_ok=True)
except Exception:
pass
while True:
record = _queue.get()
try:
blob = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
stamp = re.sub(r"[^0-9T]", "", record.get("ts", "")) or "0"
name = f"traces/{stamp}-{record.get('trace_id', 'x')}.jsonl"
api.upload_file(
path_or_fileobj=io.BytesIO(blob), path_in_repo=name,
repo_id=TRACE_DATASET, repo_type="dataset",
commit_message="add trace")
except Exception:
pass # best-effort: analytics must never break the assistant
finally:
_queue.task_done()
def _ensure_worker():
global _worker_started
if _worker_started:
return
with _worker_lock:
if not _worker_started:
threading.Thread(target=_worker, name="trace-uploader",
daemon=True).start()
_worker_started = True
def enqueue_trace(record: dict):
"""Queue a trace for upload. No-op unless TRACE_DATASET + HF_TOKEN are set."""
if not (TRACE_DATASET and _HF_TOKEN):
return
_ensure_worker()
try:
_queue.put_nowait(dict(record)) # copy: caller may keep mutating
except queue.Full:
pass # shed load rather than block or grow unboundedly