windhover 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- windhover/__init__.py +5 -0
- windhover/config.py +32 -0
- windhover/demo_graph.py +48 -0
- windhover/extract.py +103 -0
- windhover/pricing.json +16 -0
- windhover/server.py +333 -0
- windhover/static/icon.svg +8 -0
- windhover/static/index.html +843 -0
- windhover/static/manifest.json +18 -0
- windhover/static/vendor/cytoscape-dagre.js +397 -0
- windhover/static/vendor/cytoscape.min.js +32 -0
- windhover/static/vendor/dagre.min.js +3809 -0
- windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
- windhover/store.py +347 -0
- windhover/tracer.py +325 -0
- windhover-0.7.0.dist-info/METADATA +111 -0
- windhover-0.7.0.dist-info/RECORD +22 -0
- windhover-0.7.0.dist-info/WHEEL +4 -0
- windhover-0.7.0.dist-info/entry_points.txt +2 -0
- windhover-0.7.0.dist-info/licenses/LICENSE +21 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
windhover/store.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Windhover data store — SQLite (WAL), a run + span-tree model.
|
|
2
|
+
|
|
3
|
+
Runs hold aggregate totals; spans form a tree (node spans top-level, LLM/tool
|
|
4
|
+
spans nest under a node). Thread-safe via a module lock; each op uses a short-
|
|
5
|
+
lived connection so worker threads and the request thread never share one.
|
|
6
|
+
|
|
7
|
+
Search uses FTS5 over span payloads when the host SQLite has it, and degrades
|
|
8
|
+
to LIKE scans when it doesn't; tag filtering likewise prefers json1 and falls
|
|
9
|
+
back to a substring match. Feature detection happens once at startup so the
|
|
10
|
+
same code runs on any Python/SQLite build.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import json, os, sqlite3, threading, time, uuid
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 4
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fts_match(q: str) -> str:
|
|
21
|
+
"""Turn free text into a safe FTS5 MATCH string (quoted AND terms)."""
|
|
22
|
+
return " ".join('"%s"' % t.replace('"', '""') for t in q.split())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Store:
|
|
26
|
+
def __init__(self, path: str):
|
|
27
|
+
self.path = path
|
|
28
|
+
self.has_fts = False
|
|
29
|
+
self.has_json1 = False
|
|
30
|
+
self._init()
|
|
31
|
+
|
|
32
|
+
def _conn(self) -> sqlite3.Connection:
|
|
33
|
+
c = sqlite3.connect(self.path, check_same_thread=False, timeout=10)
|
|
34
|
+
c.row_factory = sqlite3.Row
|
|
35
|
+
c.execute("PRAGMA journal_mode=WAL")
|
|
36
|
+
c.execute("PRAGMA busy_timeout=5000")
|
|
37
|
+
return c
|
|
38
|
+
|
|
39
|
+
def _init(self) -> None:
|
|
40
|
+
with _lock, self._conn() as c:
|
|
41
|
+
c.executescript("""
|
|
42
|
+
CREATE TABLE IF NOT EXISTS schema_meta(version INTEGER);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS runs(
|
|
44
|
+
id TEXT PRIMARY KEY, graph TEXT, source TEXT, status TEXT,
|
|
45
|
+
session TEXT, tags TEXT, input TEXT, error TEXT,
|
|
46
|
+
started_ms INTEGER, ended_ms INTEGER, duration_ms INTEGER,
|
|
47
|
+
node_count INTEGER DEFAULT 0, llm_calls INTEGER DEFAULT 0,
|
|
48
|
+
prompt_tokens INTEGER, completion_tokens INTEGER,
|
|
49
|
+
total_tokens INTEGER, cost_usd REAL);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS spans(
|
|
51
|
+
id TEXT PRIMARY KEY, run_id TEXT, parent_id TEXT, seq INTEGER,
|
|
52
|
+
type TEXT, name TEXT, status TEXT,
|
|
53
|
+
started_ms INTEGER, ended_ms INTEGER, offset_ms INTEGER, dur_ms INTEGER,
|
|
54
|
+
input TEXT, output TEXT, model TEXT,
|
|
55
|
+
prompt_tokens INTEGER, completion_tokens INTEGER, cost_usd REAL, error TEXT);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS scores(
|
|
57
|
+
id TEXT PRIMARY KEY, run_id TEXT, name TEXT, value REAL,
|
|
58
|
+
comment TEXT, source TEXT, created_ms INTEGER);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS ix_spans_run ON spans(run_id, seq);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS ix_runs_started ON runs(started_ms DESC);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS ix_runs_session ON runs(session);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS ix_scores_run ON scores(run_id);
|
|
63
|
+
""")
|
|
64
|
+
cols = [r[1] for r in c.execute("PRAGMA table_info(runs)").fetchall()]
|
|
65
|
+
if "bookmarked" not in cols:
|
|
66
|
+
c.execute("ALTER TABLE runs ADD COLUMN bookmarked INTEGER DEFAULT 0")
|
|
67
|
+
try:
|
|
68
|
+
c.execute("SELECT count(*) FROM json_each('[1]')")
|
|
69
|
+
self.has_json1 = True
|
|
70
|
+
except sqlite3.OperationalError:
|
|
71
|
+
self.has_json1 = False
|
|
72
|
+
try:
|
|
73
|
+
c.execute("""CREATE VIRTUAL TABLE IF NOT EXISTS span_fts
|
|
74
|
+
USING fts5(text, span_id UNINDEXED, run_id UNINDEXED)""")
|
|
75
|
+
self.has_fts = True
|
|
76
|
+
# one-time backfill (first boot after upgrade)
|
|
77
|
+
n_fts = c.execute("SELECT count(*) FROM span_fts").fetchone()[0]
|
|
78
|
+
n_spans = c.execute("SELECT count(*) FROM spans").fetchone()[0]
|
|
79
|
+
if n_fts == 0 and n_spans > 0:
|
|
80
|
+
for s in c.execute("SELECT id,run_id,name,model,error,input,output FROM spans"):
|
|
81
|
+
c.execute("INSERT INTO span_fts(text,span_id,run_id) VALUES(?,?,?)",
|
|
82
|
+
(self._span_text(dict(s)), s["id"], s["run_id"]))
|
|
83
|
+
except sqlite3.OperationalError:
|
|
84
|
+
self.has_fts = False
|
|
85
|
+
row = c.execute("SELECT version FROM schema_meta LIMIT 1").fetchone()
|
|
86
|
+
if row is None:
|
|
87
|
+
self._migrate_v2(c)
|
|
88
|
+
c.execute("INSERT INTO schema_meta(version) VALUES(?)", (SCHEMA_VERSION,))
|
|
89
|
+
elif row["version"] < SCHEMA_VERSION:
|
|
90
|
+
c.execute("UPDATE schema_meta SET version=?", (SCHEMA_VERSION,))
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _span_text(s: dict) -> str:
|
|
94
|
+
parts = [s.get("name"), s.get("model"), s.get("error")]
|
|
95
|
+
for f in ("input", "output"):
|
|
96
|
+
v = s.get(f)
|
|
97
|
+
if v is not None:
|
|
98
|
+
parts.append(v if isinstance(v, str) else json.dumps(v, default=str))
|
|
99
|
+
return " ".join(p for p in parts if p)
|
|
100
|
+
|
|
101
|
+
def _migrate_v2(self, c: sqlite3.Connection) -> None:
|
|
102
|
+
"""Best-effort: fold legacy v2 flat `events` into node spans (dev data)."""
|
|
103
|
+
try:
|
|
104
|
+
has = c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").fetchone()
|
|
105
|
+
if not has:
|
|
106
|
+
return
|
|
107
|
+
for e in c.execute("SELECT run_id,seq,node,summary,offset_ms,dur_ms FROM events").fetchall():
|
|
108
|
+
c.execute("""INSERT OR IGNORE INTO spans
|
|
109
|
+
(id,run_id,parent_id,seq,type,name,status,offset_ms,dur_ms,output)
|
|
110
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)""",
|
|
111
|
+
(f"{e['run_id']}-{e['seq']}", e["run_id"], None, e["seq"], "node",
|
|
112
|
+
e["node"], "ok", e["offset_ms"], e["dur_ms"], e["summary"]))
|
|
113
|
+
c.execute("ALTER TABLE events RENAME TO events_legacy_v2")
|
|
114
|
+
except Exception:
|
|
115
|
+
pass # migration is a courtesy; never block startup
|
|
116
|
+
|
|
117
|
+
# ---- writes -----------------------------------------------------------
|
|
118
|
+
def open_run(self, run: dict) -> None:
|
|
119
|
+
with _lock, self._conn() as c:
|
|
120
|
+
c.execute("""INSERT OR REPLACE INTO runs
|
|
121
|
+
(id,graph,source,status,session,tags,input,started_ms)
|
|
122
|
+
VALUES(?,?,?,?,?,?,?,?)""",
|
|
123
|
+
(run["id"], run.get("graph"), run.get("source", "ui"), "running",
|
|
124
|
+
run.get("session"), json.dumps(run.get("tags")),
|
|
125
|
+
json.dumps(run.get("input"), default=str), run["started_ms"]))
|
|
126
|
+
|
|
127
|
+
def add_span(self, s: dict) -> None:
|
|
128
|
+
with _lock, self._conn() as c:
|
|
129
|
+
c.execute("""INSERT OR REPLACE INTO spans
|
|
130
|
+
(id,run_id,parent_id,seq,type,name,status,started_ms,ended_ms,offset_ms,
|
|
131
|
+
dur_ms,input,output,model,prompt_tokens,completion_tokens,cost_usd,error)
|
|
132
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
133
|
+
(s["id"], s["run_id"], s.get("parent_id"), s.get("seq", 0), s["type"],
|
|
134
|
+
s.get("name"), s.get("status", "ok"), s.get("started_ms"), s.get("ended_ms"),
|
|
135
|
+
s.get("offset_ms"), s.get("dur_ms"),
|
|
136
|
+
json.dumps(s.get("input"), default=str) if s.get("input") is not None else None,
|
|
137
|
+
json.dumps(s.get("output"), default=str) if s.get("output") is not None else None,
|
|
138
|
+
s.get("model"), s.get("prompt_tokens"), s.get("completion_tokens"),
|
|
139
|
+
s.get("cost_usd"), s.get("error")))
|
|
140
|
+
if self.has_fts:
|
|
141
|
+
c.execute("DELETE FROM span_fts WHERE span_id=?", (s["id"],))
|
|
142
|
+
c.execute("INSERT INTO span_fts(text,span_id,run_id) VALUES(?,?,?)",
|
|
143
|
+
(self._span_text(s), s["id"], s["run_id"]))
|
|
144
|
+
|
|
145
|
+
def close_run(self, run_id: str, status: str, ended_ms: int, error: Optional[str] = None) -> None:
|
|
146
|
+
with _lock, self._conn() as c:
|
|
147
|
+
agg = c.execute("""SELECT
|
|
148
|
+
COUNT(*) FILTER (WHERE type='node') nc,
|
|
149
|
+
COUNT(*) FILTER (WHERE type='llm') lc,
|
|
150
|
+
SUM(prompt_tokens) pt, SUM(completion_tokens) ct, SUM(cost_usd) cost
|
|
151
|
+
FROM spans WHERE run_id=?""", (run_id,)).fetchone()
|
|
152
|
+
st = c.execute("SELECT started_ms FROM runs WHERE id=?", (run_id,)).fetchone()
|
|
153
|
+
started = st["started_ms"] if st else ended_ms
|
|
154
|
+
pt, ct = agg["pt"], agg["ct"]
|
|
155
|
+
c.execute("""UPDATE runs SET status=?,ended_ms=?,duration_ms=?,error=?,
|
|
156
|
+
node_count=?,llm_calls=?,prompt_tokens=?,completion_tokens=?,total_tokens=?,cost_usd=?
|
|
157
|
+
WHERE id=?""",
|
|
158
|
+
(status, ended_ms, ended_ms - started, error, agg["nc"], agg["lc"],
|
|
159
|
+
pt, ct, (pt or 0) + (ct or 0) if (pt or ct) else None, agg["cost"], run_id))
|
|
160
|
+
|
|
161
|
+
def update_run_meta(self, run_id: str, tags: Optional[list] = None,
|
|
162
|
+
bookmarked: Optional[bool] = None) -> bool:
|
|
163
|
+
sets, args = [], []
|
|
164
|
+
if tags is not None:
|
|
165
|
+
sets.append("tags=?"); args.append(json.dumps([str(t) for t in tags]))
|
|
166
|
+
if bookmarked is not None:
|
|
167
|
+
sets.append("bookmarked=?"); args.append(1 if bookmarked else 0)
|
|
168
|
+
if not sets:
|
|
169
|
+
return False
|
|
170
|
+
args.append(run_id)
|
|
171
|
+
with _lock, self._conn() as c:
|
|
172
|
+
cur = c.execute(f"UPDATE runs SET {','.join(sets)} WHERE id=?", args)
|
|
173
|
+
return cur.rowcount > 0
|
|
174
|
+
|
|
175
|
+
def add_score(self, run_id: str, name: str, value: float,
|
|
176
|
+
comment: Optional[str] = None, source: str = "api") -> Optional[dict]:
|
|
177
|
+
with _lock, self._conn() as c:
|
|
178
|
+
if not c.execute("SELECT 1 FROM runs WHERE id=?", (run_id,)).fetchone():
|
|
179
|
+
return None
|
|
180
|
+
sc = {"id": uuid.uuid4().hex[:12], "run_id": run_id, "name": str(name),
|
|
181
|
+
"value": float(value), "comment": comment, "source": source,
|
|
182
|
+
"created_ms": int(time.time() * 1000)}
|
|
183
|
+
c.execute("""INSERT INTO scores(id,run_id,name,value,comment,source,created_ms)
|
|
184
|
+
VALUES(?,?,?,?,?,?,?)""",
|
|
185
|
+
(sc["id"], sc["run_id"], sc["name"], sc["value"],
|
|
186
|
+
sc["comment"], sc["source"], sc["created_ms"]))
|
|
187
|
+
return sc
|
|
188
|
+
|
|
189
|
+
def delete_score(self, score_id: str) -> bool:
|
|
190
|
+
with _lock, self._conn() as c:
|
|
191
|
+
return c.execute("DELETE FROM scores WHERE id=?", (score_id,)).rowcount > 0
|
|
192
|
+
|
|
193
|
+
def prune(self, days: int) -> dict:
|
|
194
|
+
"""Delete runs (and their spans/scores/index rows) older than `days`."""
|
|
195
|
+
cutoff = int((time.time() - days * 86400) * 1000)
|
|
196
|
+
with _lock, self._conn() as c:
|
|
197
|
+
ids = [r["id"] for r in
|
|
198
|
+
c.execute("SELECT id FROM runs WHERE started_ms < ?", (cutoff,)).fetchall()]
|
|
199
|
+
for chunk in (ids[i:i + 500] for i in range(0, len(ids), 500)):
|
|
200
|
+
q = ",".join("?" * len(chunk))
|
|
201
|
+
if self.has_fts:
|
|
202
|
+
c.execute(f"DELETE FROM span_fts WHERE run_id IN ({q})", chunk)
|
|
203
|
+
c.execute(f"DELETE FROM spans WHERE run_id IN ({q})", chunk)
|
|
204
|
+
c.execute(f"DELETE FROM scores WHERE run_id IN ({q})", chunk)
|
|
205
|
+
c.execute(f"DELETE FROM runs WHERE id IN ({q})", chunk)
|
|
206
|
+
return {"pruned_runs": len(ids), "cutoff_ms": cutoff}
|
|
207
|
+
|
|
208
|
+
# ---- reads ------------------------------------------------------------
|
|
209
|
+
def runs(self, limit: int = 50, offset: int = 0, q: Optional[str] = None,
|
|
210
|
+
status: Optional[str] = None, graph: Optional[str] = None,
|
|
211
|
+
session: Optional[str] = None, tag: Optional[str] = None,
|
|
212
|
+
bookmarked: Optional[bool] = None,
|
|
213
|
+
since_ms: Optional[int] = None, until_ms: Optional[int] = None) -> dict:
|
|
214
|
+
where, args = [], []
|
|
215
|
+
if q:
|
|
216
|
+
if self.has_fts:
|
|
217
|
+
where.append("id IN (SELECT DISTINCT run_id FROM span_fts WHERE span_fts MATCH ?)")
|
|
218
|
+
args.append(_fts_match(q))
|
|
219
|
+
else:
|
|
220
|
+
like = f"%{q}%"
|
|
221
|
+
where.append("""id IN (SELECT DISTINCT run_id FROM spans
|
|
222
|
+
WHERE input LIKE ? OR output LIKE ? OR name LIKE ? OR error LIKE ?)""")
|
|
223
|
+
args += [like, like, like, like]
|
|
224
|
+
if status:
|
|
225
|
+
where.append("status=?"); args.append(status)
|
|
226
|
+
if graph:
|
|
227
|
+
where.append("graph=?"); args.append(graph)
|
|
228
|
+
if session:
|
|
229
|
+
where.append("session=?"); args.append(session)
|
|
230
|
+
if tag:
|
|
231
|
+
if self.has_json1:
|
|
232
|
+
where.append("""EXISTS (SELECT 1 FROM json_each(COALESCE(runs.tags,'[]')) je
|
|
233
|
+
WHERE je.value=?)""")
|
|
234
|
+
args.append(tag)
|
|
235
|
+
else:
|
|
236
|
+
where.append("COALESCE(runs.tags,'') LIKE ?")
|
|
237
|
+
args.append(f'%"{tag}"%')
|
|
238
|
+
if bookmarked:
|
|
239
|
+
where.append("bookmarked=1")
|
|
240
|
+
if since_ms is not None:
|
|
241
|
+
where.append("started_ms>=?"); args.append(since_ms)
|
|
242
|
+
if until_ms is not None:
|
|
243
|
+
where.append("started_ms<=?"); args.append(until_ms)
|
|
244
|
+
cond = (" WHERE " + " AND ".join(where)) if where else ""
|
|
245
|
+
with _lock, self._conn() as c:
|
|
246
|
+
total = c.execute(f"SELECT COUNT(*) FROM runs{cond}", args).fetchone()[0]
|
|
247
|
+
rows = [dict(r) for r in c.execute(
|
|
248
|
+
f"SELECT * FROM runs{cond} ORDER BY started_ms DESC LIMIT ? OFFSET ?",
|
|
249
|
+
[*args, limit, offset]).fetchall()]
|
|
250
|
+
if rows:
|
|
251
|
+
ph = ",".join("?" * len(rows))
|
|
252
|
+
sc: dict[str, dict] = {}
|
|
253
|
+
for s in c.execute(
|
|
254
|
+
f"SELECT run_id,name,AVG(value) v FROM scores WHERE run_id IN ({ph}) "
|
|
255
|
+
"GROUP BY run_id,name", [r["id"] for r in rows]).fetchall():
|
|
256
|
+
sc.setdefault(s["run_id"], {})[s["name"]] = round(s["v"], 4)
|
|
257
|
+
for r in rows:
|
|
258
|
+
r["tags"] = json.loads(r["tags"]) if r.get("tags") else None
|
|
259
|
+
r["scores"] = sc.get(r["id"]) or None
|
|
260
|
+
return {"runs": rows, "total": total, "limit": limit, "offset": offset}
|
|
261
|
+
|
|
262
|
+
def sessions(self, limit: int = 100) -> list[dict]:
|
|
263
|
+
with _lock, self._conn() as c:
|
|
264
|
+
return [dict(r) for r in c.execute("""SELECT session,
|
|
265
|
+
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
266
|
+
MIN(started_ms) first_ms, MAX(started_ms) last_ms,
|
|
267
|
+
SUM(total_tokens) tokens, SUM(cost_usd) cost, SUM(duration_ms) duration_ms
|
|
268
|
+
FROM runs WHERE session IS NOT NULL AND session!=''
|
|
269
|
+
GROUP BY session ORDER BY last_ms DESC LIMIT ?""", (limit,)).fetchall()]
|
|
270
|
+
|
|
271
|
+
def run_detail(self, run_id: str) -> Optional[dict]:
|
|
272
|
+
with _lock, self._conn() as c:
|
|
273
|
+
r = c.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
|
|
274
|
+
if not r:
|
|
275
|
+
return None
|
|
276
|
+
spans = [dict(s) for s in c.execute(
|
|
277
|
+
"SELECT * FROM spans WHERE run_id=? ORDER BY seq", (run_id,)).fetchall()]
|
|
278
|
+
scores = [dict(s) for s in c.execute(
|
|
279
|
+
"SELECT * FROM scores WHERE run_id=? ORDER BY created_ms", (run_id,)).fetchall()]
|
|
280
|
+
d = dict(r)
|
|
281
|
+
for k in ("input", "tags"):
|
|
282
|
+
d[k] = json.loads(d[k]) if d.get(k) else None
|
|
283
|
+
for s in spans:
|
|
284
|
+
for k in ("input", "output"):
|
|
285
|
+
s[k] = json.loads(s[k]) if s.get(k) else None
|
|
286
|
+
d["spans"] = spans
|
|
287
|
+
d["scores"] = scores
|
|
288
|
+
return d
|
|
289
|
+
|
|
290
|
+
def node_history(self, name: str, limit: int = 25) -> dict:
|
|
291
|
+
"""Aggregate + recent executions of one named node/span across all runs,
|
|
292
|
+
each with its payloads and direct child spans (LLM/tool calls inside it)."""
|
|
293
|
+
with _lock, self._conn() as c:
|
|
294
|
+
agg = c.execute("""SELECT COUNT(*) n, AVG(dur_ms) avg_ms,
|
|
295
|
+
MIN(dur_ms) min_ms, MAX(dur_ms) max_ms,
|
|
296
|
+
COUNT(*) FILTER (WHERE status='error') errors,
|
|
297
|
+
SUM(COALESCE(prompt_tokens,0)+COALESCE(completion_tokens,0)) tokens,
|
|
298
|
+
SUM(cost_usd) cost
|
|
299
|
+
FROM spans WHERE name=?""", (name,)).fetchone()
|
|
300
|
+
rows = [dict(r) for r in c.execute("""SELECT s.*, r.graph, r.source
|
|
301
|
+
FROM spans s LEFT JOIN runs r ON r.id=s.run_id
|
|
302
|
+
WHERE s.name=? ORDER BY s.started_ms DESC LIMIT ?""",
|
|
303
|
+
(name, limit)).fetchall()]
|
|
304
|
+
kids: dict[str, list] = {}
|
|
305
|
+
if rows:
|
|
306
|
+
q = ",".join("?" * len(rows))
|
|
307
|
+
for k in c.execute(
|
|
308
|
+
f"SELECT * FROM spans WHERE parent_id IN ({q}) ORDER BY seq",
|
|
309
|
+
[r["id"] for r in rows]).fetchall():
|
|
310
|
+
kids.setdefault(k["parent_id"], []).append(dict(k))
|
|
311
|
+
for r in rows:
|
|
312
|
+
r["children"] = kids.get(r["id"], [])
|
|
313
|
+
for s in (r, *r["children"]):
|
|
314
|
+
for f in ("input", "output"):
|
|
315
|
+
s[f] = json.loads(s[f]) if s.get(f) else None
|
|
316
|
+
return {"name": name, "summary": dict(agg), "recent": rows}
|
|
317
|
+
|
|
318
|
+
def stats(self, days: int = 30) -> dict:
|
|
319
|
+
cutoff = int((time.time() - days * 86400) * 1000)
|
|
320
|
+
with _lock, self._conn() as c:
|
|
321
|
+
tot = c.execute("""SELECT COUNT(*) runs,
|
|
322
|
+
COUNT(*) FILTER (WHERE status='error') errors,
|
|
323
|
+
SUM(total_tokens) tokens, SUM(cost_usd) cost,
|
|
324
|
+
SUM(llm_calls) llm_calls FROM runs""").fetchone()
|
|
325
|
+
per_node = c.execute("""SELECT name, COUNT(*) n, AVG(dur_ms) avg_ms,
|
|
326
|
+
SUM(cost_usd) cost FROM spans WHERE type='node'
|
|
327
|
+
GROUP BY name ORDER BY avg_ms DESC LIMIT 20""").fetchall()
|
|
328
|
+
models = c.execute("""SELECT model, COUNT(*) calls,
|
|
329
|
+
SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens,
|
|
330
|
+
SUM(cost_usd) cost, AVG(dur_ms) avg_ms
|
|
331
|
+
FROM spans WHERE type='llm' AND model IS NOT NULL
|
|
332
|
+
GROUP BY model ORDER BY calls DESC LIMIT 20""").fetchall()
|
|
333
|
+
daily = c.execute("""SELECT
|
|
334
|
+
strftime('%Y-%m-%d', started_ms/1000, 'unixepoch') day,
|
|
335
|
+
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
336
|
+
SUM(total_tokens) tokens, SUM(cost_usd) cost
|
|
337
|
+
FROM runs WHERE started_ms >= ?
|
|
338
|
+
GROUP BY day ORDER BY day""", (cutoff,)).fetchall()
|
|
339
|
+
try:
|
|
340
|
+
db_bytes = os.path.getsize(self.path)
|
|
341
|
+
except OSError:
|
|
342
|
+
db_bytes = None
|
|
343
|
+
return {"totals": {**dict(tot), "db_bytes": db_bytes},
|
|
344
|
+
"per_node": [dict(r) for r in per_node],
|
|
345
|
+
"models": [dict(r) for r in models],
|
|
346
|
+
"daily": [dict(r) for r in daily],
|
|
347
|
+
"days": days}
|