agentloop-profiler 0.1.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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- dashboard/value_view.py +105 -0
agentloop/server.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Query
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from agentloop.config import get_admin_api_key, get_api_key, get_cors_origins, require_api_key
|
|
11
|
+
from agentloop.findings import build_diagnosis
|
|
12
|
+
from agentloop.intervention_service import create_stored_intervention
|
|
13
|
+
from agentloop.interventions import (
|
|
14
|
+
InterventionConflictError,
|
|
15
|
+
InterventionReferenceError,
|
|
16
|
+
InterventionValidationError,
|
|
17
|
+
)
|
|
18
|
+
from agentloop.issues import build_issue_drafts
|
|
19
|
+
from agentloop.optimizer import build_optimization_plan
|
|
20
|
+
from agentloop.quality import QualityValidationError, build_quality_report
|
|
21
|
+
from agentloop.ranking import RankingSort, sort_ranked
|
|
22
|
+
from agentloop.schema import TraceValidationError, validate_elapsed_ms
|
|
23
|
+
from agentloop.store import (
|
|
24
|
+
DEFAULT_PAGE_SIZE,
|
|
25
|
+
MAX_PAGE_SIZE,
|
|
26
|
+
FindingNotFoundError,
|
|
27
|
+
FindingTransitionError,
|
|
28
|
+
InvalidCursorError,
|
|
29
|
+
TraceProjectConflictError,
|
|
30
|
+
TraceStore,
|
|
31
|
+
get_store,
|
|
32
|
+
)
|
|
33
|
+
from agentloop.tracer import AgentTrace
|
|
34
|
+
from agentloop.value import build_value_report
|
|
35
|
+
from agentloop.version import __version__
|
|
36
|
+
|
|
37
|
+
app = FastAPI(
|
|
38
|
+
title="AgentLoop API",
|
|
39
|
+
version=__version__,
|
|
40
|
+
description="API v1 is served under /v1. The info version is the server package version. "
|
|
41
|
+
"Unversioned application routes are deprecated aliases retained throughout 0.x; "
|
|
42
|
+
"their earliest removal is 1.0 with release notice. Health/readiness aliases remain supported.",
|
|
43
|
+
)
|
|
44
|
+
versioned = APIRouter(tags=["API v1"])
|
|
45
|
+
legacy = APIRouter(deprecated=True, tags=["Deprecated aliases"])
|
|
46
|
+
|
|
47
|
+
_cors_origins = get_cors_origins()
|
|
48
|
+
if _cors_origins:
|
|
49
|
+
app.add_middleware(
|
|
50
|
+
CORSMiddleware,
|
|
51
|
+
allow_origins=_cors_origins,
|
|
52
|
+
allow_credentials=True,
|
|
53
|
+
allow_methods=["*"],
|
|
54
|
+
allow_headers=["*"],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TracePayload(BaseModel):
|
|
59
|
+
name: str
|
|
60
|
+
run_id: str
|
|
61
|
+
schema_version: str | None = None
|
|
62
|
+
started_at: str | None = None
|
|
63
|
+
ended_at: str | None = None
|
|
64
|
+
elapsed_ms: float | None = Field(default=None, ge=0)
|
|
65
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
66
|
+
events: list[dict[str, Any]] = Field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@field_validator("elapsed_ms", mode="before")
|
|
69
|
+
@classmethod
|
|
70
|
+
def validate_elapsed(cls, value):
|
|
71
|
+
try:
|
|
72
|
+
return validate_elapsed_ms(value)
|
|
73
|
+
except TraceValidationError as exc:
|
|
74
|
+
# Keep non-finite input out of Pydantic's JSON error response as well
|
|
75
|
+
# as storage, and preserve the native schema's field/reason contract.
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=422, detail={"field": exc.field, "reason": exc.reason}
|
|
78
|
+
) from None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CreateApiKeyPayload(BaseModel):
|
|
82
|
+
project_id: str = "default"
|
|
83
|
+
name: str = "default"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class InterventionPayload(BaseModel):
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
baseline_run_id: str
|
|
89
|
+
candidate_run_id: str
|
|
90
|
+
target_finding_ids: list[str]
|
|
91
|
+
intervention_type: str
|
|
92
|
+
configuration: dict[str, Any] = Field(default_factory=dict)
|
|
93
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
94
|
+
gates: dict[str, Any] = Field(default_factory=dict)
|
|
95
|
+
quality_fixtures: list[dict[str, Any]] | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class QualityReportPayload(BaseModel):
|
|
99
|
+
fixtures: list[dict[str, Any]]
|
|
100
|
+
baseline_run_id: str | None = None
|
|
101
|
+
candidate_run_id: str | None = None
|
|
102
|
+
min_score: float | None = Field(default=None, ge=0, le=1)
|
|
103
|
+
|
|
104
|
+
@field_validator("min_score", mode="before")
|
|
105
|
+
@classmethod
|
|
106
|
+
def validate_min_score_type(cls, value: Any) -> Any:
|
|
107
|
+
if value is not None and (isinstance(value, bool) or not isinstance(value, int | float)):
|
|
108
|
+
raise ValueError("min_score must be a number between 0 and 1")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def store() -> TraceStore:
|
|
113
|
+
db = get_store()
|
|
114
|
+
db.init()
|
|
115
|
+
return db
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_project(
|
|
119
|
+
x_agentloop_key: str | None = Header(default=None),
|
|
120
|
+
db: TraceStore = Depends(store),
|
|
121
|
+
) -> str:
|
|
122
|
+
if x_agentloop_key:
|
|
123
|
+
key_record = db.verify_api_key(x_agentloop_key)
|
|
124
|
+
if key_record is not None:
|
|
125
|
+
return str(key_record["project_id"])
|
|
126
|
+
|
|
127
|
+
expected = get_api_key()
|
|
128
|
+
if require_api_key() and expected and hmac.compare_digest(x_agentloop_key, expected):
|
|
129
|
+
return "default"
|
|
130
|
+
|
|
131
|
+
raise HTTPException(status_code=401, detail="invalid AgentLoop API key")
|
|
132
|
+
|
|
133
|
+
if require_api_key():
|
|
134
|
+
raise HTTPException(status_code=401, detail="missing AgentLoop API key")
|
|
135
|
+
|
|
136
|
+
return "default"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_admin(x_agentloop_admin_key: str | None = Header(default=None)) -> None:
|
|
140
|
+
admin_key = get_admin_api_key()
|
|
141
|
+
if admin_key:
|
|
142
|
+
if x_agentloop_admin_key and hmac.compare_digest(x_agentloop_admin_key, admin_key):
|
|
143
|
+
return
|
|
144
|
+
raise HTTPException(status_code=401, detail="invalid AgentLoop admin API key")
|
|
145
|
+
|
|
146
|
+
if require_api_key():
|
|
147
|
+
raise HTTPException(
|
|
148
|
+
status_code=503,
|
|
149
|
+
detail="AGENTLOOP_ADMIN_API_KEY is required to create API keys when API auth is enabled",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@versioned.get("/health")
|
|
154
|
+
@app.get("/health")
|
|
155
|
+
def health() -> dict[str, str | bool]:
|
|
156
|
+
return {"status": "ok", "version": __version__, "auth_required": require_api_key()}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@versioned.get("/readyz")
|
|
160
|
+
@app.get("/readyz")
|
|
161
|
+
def readyz(db: TraceStore = Depends(store)) -> dict[str, str]:
|
|
162
|
+
db.usage_summary(project_id="default")
|
|
163
|
+
return {"status": "ready"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@versioned.post("/api-keys")
|
|
167
|
+
@legacy.post("/api-keys")
|
|
168
|
+
def create_api_key(
|
|
169
|
+
payload: CreateApiKeyPayload,
|
|
170
|
+
_: None = Depends(resolve_admin),
|
|
171
|
+
db: TraceStore = Depends(store),
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
return db.create_api_key(project_id=payload.project_id, name=payload.name)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@versioned.post("/traces")
|
|
177
|
+
@legacy.post("/traces")
|
|
178
|
+
def ingest_trace(
|
|
179
|
+
payload: TracePayload,
|
|
180
|
+
project_id: str = Depends(resolve_project),
|
|
181
|
+
db: TraceStore = Depends(store),
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
try:
|
|
184
|
+
trace = AgentTrace.from_dict(payload.model_dump())
|
|
185
|
+
except TraceValidationError as exc:
|
|
186
|
+
raise HTTPException(
|
|
187
|
+
status_code=422, detail={"field": exc.field, "reason": exc.reason}
|
|
188
|
+
) from None
|
|
189
|
+
try:
|
|
190
|
+
db.save_trace(trace, project_id=project_id)
|
|
191
|
+
except TraceProjectConflictError as exc:
|
|
192
|
+
raise HTTPException(status_code=409, detail=str(exc)) from None
|
|
193
|
+
return {"ok": True, "run_id": trace.run_id, "project_id": project_id, "report": trace.report()}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _selected_project(project_id_filter: str | None, authenticated_project: str) -> str:
|
|
197
|
+
if (
|
|
198
|
+
require_api_key()
|
|
199
|
+
and project_id_filter is not None
|
|
200
|
+
and project_id_filter != authenticated_project
|
|
201
|
+
):
|
|
202
|
+
raise HTTPException(status_code=403, detail="project access denied")
|
|
203
|
+
return project_id_filter or authenticated_project
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@versioned.get("/traces")
|
|
207
|
+
@legacy.get("/traces")
|
|
208
|
+
def list_traces(
|
|
209
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
210
|
+
page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
|
|
211
|
+
cursor: str | None = Query(default=None),
|
|
212
|
+
project_id: str = Depends(resolve_project),
|
|
213
|
+
db: TraceStore = Depends(store),
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""List traces for a project.
|
|
216
|
+
|
|
217
|
+
Without `page_size`/`cursor`, returns the full (bounded) list for
|
|
218
|
+
backward compatibility. Pass `page_size` to opt into keyset pagination:
|
|
219
|
+
the response then includes `next_cursor` — pass it back as `cursor` to
|
|
220
|
+
fetch the next page; `next_cursor: null` means there are no more rows.
|
|
221
|
+
"""
|
|
222
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
223
|
+
if page_size is None and cursor is None:
|
|
224
|
+
return {
|
|
225
|
+
"project_id": selected_project,
|
|
226
|
+
"traces": db.list_traces(project_id=selected_project),
|
|
227
|
+
"next_cursor": None,
|
|
228
|
+
}
|
|
229
|
+
try:
|
|
230
|
+
page = db.list_traces_page(
|
|
231
|
+
project_id=selected_project, limit=page_size or DEFAULT_PAGE_SIZE, cursor=cursor
|
|
232
|
+
)
|
|
233
|
+
except InvalidCursorError as exc:
|
|
234
|
+
raise HTTPException(status_code=400, detail=str(exc)) from None
|
|
235
|
+
return {
|
|
236
|
+
"project_id": selected_project,
|
|
237
|
+
"traces": page["items"],
|
|
238
|
+
"next_cursor": page["next_cursor"],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _load_trace_or_404(db: TraceStore, run_id: str, project_id: str) -> AgentTrace:
|
|
243
|
+
trace = db.get_trace(run_id=run_id, project_id=project_id)
|
|
244
|
+
if trace is None:
|
|
245
|
+
raise HTTPException(status_code=404, detail="trace not found")
|
|
246
|
+
return trace
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@versioned.get("/traces/{run_id}/report")
|
|
250
|
+
@legacy.get("/traces/{run_id}/report")
|
|
251
|
+
def get_report(
|
|
252
|
+
run_id: str,
|
|
253
|
+
project_id: str = Depends(resolve_project),
|
|
254
|
+
db: TraceStore = Depends(store),
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
return _load_trace_or_404(db, run_id, project_id).report()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@versioned.get("/traces/{run_id}/optimization")
|
|
260
|
+
@legacy.get("/traces/{run_id}/optimize")
|
|
261
|
+
def optimize_trace(
|
|
262
|
+
run_id: str,
|
|
263
|
+
project_id: str = Depends(resolve_project),
|
|
264
|
+
db: TraceStore = Depends(store),
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
return build_optimization_plan(_load_trace_or_404(db, run_id, project_id))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@legacy.get(
|
|
270
|
+
"/traces/{run_id}/diagnose",
|
|
271
|
+
deprecated=True,
|
|
272
|
+
description="Read-only compatibility alias for GET /v1/traces/{run_id}/diagnosis. "
|
|
273
|
+
"Available throughout 0.x; use POST /v1/traces/{run_id}/diagnosis to persist findings.",
|
|
274
|
+
)
|
|
275
|
+
@versioned.get("/traces/{run_id}/diagnosis")
|
|
276
|
+
@legacy.get("/traces/{run_id}/diagnosis")
|
|
277
|
+
def diagnose_trace(
|
|
278
|
+
run_id: str,
|
|
279
|
+
sort_by: RankingSort = RankingSort.PRIORITY,
|
|
280
|
+
project_id: str = Depends(resolve_project),
|
|
281
|
+
db: TraceStore = Depends(store),
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Compute current diagnosis without changing persisted findings."""
|
|
284
|
+
return build_diagnosis(_load_trace_or_404(db, run_id, project_id), sort_by=sort_by.value)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@versioned.post("/traces/{run_id}/diagnosis")
|
|
288
|
+
@legacy.post("/traces/{run_id}/diagnosis")
|
|
289
|
+
def persist_trace_diagnosis(
|
|
290
|
+
run_id: str,
|
|
291
|
+
sort_by: RankingSort = RankingSort.PRIORITY,
|
|
292
|
+
project_id: str = Depends(resolve_project),
|
|
293
|
+
db: TraceStore = Depends(store),
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""Recompute and persist findings, preserving existing lifecycle decisions."""
|
|
296
|
+
diagnosis = build_diagnosis(_load_trace_or_404(db, run_id, project_id), sort_by=sort_by.value)
|
|
297
|
+
db.save_diagnosis(diagnosis, project_id=project_id)
|
|
298
|
+
return diagnosis
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@versioned.post("/interventions")
|
|
302
|
+
@legacy.post("/interventions")
|
|
303
|
+
def create_intervention_endpoint(
|
|
304
|
+
payload: InterventionPayload,
|
|
305
|
+
project_id: str = Depends(resolve_project),
|
|
306
|
+
db: TraceStore = Depends(store),
|
|
307
|
+
) -> dict[str, Any]:
|
|
308
|
+
"""Snapshot persisted findings and compare stored traces in the authenticated project."""
|
|
309
|
+
try:
|
|
310
|
+
return create_stored_intervention(db, payload.model_dump(exclude_none=True), project_id)
|
|
311
|
+
except InterventionReferenceError as exc:
|
|
312
|
+
raise HTTPException(status_code=404, detail=str(exc)) from None
|
|
313
|
+
except InterventionConflictError as exc:
|
|
314
|
+
raise HTTPException(status_code=409, detail=str(exc)) from None
|
|
315
|
+
except (InterventionValidationError, QualityValidationError) as exc:
|
|
316
|
+
raise HTTPException(status_code=422, detail=str(exc)) from None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@versioned.get("/interventions/{intervention_id}")
|
|
320
|
+
@legacy.get("/interventions/{intervention_id}")
|
|
321
|
+
def get_intervention_endpoint(
|
|
322
|
+
intervention_id: str,
|
|
323
|
+
project_id: str = Depends(resolve_project),
|
|
324
|
+
db: TraceStore = Depends(store),
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
record = db.get_intervention(intervention_id, project_id=project_id)
|
|
327
|
+
if record is None:
|
|
328
|
+
raise HTTPException(status_code=404, detail="intervention not found")
|
|
329
|
+
return record
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@versioned.get("/findings")
|
|
333
|
+
@legacy.get("/findings")
|
|
334
|
+
def list_findings(
|
|
335
|
+
status: str | None = Query(default=None),
|
|
336
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
337
|
+
page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
|
|
338
|
+
cursor: str | None = Query(default=None),
|
|
339
|
+
project_id: str = Depends(resolve_project),
|
|
340
|
+
db: TraceStore = Depends(store),
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
"""List findings for a project. See `list_traces` for the pagination contract.
|
|
343
|
+
|
|
344
|
+
Paginated results are ordered by recency (most recently updated first),
|
|
345
|
+
not by the priority order `optimization-queue` uses.
|
|
346
|
+
"""
|
|
347
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
348
|
+
if page_size is None and cursor is None:
|
|
349
|
+
return {
|
|
350
|
+
"project_id": selected_project,
|
|
351
|
+
"findings": db.list_findings(project_id=selected_project, status=status),
|
|
352
|
+
"next_cursor": None,
|
|
353
|
+
}
|
|
354
|
+
try:
|
|
355
|
+
page = db.list_findings_page(
|
|
356
|
+
project_id=selected_project,
|
|
357
|
+
status=status,
|
|
358
|
+
limit=page_size or DEFAULT_PAGE_SIZE,
|
|
359
|
+
cursor=cursor,
|
|
360
|
+
)
|
|
361
|
+
except InvalidCursorError as exc:
|
|
362
|
+
raise HTTPException(status_code=400, detail=str(exc)) from None
|
|
363
|
+
return {
|
|
364
|
+
"project_id": selected_project,
|
|
365
|
+
"findings": page["items"],
|
|
366
|
+
"next_cursor": page["next_cursor"],
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class FindingStatusPayload(BaseModel):
|
|
371
|
+
status: str
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@versioned.post("/findings/{run_id}/{finding_id}/status")
|
|
375
|
+
@legacy.post("/findings/{run_id}/{finding_id}/status")
|
|
376
|
+
def update_finding_status(
|
|
377
|
+
run_id: str,
|
|
378
|
+
finding_id: str,
|
|
379
|
+
payload: FindingStatusPayload,
|
|
380
|
+
project_id: str = Depends(resolve_project),
|
|
381
|
+
db: TraceStore = Depends(store),
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
try:
|
|
384
|
+
return db.update_finding_status(project_id, run_id, finding_id, payload.status)
|
|
385
|
+
except FindingNotFoundError as exc:
|
|
386
|
+
raise HTTPException(status_code=404, detail=str(exc)) from None
|
|
387
|
+
except FindingTransitionError as exc:
|
|
388
|
+
raise HTTPException(status_code=409, detail=str(exc)) from None
|
|
389
|
+
except ValueError as exc:
|
|
390
|
+
raise HTTPException(status_code=400, detail=str(exc)) from None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@versioned.get("/optimization-queue")
|
|
394
|
+
@legacy.get("/optimization-queue")
|
|
395
|
+
def optimization_queue(
|
|
396
|
+
sort_by: RankingSort = RankingSort.PRIORITY,
|
|
397
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
398
|
+
project_id: str = Depends(resolve_project),
|
|
399
|
+
db: TraceStore = Depends(store),
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
402
|
+
return {
|
|
403
|
+
"project_id": selected_project,
|
|
404
|
+
"sort_by": sort_by.value,
|
|
405
|
+
"queue": sort_ranked(db.optimization_queue(project_id=selected_project), sort_by.value),
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@versioned.get("/optimization-queue/github-issues")
|
|
410
|
+
@legacy.get("/optimization-queue/github-issues")
|
|
411
|
+
def github_issue_drafts(
|
|
412
|
+
limit: int = Query(default=5, ge=1, le=20),
|
|
413
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
414
|
+
project_id: str = Depends(resolve_project),
|
|
415
|
+
db: TraceStore = Depends(store),
|
|
416
|
+
) -> dict[str, Any]:
|
|
417
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
418
|
+
queue = db.optimization_queue(project_id=selected_project)
|
|
419
|
+
return {
|
|
420
|
+
"project_id": selected_project,
|
|
421
|
+
"issue_drafts": build_issue_drafts(queue, limit=limit),
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@versioned.post("/quality-reports")
|
|
426
|
+
@legacy.post("/quality-report")
|
|
427
|
+
def quality_report_endpoint(
|
|
428
|
+
payload: QualityReportPayload,
|
|
429
|
+
project_id: str = Depends(resolve_project),
|
|
430
|
+
db: TraceStore = Depends(store),
|
|
431
|
+
) -> dict[str, Any]:
|
|
432
|
+
scorer_types = {
|
|
433
|
+
str(fixture["scorer"].get("type", "")).strip().lower()
|
|
434
|
+
for fixture in payload.fixtures
|
|
435
|
+
if isinstance(fixture.get("scorer"), dict)
|
|
436
|
+
}
|
|
437
|
+
if "custom" in scorer_types:
|
|
438
|
+
raise HTTPException(
|
|
439
|
+
status_code=400,
|
|
440
|
+
detail="custom Python scorers are not accepted by the HTTP API",
|
|
441
|
+
)
|
|
442
|
+
baseline_trace = (
|
|
443
|
+
_load_trace_or_404(db, payload.baseline_run_id, project_id)
|
|
444
|
+
if payload.baseline_run_id
|
|
445
|
+
else None
|
|
446
|
+
)
|
|
447
|
+
candidate_trace = (
|
|
448
|
+
_load_trace_or_404(db, payload.candidate_run_id, project_id)
|
|
449
|
+
if payload.candidate_run_id
|
|
450
|
+
else None
|
|
451
|
+
)
|
|
452
|
+
try:
|
|
453
|
+
return build_quality_report(
|
|
454
|
+
payload.fixtures,
|
|
455
|
+
baseline_trace=baseline_trace,
|
|
456
|
+
candidate_trace=candidate_trace,
|
|
457
|
+
min_score=payload.min_score,
|
|
458
|
+
)
|
|
459
|
+
except QualityValidationError as exc:
|
|
460
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@versioned.get("/traces/{run_id}/value")
|
|
464
|
+
@legacy.get("/traces/{run_id}/value")
|
|
465
|
+
def value_report(
|
|
466
|
+
run_id: str,
|
|
467
|
+
runs_per_month: int = Query(default=1000, ge=0),
|
|
468
|
+
engineer_hourly_rate_usd: float = Query(default=150.0, ge=0),
|
|
469
|
+
incident_cost_usd: float = Query(default=500.0, ge=0),
|
|
470
|
+
project_id: str = Depends(resolve_project),
|
|
471
|
+
db: TraceStore = Depends(store),
|
|
472
|
+
) -> dict[str, Any]:
|
|
473
|
+
trace = _load_trace_or_404(db, run_id, project_id)
|
|
474
|
+
return build_value_report(
|
|
475
|
+
trace,
|
|
476
|
+
runs_per_month=runs_per_month,
|
|
477
|
+
engineer_hourly_rate_usd=engineer_hourly_rate_usd,
|
|
478
|
+
incident_cost_usd=incident_cost_usd,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@versioned.get("/usage")
|
|
483
|
+
@legacy.get("/usage")
|
|
484
|
+
def usage_summary(
|
|
485
|
+
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
486
|
+
project_id: str = Depends(resolve_project),
|
|
487
|
+
db: TraceStore = Depends(store),
|
|
488
|
+
) -> dict[str, Any]:
|
|
489
|
+
selected_project = _selected_project(project_id_filter, project_id)
|
|
490
|
+
return db.usage_summary(project_id=selected_project)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
app.include_router(versioned, prefix="/v1")
|
|
494
|
+
app.include_router(legacy)
|