magic-lab-observer-backend 0.1.1__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.
- app/__init__.py +1 -0
- app/api/__init__.py +5 -0
- app/api/analytics.py +389 -0
- app/api/evaluations.py +314 -0
- app/api/manitos_ingest.py +454 -0
- app/api/traces.py +753 -0
- app/auth.py +36 -0
- app/db/__init__.py +20 -0
- app/db/postgres.py +27 -0
- app/evaluators/__init__.py +313 -0
- app/evaluators/rubric_engine.py +517 -0
- app/evaluators/rubrics/default.yaml +218 -0
- app/evaluators/rubrics/scoring_levels.json +182 -0
- app/main.py +123 -0
- app/models/__init__.py +16 -0
- app/models/evaluation.py +38 -0
- app/models/ingestion.py +27 -0
- app/models/trace.py +66 -0
- app/ops/__init__.py +1 -0
- app/ops/manitos_passive_gate.py +738 -0
- app/ratelimit.py +25 -0
- app/websocket.py +53 -0
- magic_lab_observer_backend-0.1.1.dist-info/METADATA +62 -0
- magic_lab_observer_backend-0.1.1.dist-info/RECORD +26 -0
- magic_lab_observer_backend-0.1.1.dist-info/WHEEL +4 -0
- magic_lab_observer_backend-0.1.1.dist-info/entry_points.txt +3 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM Observatory Backend - FastAPI application."""
|
app/api/__init__.py
ADDED
app/api/analytics.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Analytics API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, Query
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from sqlalchemy import func, select
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
|
|
12
|
+
from app.db import get_session
|
|
13
|
+
from app.models.evaluation import Evaluation
|
|
14
|
+
from app.models.trace import Span, Trace
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AnalyticsSummary(BaseModel):
|
|
20
|
+
"""Summary analytics response."""
|
|
21
|
+
|
|
22
|
+
total_traces: int
|
|
23
|
+
total_spans: int
|
|
24
|
+
total_cost_usd: float
|
|
25
|
+
avg_latency_ms: float
|
|
26
|
+
error_count: int
|
|
27
|
+
error_rate: float
|
|
28
|
+
total_evaluations: int
|
|
29
|
+
avg_evaluation_score: float | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CostByModel(BaseModel):
|
|
33
|
+
"""Cost breakdown by model."""
|
|
34
|
+
|
|
35
|
+
model: str
|
|
36
|
+
cost_usd: float
|
|
37
|
+
span_count: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TraceTimeline(BaseModel):
|
|
41
|
+
"""Trace count over time."""
|
|
42
|
+
|
|
43
|
+
timestamp: str
|
|
44
|
+
count: int
|
|
45
|
+
cost_usd: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QualityBreakdown(BaseModel):
|
|
49
|
+
"""Per-turn occurrence count for one bounded metadata value."""
|
|
50
|
+
|
|
51
|
+
key: str
|
|
52
|
+
count: int
|
|
53
|
+
rate: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ManitOSQualitySummary(BaseModel):
|
|
57
|
+
"""Metadata-only quality indicators emitted by a compatible integration."""
|
|
58
|
+
|
|
59
|
+
project_id: str
|
|
60
|
+
environment: str | None
|
|
61
|
+
hours: int
|
|
62
|
+
window_start: str | None = None
|
|
63
|
+
window_end: str | None = None
|
|
64
|
+
total_turns: int
|
|
65
|
+
error_count: int
|
|
66
|
+
error_rate: float
|
|
67
|
+
degraded_count: int
|
|
68
|
+
degraded_rate: float
|
|
69
|
+
truncated_count: int
|
|
70
|
+
truncated_rate: float
|
|
71
|
+
tool_error_count: int
|
|
72
|
+
tool_error_rate: float
|
|
73
|
+
fallback_count: int
|
|
74
|
+
fallback_rate: float
|
|
75
|
+
tts_error_count: int
|
|
76
|
+
tts_error_rate: float
|
|
77
|
+
avg_duration_ms: float
|
|
78
|
+
avg_ttft_ms: float
|
|
79
|
+
models: list[QualityBreakdown]
|
|
80
|
+
languages: list[QualityBreakdown]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("/summary", response_model=AnalyticsSummary)
|
|
84
|
+
async def get_analytics_summary(
|
|
85
|
+
session_id: str | None = Query(None),
|
|
86
|
+
hours: int = Query(24, ge=1, le=720),
|
|
87
|
+
session: AsyncSession = Depends(get_session),
|
|
88
|
+
) -> dict:
|
|
89
|
+
"""Get analytics summary for the specified time period."""
|
|
90
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
91
|
+
|
|
92
|
+
# Total traces
|
|
93
|
+
trace_query = select(func.count(Trace.id)).where(Trace.created_at >= cutoff)
|
|
94
|
+
if session_id:
|
|
95
|
+
trace_query = trace_query.where(Trace.session_id == session_id)
|
|
96
|
+
result = await session.execute(trace_query)
|
|
97
|
+
total_traces = result.scalar() or 0
|
|
98
|
+
|
|
99
|
+
# Total spans and cost
|
|
100
|
+
span_query = select(Span.start_time, Span.end_time, Span.cost_usd).join(
|
|
101
|
+
Trace, Span.trace_id == Trace.id
|
|
102
|
+
).where(Trace.created_at >= cutoff)
|
|
103
|
+
if session_id:
|
|
104
|
+
span_query = span_query.where(Trace.session_id == session_id)
|
|
105
|
+
result = await session.execute(span_query)
|
|
106
|
+
span_rows = result.all()
|
|
107
|
+
total_spans = len(span_rows)
|
|
108
|
+
total_cost_usd = sum(float(row.cost_usd or 0) for row in span_rows)
|
|
109
|
+
latencies_ms = [
|
|
110
|
+
(row.end_time - row.start_time).total_seconds() * 1000
|
|
111
|
+
for row in span_rows
|
|
112
|
+
if row.start_time is not None and row.end_time is not None
|
|
113
|
+
]
|
|
114
|
+
avg_latency_ms = sum(latencies_ms) / len(latencies_ms) if latencies_ms else 0.0
|
|
115
|
+
|
|
116
|
+
# Error count
|
|
117
|
+
error_query = select(func.count(Trace.id)).where(
|
|
118
|
+
Trace.created_at >= cutoff, Trace.status == "error"
|
|
119
|
+
)
|
|
120
|
+
if session_id:
|
|
121
|
+
error_query = error_query.where(Trace.session_id == session_id)
|
|
122
|
+
result = await session.execute(error_query)
|
|
123
|
+
error_count = result.scalar() or 0
|
|
124
|
+
|
|
125
|
+
# Evaluations
|
|
126
|
+
eval_query = select(
|
|
127
|
+
func.count(Evaluation.id),
|
|
128
|
+
func.coalesce(func.avg(Evaluation.score), 0),
|
|
129
|
+
).join(Trace, Evaluation.trace_id == Trace.id).where(Trace.created_at >= cutoff)
|
|
130
|
+
if session_id:
|
|
131
|
+
eval_query = eval_query.where(Trace.session_id == session_id)
|
|
132
|
+
result = await session.execute(eval_query)
|
|
133
|
+
eval_data = result.one()
|
|
134
|
+
total_evaluations = eval_data[0] or 0
|
|
135
|
+
avg_score = float(eval_data[1] or 0) if total_evaluations > 0 else None
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"total_traces": total_traces,
|
|
139
|
+
"total_spans": total_spans,
|
|
140
|
+
"total_cost_usd": total_cost_usd,
|
|
141
|
+
"avg_latency_ms": avg_latency_ms,
|
|
142
|
+
"error_count": error_count,
|
|
143
|
+
"error_rate": error_count / total_traces if total_traces > 0 else 0,
|
|
144
|
+
"total_evaluations": total_evaluations,
|
|
145
|
+
"avg_evaluation_score": avg_score,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@router.get("/cost-by-model", response_model=list[CostByModel])
|
|
150
|
+
async def get_cost_by_model(
|
|
151
|
+
hours: int = Query(24, ge=1, le=720),
|
|
152
|
+
session: AsyncSession = Depends(get_session),
|
|
153
|
+
) -> list[dict]:
|
|
154
|
+
"""Get cost breakdown by model (from span attributes)."""
|
|
155
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
156
|
+
|
|
157
|
+
# SQLAlchemy JSON extraction differs between SQLite and PostgreSQL.
|
|
158
|
+
# Aggregate this bounded time-window result in Python for identical
|
|
159
|
+
# semantics on both supported databases.
|
|
160
|
+
query = select(Span.attributes, Span.cost_usd).join(
|
|
161
|
+
Trace, Span.trace_id == Trace.id
|
|
162
|
+
).where(Trace.created_at >= cutoff)
|
|
163
|
+
result = await session.execute(query)
|
|
164
|
+
rows = result.all()
|
|
165
|
+
by_model: dict[str, dict[str, str | float | int]] = {}
|
|
166
|
+
for row in rows:
|
|
167
|
+
attributes = row.attributes if isinstance(row.attributes, dict) else {}
|
|
168
|
+
model = str(attributes.get("model") or "").strip()
|
|
169
|
+
if not model:
|
|
170
|
+
continue
|
|
171
|
+
bucket = by_model.setdefault(model, {"model": model, "cost_usd": 0.0, "span_count": 0})
|
|
172
|
+
bucket["cost_usd"] = float(bucket["cost_usd"]) + float(row.cost_usd or 0)
|
|
173
|
+
bucket["span_count"] = int(bucket["span_count"]) + 1
|
|
174
|
+
return sorted(by_model.values(), key=lambda item: float(item["cost_usd"]), reverse=True)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.get("/timeline", response_model=list[TraceTimeline])
|
|
178
|
+
async def get_trace_timeline(
|
|
179
|
+
hours: int = Query(24, ge=1, le=720),
|
|
180
|
+
interval_minutes: int = Query(60, ge=5, le=1440),
|
|
181
|
+
session: AsyncSession = Depends(get_session),
|
|
182
|
+
) -> list[dict]:
|
|
183
|
+
"""Get trace count and cost over time."""
|
|
184
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
|
185
|
+
|
|
186
|
+
# Get all traces in the time range
|
|
187
|
+
query = (
|
|
188
|
+
select(Trace)
|
|
189
|
+
.where(Trace.created_at >= cutoff)
|
|
190
|
+
.order_by(Trace.created_at)
|
|
191
|
+
)
|
|
192
|
+
result = await session.execute(query)
|
|
193
|
+
traces = list(result.scalars().all())
|
|
194
|
+
|
|
195
|
+
# Get all span costs for traces in the time range (single query, no N+1)
|
|
196
|
+
cost_query = select(
|
|
197
|
+
Span.trace_id,
|
|
198
|
+
func.coalesce(func.sum(Span.cost_usd), 0).label("cost_usd"),
|
|
199
|
+
).join(Trace, Span.trace_id == Trace.id).where(
|
|
200
|
+
Trace.created_at >= cutoff
|
|
201
|
+
).group_by(Span.trace_id)
|
|
202
|
+
cost_result = await session.execute(cost_query)
|
|
203
|
+
cost_by_trace = {row.trace_id: float(row.cost_usd) for row in cost_result.all()}
|
|
204
|
+
|
|
205
|
+
# Bucket traces by interval
|
|
206
|
+
buckets: dict[str, dict] = {}
|
|
207
|
+
for trace in traces:
|
|
208
|
+
# Truncate to interval
|
|
209
|
+
ts = trace.created_at.replace(
|
|
210
|
+
minute=(trace.created_at.minute // interval_minutes) * interval_minutes,
|
|
211
|
+
second=0,
|
|
212
|
+
microsecond=0,
|
|
213
|
+
)
|
|
214
|
+
key = ts.isoformat()
|
|
215
|
+
if key not in buckets:
|
|
216
|
+
buckets[key] = {"timestamp": key, "count": 0, "cost_usd": 0}
|
|
217
|
+
buckets[key]["count"] += 1
|
|
218
|
+
buckets[key]["cost_usd"] += cost_by_trace.get(trace.id, 0)
|
|
219
|
+
|
|
220
|
+
return list(buckets.values())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@router.get("/manitos-quality", response_model=ManitOSQualitySummary)
|
|
224
|
+
async def get_manitos_quality(
|
|
225
|
+
hours: int = Query(24, ge=1, le=720),
|
|
226
|
+
project_id: str = Query("manitos", min_length=1, max_length=128),
|
|
227
|
+
environment: str | None = Query(None, min_length=1, max_length=64),
|
|
228
|
+
since: datetime | None = Query(None),
|
|
229
|
+
until: datetime | None = Query(None),
|
|
230
|
+
session: AsyncSession = Depends(get_session),
|
|
231
|
+
) -> dict:
|
|
232
|
+
"""Aggregate bounded, metadata-only turn quality signals across SQL dialects.
|
|
233
|
+
|
|
234
|
+
With ``since`` the aggregation uses a fixed half-open interval
|
|
235
|
+
``[since, until or now)`` instead of the rolling ``hours`` window, so the
|
|
236
|
+
result is genuinely cumulative over that interval and never mixes turns
|
|
237
|
+
aging out of a sliding cutoff.
|
|
238
|
+
"""
|
|
239
|
+
now = datetime.now(timezone.utc)
|
|
240
|
+
if since is not None:
|
|
241
|
+
if since.tzinfo is None:
|
|
242
|
+
since = since.replace(tzinfo=timezone.utc)
|
|
243
|
+
window_start = since
|
|
244
|
+
window_end = until or now
|
|
245
|
+
if window_end.tzinfo is None:
|
|
246
|
+
window_end = window_end.replace(tzinfo=timezone.utc)
|
|
247
|
+
cutoff = window_start
|
|
248
|
+
else:
|
|
249
|
+
cutoff = now - timedelta(hours=hours)
|
|
250
|
+
window_start = cutoff
|
|
251
|
+
window_end = now
|
|
252
|
+
trace_query = select(Trace.id, Trace.status).where(
|
|
253
|
+
Trace.created_at >= cutoff, Trace.created_at < window_end,
|
|
254
|
+
Trace.project_id == project_id,
|
|
255
|
+
)
|
|
256
|
+
if environment:
|
|
257
|
+
trace_query = trace_query.where(Trace.environment == environment)
|
|
258
|
+
trace_rows = (await session.execute(trace_query)).all()
|
|
259
|
+
trace_ids = {str(row.id) for row in trace_rows}
|
|
260
|
+
total_turns = len(trace_ids)
|
|
261
|
+
error_count = sum(1 for row in trace_rows if row.status == "error")
|
|
262
|
+
|
|
263
|
+
degraded: set[str] = set()
|
|
264
|
+
truncated: set[str] = set()
|
|
265
|
+
tool_errors: set[str] = set()
|
|
266
|
+
fallbacks: set[str] = set()
|
|
267
|
+
tts_errors: set[str] = set()
|
|
268
|
+
durations: list[float] = []
|
|
269
|
+
ttfts: list[float] = []
|
|
270
|
+
models: dict[str, set[str]] = {}
|
|
271
|
+
languages: dict[str, set[str]] = {}
|
|
272
|
+
|
|
273
|
+
if trace_ids:
|
|
274
|
+
span_query = select(Span.trace_id, Span.name, Span.attributes).where(
|
|
275
|
+
Span.trace_id.in_(trace_ids)
|
|
276
|
+
)
|
|
277
|
+
span_rows = (await session.execute(span_query)).all()
|
|
278
|
+
for row in span_rows:
|
|
279
|
+
trace_id = str(row.trace_id)
|
|
280
|
+
attributes = row.attributes if isinstance(row.attributes, dict) else {}
|
|
281
|
+
if row.name == "manitos.turn.lifecycle":
|
|
282
|
+
if attributes.get("llm_degraded") is True:
|
|
283
|
+
degraded.add(trace_id)
|
|
284
|
+
if attributes.get("llm_truncated") is True:
|
|
285
|
+
truncated.add(trace_id)
|
|
286
|
+
if attributes.get("tool_error") is True:
|
|
287
|
+
tool_errors.add(trace_id)
|
|
288
|
+
for key, target in (("duration_ms", durations), ("ttft_ms", ttfts)):
|
|
289
|
+
value = attributes.get(key)
|
|
290
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
291
|
+
target.append(float(value))
|
|
292
|
+
if attributes.get("local_fallback") is True:
|
|
293
|
+
fallbacks.add(trace_id)
|
|
294
|
+
if row.name == "tts.synthesis" and attributes.get("status") == "error":
|
|
295
|
+
tts_errors.add(trace_id)
|
|
296
|
+
for key, target in (("model", models), ("language", languages)):
|
|
297
|
+
value = str(attributes.get(key) or "").strip()[:160]
|
|
298
|
+
if value:
|
|
299
|
+
target.setdefault(value, set()).add(trace_id)
|
|
300
|
+
|
|
301
|
+
def rate(count: int) -> float:
|
|
302
|
+
return count / total_turns if total_turns else 0.0
|
|
303
|
+
|
|
304
|
+
def breakdown(values: dict[str, set[str]]) -> list[dict]:
|
|
305
|
+
items = [
|
|
306
|
+
{"key": key, "count": len(ids), "rate": rate(len(ids))}
|
|
307
|
+
for key, ids in values.items()
|
|
308
|
+
]
|
|
309
|
+
return sorted(items, key=lambda item: (-item["count"], item["key"]))
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"project_id": project_id,
|
|
313
|
+
"environment": environment,
|
|
314
|
+
"hours": hours,
|
|
315
|
+
"window_start": window_start.isoformat(),
|
|
316
|
+
"window_end": window_end.isoformat(),
|
|
317
|
+
"total_turns": total_turns,
|
|
318
|
+
"error_count": error_count,
|
|
319
|
+
"error_rate": rate(error_count),
|
|
320
|
+
"degraded_count": len(degraded),
|
|
321
|
+
"degraded_rate": rate(len(degraded)),
|
|
322
|
+
"truncated_count": len(truncated),
|
|
323
|
+
"truncated_rate": rate(len(truncated)),
|
|
324
|
+
"tool_error_count": len(tool_errors),
|
|
325
|
+
"tool_error_rate": rate(len(tool_errors)),
|
|
326
|
+
"fallback_count": len(fallbacks),
|
|
327
|
+
"fallback_rate": rate(len(fallbacks)),
|
|
328
|
+
"tts_error_count": len(tts_errors),
|
|
329
|
+
"tts_error_rate": rate(len(tts_errors)),
|
|
330
|
+
"avg_duration_ms": sum(durations) / len(durations) if durations else 0.0,
|
|
331
|
+
"avg_ttft_ms": sum(ttfts) / len(ttfts) if ttfts else 0.0,
|
|
332
|
+
"models": breakdown(models),
|
|
333
|
+
"languages": breakdown(languages),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ── Sessions ─────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class SessionInfo(BaseModel):
|
|
341
|
+
"""Session summary."""
|
|
342
|
+
|
|
343
|
+
session_id: str
|
|
344
|
+
trace_count: int
|
|
345
|
+
total_cost_usd: float
|
|
346
|
+
created_at: str | None
|
|
347
|
+
last_activity: str | None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@router.get("/sessions", response_model=list[SessionInfo])
|
|
351
|
+
async def list_sessions(
|
|
352
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
353
|
+
session: AsyncSession = Depends(get_session),
|
|
354
|
+
) -> list[dict]:
|
|
355
|
+
"""List unique sessions with aggregated stats."""
|
|
356
|
+
query = (
|
|
357
|
+
select(
|
|
358
|
+
Trace.session_id,
|
|
359
|
+
func.count(Trace.id).label("trace_count"),
|
|
360
|
+
func.coalesce(
|
|
361
|
+
func.sum(
|
|
362
|
+
select(func.coalesce(func.sum(Span.cost_usd), 0))
|
|
363
|
+
.where(Span.trace_id == Trace.id)
|
|
364
|
+
.correlate(Trace)
|
|
365
|
+
.scalar_subquery()
|
|
366
|
+
),
|
|
367
|
+
0,
|
|
368
|
+
).label("total_cost_usd"),
|
|
369
|
+
func.min(Trace.created_at).label("created_at"),
|
|
370
|
+
func.max(Trace.created_at).label("last_activity"),
|
|
371
|
+
)
|
|
372
|
+
.where(Trace.session_id.isnot(None))
|
|
373
|
+
.group_by(Trace.session_id)
|
|
374
|
+
.order_by(func.max(Trace.created_at).desc())
|
|
375
|
+
.limit(limit)
|
|
376
|
+
)
|
|
377
|
+
result = await session.execute(query)
|
|
378
|
+
rows = result.all()
|
|
379
|
+
|
|
380
|
+
return [
|
|
381
|
+
{
|
|
382
|
+
"session_id": str(row.session_id),
|
|
383
|
+
"trace_count": row.trace_count,
|
|
384
|
+
"total_cost_usd": float(row.total_cost_usd),
|
|
385
|
+
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
386
|
+
"last_activity": row.last_activity.isoformat() if row.last_activity else None,
|
|
387
|
+
}
|
|
388
|
+
for row in rows
|
|
389
|
+
]
|