agent-auditor 0.13.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.
- agent_auditor/__init__.py +1 -0
- agent_auditor/__main__.py +3 -0
- agent_auditor/api/__init__.py +0 -0
- agent_auditor/api/app.py +108 -0
- agent_auditor/api/assistant_routes.py +118 -0
- agent_auditor/api/canary_routes.py +40 -0
- agent_auditor/api/canary_schemas.py +78 -0
- agent_auditor/api/deps.py +24 -0
- agent_auditor/api/drift_routes.py +53 -0
- agent_auditor/api/drift_schemas.py +69 -0
- agent_auditor/api/evals_routes.py +128 -0
- agent_auditor/api/evals_schemas.py +95 -0
- agent_auditor/api/fixes_routes.py +81 -0
- agent_auditor/api/insight_routes.py +76 -0
- agent_auditor/api/main.py +33 -0
- agent_auditor/api/regress_routes.py +82 -0
- agent_auditor/api/regress_schemas.py +102 -0
- agent_auditor/api/release_routes.py +96 -0
- agent_auditor/api/release_schemas.py +98 -0
- agent_auditor/api/rootcause_routes.py +45 -0
- agent_auditor/api/rootcause_schemas.py +75 -0
- agent_auditor/api/routes.py +195 -0
- agent_auditor/assistant.py +357 -0
- agent_auditor/audit.py +23 -0
- agent_auditor/canary/__init__.py +10 -0
- agent_auditor/canary/checks.py +233 -0
- agent_auditor/canary/decide.py +60 -0
- agent_auditor/canary/service.py +191 -0
- agent_auditor/cli.py +124 -0
- agent_auditor/config.py +43 -0
- agent_auditor/dashboard/app.css +236 -0
- agent_auditor/dashboard/index.html +18 -0
- agent_auditor/dashboard/js/00-core.js +206 -0
- agent_auditor/dashboard/js/10-drawer.js +78 -0
- agent_auditor/dashboard/js/20-overview.js +113 -0
- agent_auditor/dashboard/js/30-failures-runs.js +69 -0
- agent_auditor/dashboard/js/40-versions-quality.js +75 -0
- agent_auditor/dashboard/js/50-diagnose.js +114 -0
- agent_auditor/dashboard/js/60-ship.js +80 -0
- agent_auditor/dashboard/js/70-improve.js +89 -0
- agent_auditor/dashboard/js/80-ops.js +87 -0
- agent_auditor/dashboard/js/90-main.js +171 -0
- agent_auditor/data/dashboard_guide.md +81 -0
- agent_auditor/data/feature_matrix.json +45 -0
- agent_auditor/db.py +24 -0
- agent_auditor/demo.py +131 -0
- agent_auditor/drift/__init__.py +8 -0
- agent_auditor/drift/detect.py +223 -0
- agent_auditor/drift/service.py +168 -0
- agent_auditor/drift/signals.py +72 -0
- agent_auditor/drift/stats.py +144 -0
- agent_auditor/evals/__init__.py +16 -0
- agent_auditor/evals/compare.py +126 -0
- agent_auditor/evals/expect.py +79 -0
- agent_auditor/evals/scorers.py +296 -0
- agent_auditor/evals/service.py +241 -0
- agent_auditor/evals/stats.py +43 -0
- agent_auditor/evals/types.py +54 -0
- agent_auditor/fixes/__init__.py +4 -0
- agent_auditor/fixes/judge.py +74 -0
- agent_auditor/fixes/propose.py +139 -0
- agent_auditor/insights/__init__.py +1 -0
- agent_auditor/insights/bench.py +199 -0
- agent_auditor/insights/gap.py +43 -0
- agent_auditor/insights/service.py +302 -0
- agent_auditor/models.py +293 -0
- agent_auditor/regress/__init__.py +15 -0
- agent_auditor/regress/expectations.py +162 -0
- agent_auditor/regress/fingerprint.py +146 -0
- agent_auditor/regress/service.py +446 -0
- agent_auditor/release/__init__.py +9 -0
- agent_auditor/release/gate.py +173 -0
- agent_auditor/release/service.py +276 -0
- agent_auditor/rootcause/__init__.py +8 -0
- agent_auditor/rootcause/benchmark.py +96 -0
- agent_auditor/rootcause/change.py +111 -0
- agent_auditor/rootcause/classify.py +255 -0
- agent_auditor/rootcause/errors.py +83 -0
- agent_auditor/rootcause/service.py +202 -0
- agent_auditor/rootcause/sources.py +81 -0
- agent_auditor/rootcause/summary.py +99 -0
- agent_auditor/rootcause/synthetic.py +239 -0
- agent_auditor/schemas.py +123 -0
- agent_auditor/sdk/__init__.py +5 -0
- agent_auditor/sdk/_mapper.py +105 -0
- agent_auditor/sdk/canary.py +123 -0
- agent_auditor/sdk/fixes.py +109 -0
- agent_auditor/sdk/langgraph.py +178 -0
- agent_auditor/sdk/serialize.py +71 -0
- agent_auditor/sdk/tracer.py +993 -0
- agent_auditor/versioning.py +13 -0
- agent_auditor/worker.py +93 -0
- agent_auditor-0.13.0.dist-info/METADATA +281 -0
- agent_auditor-0.13.0.dist-info/RECORD +98 -0
- agent_auditor-0.13.0.dist-info/WHEEL +5 -0
- agent_auditor-0.13.0.dist-info/entry_points.txt +3 -0
- agent_auditor-0.13.0.dist-info/licenses/LICENSE +21 -0
- agent_auditor-0.13.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.13.0"
|
|
File without changes
|
agent_auditor/api/app.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
9
|
+
from sqlalchemy import text
|
|
10
|
+
|
|
11
|
+
from ..config import get_settings
|
|
12
|
+
from ..db import Base, make_engine, make_session_factory
|
|
13
|
+
from .. import models # noqa: F401 (register tables on Base.metadata)
|
|
14
|
+
from .routes import router
|
|
15
|
+
from .evals_routes import router as evals_router
|
|
16
|
+
from .drift_routes import router as drift_router
|
|
17
|
+
from .regress_routes import router as regress_router
|
|
18
|
+
from .rootcause_routes import router as rootcause_router
|
|
19
|
+
from .canary_routes import router as canary_router
|
|
20
|
+
from .release_routes import router as release_router
|
|
21
|
+
from .assistant_routes import router as assistant_router
|
|
22
|
+
from .fixes_routes import router as fixes_router
|
|
23
|
+
from .insight_routes import feedback_router, router as insight_router
|
|
24
|
+
from ..assistant import RateLimiter
|
|
25
|
+
|
|
26
|
+
_DASH_DIR = Path(__file__).resolve().parent.parent / "dashboard"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _dashboard_page() -> str:
|
|
30
|
+
"""The page is stored as a shell, one stylesheet and several script parts (kept apart so each is easy to edit); join them here."""
|
|
31
|
+
shell = (_DASH_DIR / "index.html").read_text(encoding="utf-8")
|
|
32
|
+
css = (_DASH_DIR / "app.css").read_text(encoding="utf-8")
|
|
33
|
+
js = "\n".join(p.read_text(encoding="utf-8") for p in sorted((_DASH_DIR / "js").glob("*.js")))
|
|
34
|
+
return shell.replace("/*__CSS__*/", css).replace("/*__JS__*/", js)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_app(database_url: str | None = None, api_key: str | None = None,
|
|
38
|
+
anthropic_api_key: str | None = None) -> FastAPI:
|
|
39
|
+
settings = get_settings()
|
|
40
|
+
if database_url is not None:
|
|
41
|
+
settings = replace(settings, database_url=database_url)
|
|
42
|
+
if api_key is not None:
|
|
43
|
+
settings = replace(settings, api_key=api_key)
|
|
44
|
+
|
|
45
|
+
if anthropic_api_key is not None:
|
|
46
|
+
settings = replace(settings, anthropic_api_key=anthropic_api_key)
|
|
47
|
+
|
|
48
|
+
engine = make_engine(settings.database_url)
|
|
49
|
+
if settings.auto_create_tables: # with Alembic (Docker Compose) set AUDITOR_AUTO_CREATE_TABLES=false
|
|
50
|
+
Base.metadata.create_all(engine)
|
|
51
|
+
|
|
52
|
+
app = FastAPI(title="Agent Auditor", version="0.13.0")
|
|
53
|
+
app.state.settings = settings
|
|
54
|
+
app.state.engine = engine
|
|
55
|
+
app.state.session_factory = make_session_factory(engine)
|
|
56
|
+
app.state.assistant_transport = None # tests inject an httpx transport here
|
|
57
|
+
app.state.rate_limiter = RateLimiter()
|
|
58
|
+
app.state.assistant_dead = {} # AI services that failed recently, skipped for a few minutes
|
|
59
|
+
|
|
60
|
+
@app.get("/", include_in_schema=False)
|
|
61
|
+
def root() -> RedirectResponse:
|
|
62
|
+
return RedirectResponse(url="/docs")
|
|
63
|
+
|
|
64
|
+
@app.get("/healthz", include_in_schema=False)
|
|
65
|
+
def healthz() -> dict:
|
|
66
|
+
"""Liveness: the process is up. No database access, no authentication."""
|
|
67
|
+
return {"status": "ok", "version": app.version}
|
|
68
|
+
|
|
69
|
+
@app.get("/readyz", include_in_schema=False)
|
|
70
|
+
def readyz():
|
|
71
|
+
"""Readiness: the database answers. Used by Docker Compose and load balancers."""
|
|
72
|
+
try:
|
|
73
|
+
with engine.connect() as conn:
|
|
74
|
+
conn.execute(text("SELECT 1"))
|
|
75
|
+
except Exception: # noqa: BLE001 - report not-ready instead of crashing
|
|
76
|
+
return JSONResponse({"status": "database unavailable"}, status_code=503)
|
|
77
|
+
return {"status": "ok"}
|
|
78
|
+
|
|
79
|
+
@app.get("/dashboard", include_in_schema=False)
|
|
80
|
+
def dashboard() -> HTMLResponse:
|
|
81
|
+
"""Read-only dashboard. The page itself is public; every data call it makes
|
|
82
|
+
carries the API key the person types in, so it exposes nothing by itself."""
|
|
83
|
+
nonce = secrets.token_urlsafe(16)
|
|
84
|
+
page = _dashboard_page().replace("__NONCE__", nonce)
|
|
85
|
+
csp = (
|
|
86
|
+
"default-src 'none'; "
|
|
87
|
+
f"script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; "
|
|
88
|
+
"connect-src 'self'; img-src 'self' data:; "
|
|
89
|
+
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
90
|
+
)
|
|
91
|
+
return HTMLResponse(page, headers={
|
|
92
|
+
"Content-Security-Policy": csp,
|
|
93
|
+
"Cache-Control": "no-store",
|
|
94
|
+
"X-Content-Type-Options": "nosniff",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
app.include_router(router)
|
|
98
|
+
app.include_router(evals_router)
|
|
99
|
+
app.include_router(drift_router)
|
|
100
|
+
app.include_router(regress_router)
|
|
101
|
+
app.include_router(rootcause_router)
|
|
102
|
+
app.include_router(canary_router)
|
|
103
|
+
app.include_router(release_router)
|
|
104
|
+
app.include_router(assistant_router)
|
|
105
|
+
app.include_router(fixes_router)
|
|
106
|
+
app.include_router(insight_router)
|
|
107
|
+
app.include_router(feedback_router)
|
|
108
|
+
return app
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from .. import assistant
|
|
11
|
+
from ..insights import service as insights
|
|
12
|
+
from ..models import Agent, AgentVersion
|
|
13
|
+
from ..rootcause import service as rootcause
|
|
14
|
+
from .deps import get_db, require_api_key
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(require_api_key)])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StatusOut(BaseModel):
|
|
20
|
+
enabled: bool # an AI service is available (the built-in guide always is)
|
|
21
|
+
model: str | None
|
|
22
|
+
providers: list[str] = Field(default_factory=list)
|
|
23
|
+
guide: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Turn(BaseModel):
|
|
27
|
+
role: Literal["user", "assistant"]
|
|
28
|
+
content: str = Field(max_length=8000)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AskIn(BaseModel):
|
|
32
|
+
question: str = Field(min_length=1, max_length=assistant.MAX_QUESTION_CHARS)
|
|
33
|
+
context: dict[str, Any] = Field(default_factory=dict, description="What the dashboard is showing right now")
|
|
34
|
+
history: list[Turn] = Field(default_factory=list, max_length=20)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AnswerOut(BaseModel):
|
|
38
|
+
answer: str
|
|
39
|
+
source: str = "guide" # the AI service that wrote it ("Gemini"), or "guide" for the built-in guide
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SuggestFixIn(BaseModel):
|
|
43
|
+
environment: Literal["production", "canary", "test"] = "production"
|
|
44
|
+
since_hours: float | None = Field(default=None, gt=0, le=24 * 365)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FixOut(BaseModel):
|
|
48
|
+
suggestion: str
|
|
49
|
+
version: str | None
|
|
50
|
+
failed_runs: int
|
|
51
|
+
note: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _call(request: Request, system: str, messages: list[dict], max_tokens: int) -> str:
|
|
55
|
+
s = request.app.state.settings
|
|
56
|
+
try:
|
|
57
|
+
request.app.state.rate_limiter.check()
|
|
58
|
+
return assistant.ask_any(system=system, messages=messages, max_tokens=max_tokens, anthropic_key=s.anthropic_api_key,
|
|
59
|
+
anthropic_model=s.assistant_model, transport=request.app.state.assistant_transport,
|
|
60
|
+
dead=request.app.state.assistant_dead)[0]
|
|
61
|
+
except assistant.RateLimited as exc:
|
|
62
|
+
raise HTTPException(429, str(exc)) from exc
|
|
63
|
+
except assistant.AssistantUnavailable as exc:
|
|
64
|
+
raise HTTPException(503, "an AI key is needed for this: add GEMINI_API_KEY (free) or another key to .env and restart") from exc
|
|
65
|
+
except assistant.AssistantError as exc:
|
|
66
|
+
raise HTTPException(502, str(exc)) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/assistant/status", response_model=StatusOut)
|
|
70
|
+
def status(request: Request) -> StatusOut:
|
|
71
|
+
s = request.app.state.settings
|
|
72
|
+
names = assistant.configured_providers(anthropic_key=s.anthropic_api_key)
|
|
73
|
+
return StatusOut(enabled=bool(names), model=s.assistant_model if "anthropic" in names else None, providers=names)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/assistant/ask", response_model=AnswerOut)
|
|
77
|
+
def ask(body: AskIn, request: Request, db: Session = Depends(get_db)) -> AnswerOut:
|
|
78
|
+
context = dict(body.context)
|
|
79
|
+
agent = context.get("agent")
|
|
80
|
+
if isinstance(agent, str) and agent:
|
|
81
|
+
try: # live numbers for the agent on screen, so "why are there so many failures?" can be answered
|
|
82
|
+
context["live_overview"] = insights.overview(db, agent, 168)
|
|
83
|
+
except Exception: # noqa: BLE001 - the answer is still useful without them
|
|
84
|
+
pass
|
|
85
|
+
msgs = assistant.build_messages(body.question, context, [t.model_dump() for t in body.history])
|
|
86
|
+
s = request.app.state.settings
|
|
87
|
+
system = assistant.SYSTEM + "\n\nGuide to every page of the dashboard:\n" + assistant.guide_text()
|
|
88
|
+
try:
|
|
89
|
+
request.app.state.rate_limiter.check()
|
|
90
|
+
except assistant.RateLimited as exc:
|
|
91
|
+
raise HTTPException(429, str(exc)) from exc
|
|
92
|
+
try:
|
|
93
|
+
text, source = assistant.ask_any(system=system, messages=msgs, anthropic_key=s.anthropic_api_key, anthropic_model=s.assistant_model,
|
|
94
|
+
transport=request.app.state.assistant_transport, dead=request.app.state.assistant_dead)
|
|
95
|
+
return AnswerOut(answer=text, source=source)
|
|
96
|
+
except assistant.AssistantUnavailable:
|
|
97
|
+
return AnswerOut(answer=assistant.answer_from_guide(body.question), source="guide")
|
|
98
|
+
except assistant.AssistantError as exc:
|
|
99
|
+
note = "\n\n(The AI services did not answer, so this comes from the built-in guide. " + str(exc)[:300] + ")"
|
|
100
|
+
return AnswerOut(answer=assistant.answer_from_guide(body.question) + note, source="guide")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post("/agents/{name}/suggest-fix", response_model=FixOut)
|
|
104
|
+
def suggest_fix(name: str, body: SuggestFixIn, request: Request, db: Session = Depends(get_db)) -> FixOut:
|
|
105
|
+
agent = db.scalar(select(Agent).where(Agent.name == name))
|
|
106
|
+
if agent is None:
|
|
107
|
+
raise HTTPException(404, f"agent '{name}' not found")
|
|
108
|
+
try:
|
|
109
|
+
report = rootcause.root_cause_report(db, agent=name, environment=body.environment, since_hours=body.since_hours,
|
|
110
|
+
diagnose_first=False)
|
|
111
|
+
except LookupError as exc:
|
|
112
|
+
raise HTTPException(404, str(exc)) from exc
|
|
113
|
+
active = db.scalar(select(AgentVersion).where(AgentVersion.agent_id == agent.id, AgentVersion.is_active.is_(True)))
|
|
114
|
+
msgs = assistant.fix_messages(active.label if active else None, active.prompt if active else None,
|
|
115
|
+
active.config if active else None, report)
|
|
116
|
+
text = _call(request, assistant.FIX_SYSTEM, msgs, 900)
|
|
117
|
+
return FixOut(suggestion=text, version=active.label if active else None, failed_runs=int(report.get("failed_runs") or 0),
|
|
118
|
+
note="A draft written by an AI model. Register it as a new version and run it through a canary before release.")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from ..canary import service
|
|
7
|
+
from .canary_schemas import CanaryEvaluateIn, CanaryReportOut, CanarySummaryOut
|
|
8
|
+
from .deps import get_db, require_api_key
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(require_api_key)])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _wrap(call):
|
|
14
|
+
try:
|
|
15
|
+
return call()
|
|
16
|
+
except LookupError as exc:
|
|
17
|
+
raise HTTPException(404, str(exc)) from exc
|
|
18
|
+
except ValueError as exc:
|
|
19
|
+
raise HTTPException(422, str(exc)) from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/agents/{name}/canary/evaluate", response_model=CanaryReportOut)
|
|
23
|
+
def evaluate(name: str, body: CanaryEvaluateIn, db: Session = Depends(get_db)) -> dict:
|
|
24
|
+
payload = body.model_dump()
|
|
25
|
+
data = _wrap(lambda: service.canary_report(db, agent=name, **payload))
|
|
26
|
+
db.commit()
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/agents/{name}/canary", response_model=list[CanarySummaryOut])
|
|
31
|
+
def list_reports(
|
|
32
|
+
name: str, candidate: str | None = None, limit: int = Query(default=20, ge=1, le=100),
|
|
33
|
+
db: Session = Depends(get_db),
|
|
34
|
+
) -> list[dict]:
|
|
35
|
+
return _wrap(lambda: service.list_reports(db, name, limit, candidate))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/canary/{report_id}", response_model=CanaryReportOut)
|
|
39
|
+
def get_report(report_id: str, db: Session = Depends(get_db)) -> dict:
|
|
40
|
+
return _wrap(lambda: service.get_report(db, report_id))
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
Environment = Literal["production", "canary", "test"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CanaryPolicyIn(BaseModel):
|
|
12
|
+
min_canary_runs: int = Field(default=30, ge=1, le=1_000_000)
|
|
13
|
+
min_baseline_runs: int = Field(default=30, ge=1, le=1_000_000)
|
|
14
|
+
min_eval_samples: int = Field(default=20, ge=1, le=1_000_000)
|
|
15
|
+
min_effect: float = Field(default=0.05, ge=0, le=1)
|
|
16
|
+
rollback_effect: float = Field(default=0.10, ge=0, le=1)
|
|
17
|
+
alpha: float = Field(default=0.01, gt=0, lt=1)
|
|
18
|
+
latency_warn_ratio: float = Field(default=1.3, ge=1)
|
|
19
|
+
latency_fail_ratio: float = Field(default=2.0, ge=1)
|
|
20
|
+
latency_rollback_ratio: float = Field(default=4.0, ge=1)
|
|
21
|
+
require_replay: bool = False
|
|
22
|
+
replay_rollback_min: int = Field(default=2, ge=1)
|
|
23
|
+
replay_rollback_share: float = Field(default=0.10, ge=0, le=1)
|
|
24
|
+
warnings_block: bool = False
|
|
25
|
+
include_drift: bool = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CanaryEvaluateIn(BaseModel):
|
|
29
|
+
baseline: str = Field(description="Version label running in production")
|
|
30
|
+
candidate: str = Field(description="Version label being canaried")
|
|
31
|
+
baseline_environment: Environment = "production"
|
|
32
|
+
candidate_environment: Environment = "canary"
|
|
33
|
+
since_hours: float = Field(default=24.0, gt=0, le=24 * 365)
|
|
34
|
+
policy: CanaryPolicyIn = Field(default_factory=CanaryPolicyIn)
|
|
35
|
+
evaluate_first: bool = Field(default=True, description="Score unscored runs on both sides first")
|
|
36
|
+
diagnose_first: bool = Field(default=True, description="Diagnose undiagnosed runs on both sides first")
|
|
37
|
+
save: bool = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CheckOut(BaseModel):
|
|
41
|
+
name: str
|
|
42
|
+
status: Literal["pass", "warn", "fail", "skipped"]
|
|
43
|
+
severity: Literal["none", "minor", "major"]
|
|
44
|
+
reason: str
|
|
45
|
+
details: dict[str, Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SideOut(BaseModel):
|
|
49
|
+
version: str
|
|
50
|
+
environment: str
|
|
51
|
+
runs: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CanaryReportOut(BaseModel):
|
|
55
|
+
id: str | None = None
|
|
56
|
+
created_at: datetime | None = None
|
|
57
|
+
agent: str
|
|
58
|
+
baseline: SideOut
|
|
59
|
+
candidate: SideOut
|
|
60
|
+
window: dict[str, Any]
|
|
61
|
+
verdict: Literal["promote", "hold", "rollback", "continue"]
|
|
62
|
+
reasons: list[str]
|
|
63
|
+
blocking: list[str]
|
|
64
|
+
warnings: list[str]
|
|
65
|
+
checks: list[CheckOut]
|
|
66
|
+
policy: dict[str, Any]
|
|
67
|
+
caveat: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CanarySummaryOut(BaseModel):
|
|
71
|
+
id: str
|
|
72
|
+
created_at: datetime
|
|
73
|
+
agent: str
|
|
74
|
+
baseline: str
|
|
75
|
+
candidate: str
|
|
76
|
+
verdict: str
|
|
77
|
+
blocking: list[str]
|
|
78
|
+
reasons: list[str]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException, Request, Security
|
|
7
|
+
from fastapi.security import APIKeyHeader
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_db(request: Request) -> Iterator[Session]:
|
|
12
|
+
factory = request.app.state.session_factory
|
|
13
|
+
with factory() as session:
|
|
14
|
+
yield session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Declared as a security scheme so the Swagger page (/docs) shows one Authorize button.
|
|
18
|
+
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_api_key(request: Request, api_key: str | None = Security(_api_key_header)) -> None:
|
|
22
|
+
expected = request.app.state.settings.api_key
|
|
23
|
+
if expected and not hmac.compare_digest((api_key or "").encode(), expected.encode()):
|
|
24
|
+
raise HTTPException(status_code=401, detail="invalid or missing API key")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from ..drift import service
|
|
7
|
+
from ..models import DriftReport
|
|
8
|
+
from .deps import get_db, require_api_key
|
|
9
|
+
from .drift_schemas import DriftCheckIn, DriftReportOut, DriftReportSummary
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(require_api_key)])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _full(row: DriftReport) -> DriftReportOut:
|
|
15
|
+
return DriftReportOut(id=row.id, created_at=row.created_at, **row.result)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("/drift/check", response_model=DriftReportOut)
|
|
19
|
+
def check_drift(body: DriftCheckIn, db: Session = Depends(get_db)) -> DriftReportOut:
|
|
20
|
+
try:
|
|
21
|
+
data, row = service.run_drift_check(db, **body.model_dump())
|
|
22
|
+
except LookupError as exc:
|
|
23
|
+
raise HTTPException(404, str(exc)) from exc
|
|
24
|
+
except ValueError as exc:
|
|
25
|
+
raise HTTPException(422, str(exc)) from exc
|
|
26
|
+
db.commit()
|
|
27
|
+
if row is None:
|
|
28
|
+
return DriftReportOut(**data)
|
|
29
|
+
return _full(row)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/agents/{name}/drift", response_model=list[DriftReportSummary])
|
|
33
|
+
def list_drift_reports(name: str, limit: int = Query(default=10, ge=1, le=100), db: Session = Depends(get_db)) -> list[DriftReportSummary]:
|
|
34
|
+
try:
|
|
35
|
+
rows = service.list_reports(db, name, limit)
|
|
36
|
+
except LookupError as exc:
|
|
37
|
+
raise HTTPException(404, str(exc)) from exc
|
|
38
|
+
return [
|
|
39
|
+
DriftReportSummary(
|
|
40
|
+
id=r.id, created_at=r.created_at, as_of=r.as_of, environment=r.environment, version=r.version_label,
|
|
41
|
+
overall=r.overall, reference_runs=r.result.get("reference_runs", 0), current_runs=r.result.get("current_runs", 0),
|
|
42
|
+
flagged=[f"{s['name']}:{s['severity']}" for s in r.result.get("signals", []) if s["severity"] in ("medium", "high")],
|
|
43
|
+
)
|
|
44
|
+
for r in rows
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/drift/reports/{report_id}", response_model=DriftReportOut)
|
|
49
|
+
def get_drift_report(report_id: str, db: Session = Depends(get_db)) -> DriftReportOut:
|
|
50
|
+
row = db.get(DriftReport, report_id)
|
|
51
|
+
if row is None:
|
|
52
|
+
raise HTTPException(404, "drift report not found")
|
|
53
|
+
return _full(row)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DriftCheckIn(BaseModel):
|
|
10
|
+
agent: str
|
|
11
|
+
environment: Literal["production", "canary", "test"] = "production"
|
|
12
|
+
version: str | None = Field(default=None, description="Only look at runs of this version label")
|
|
13
|
+
current_hours: float = Field(default=24.0, gt=0, le=24 * 365, description="Length of the recent window")
|
|
14
|
+
reference_hours: float = Field(default=168.0, gt=0, le=24 * 365 * 5, description="Length of the baseline window that ends where the recent one starts")
|
|
15
|
+
as_of: datetime | None = Field(default=None, description="End of the recent window; defaults to now")
|
|
16
|
+
min_samples: int = Field(default=30, ge=5, le=100_000, description="Minimum observations per window for a signal to be tested")
|
|
17
|
+
alpha: float = Field(default=0.01, gt=0, lt=1, description="Family-wise false alarm rate, split across signals")
|
|
18
|
+
correct_multiple: bool = True
|
|
19
|
+
include_evals: bool = Field(default=True, description="Also compare eval pass rates (needs runs scored by /v1/evals/run)")
|
|
20
|
+
max_runs: int = Field(default=2000, ge=50, le=5000, description="Newest runs analysed per window")
|
|
21
|
+
save: bool = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DriftSignalOut(BaseModel):
|
|
25
|
+
name: str
|
|
26
|
+
kind: str
|
|
27
|
+
severity: str
|
|
28
|
+
significant: bool
|
|
29
|
+
p_value: float | None
|
|
30
|
+
alpha_used: float
|
|
31
|
+
effect_name: str
|
|
32
|
+
effect: float | None
|
|
33
|
+
reference_n: int
|
|
34
|
+
current_n: int
|
|
35
|
+
reference: dict[str, Any]
|
|
36
|
+
current: dict[str, Any]
|
|
37
|
+
reason: str
|
|
38
|
+
details: dict[str, Any]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DriftReportOut(BaseModel):
|
|
42
|
+
id: str | None = None
|
|
43
|
+
created_at: datetime | None = None
|
|
44
|
+
agent: str
|
|
45
|
+
environment: str
|
|
46
|
+
version: str | None
|
|
47
|
+
as_of: datetime
|
|
48
|
+
windows: dict[str, datetime]
|
|
49
|
+
overall: str
|
|
50
|
+
reference_runs: int
|
|
51
|
+
current_runs: int
|
|
52
|
+
reference_total_runs: int
|
|
53
|
+
current_total_runs: int
|
|
54
|
+
alpha: float
|
|
55
|
+
min_samples: int
|
|
56
|
+
notes: list[str]
|
|
57
|
+
signals: list[DriftSignalOut]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DriftReportSummary(BaseModel):
|
|
61
|
+
id: str
|
|
62
|
+
created_at: datetime
|
|
63
|
+
as_of: datetime
|
|
64
|
+
environment: str
|
|
65
|
+
version: str | None
|
|
66
|
+
overall: str
|
|
67
|
+
reference_runs: int
|
|
68
|
+
current_runs: int
|
|
69
|
+
flagged: list[str]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from ..evals import compare_counts, get_scorers, wilson_interval
|
|
12
|
+
from ..evals import service
|
|
13
|
+
from ..models import EvalResult, Run
|
|
14
|
+
from .deps import get_db, require_api_key
|
|
15
|
+
from .evals_schemas import (
|
|
16
|
+
CompareOut, EvalBatchOut, EvalResultOut, EvalRunIn, ScorerComparisonOut, ScorerInfo, ScorerStat,
|
|
17
|
+
SummaryOut, VersionSummary,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(require_api_key)])
|
|
21
|
+
|
|
22
|
+
Environment = Literal["production", "canary", "test"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _utc(value: datetime | None) -> datetime | None:
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/evals/scorers", response_model=list[ScorerInfo])
|
|
32
|
+
def list_scorers() -> list[ScorerInfo]:
|
|
33
|
+
return [
|
|
34
|
+
ScorerInfo(name=s.name, description=s.description, defaults=s.defaults, default_enabled=s.default_enabled)
|
|
35
|
+
for s in get_scorers().values()
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.post("/evals/run", response_model=EvalBatchOut)
|
|
40
|
+
def run_evals(body: EvalRunIn, db: Session = Depends(get_db)) -> EvalBatchOut:
|
|
41
|
+
if body.scorers:
|
|
42
|
+
unknown = [n for n in body.scorers if n not in get_scorers()]
|
|
43
|
+
if unknown:
|
|
44
|
+
raise HTTPException(422, f"unknown scorers: {', '.join(unknown)}")
|
|
45
|
+
try:
|
|
46
|
+
batch = service.evaluate_runs(
|
|
47
|
+
db, agent=body.agent, version=body.version, environment=body.environment, status=body.status,
|
|
48
|
+
limit=body.limit, only_unevaluated=body.only_unevaluated, scorers=body.scorers, params=body.params,
|
|
49
|
+
)
|
|
50
|
+
except LookupError as exc:
|
|
51
|
+
raise HTTPException(404, str(exc)) from exc
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise HTTPException(422, str(exc)) from exc
|
|
54
|
+
db.commit()
|
|
55
|
+
return EvalBatchOut(**asdict(batch))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/runs/{run_id}/evals", response_model=list[EvalResultOut])
|
|
59
|
+
def get_run_evals(run_id: str, db: Session = Depends(get_db)) -> list[EvalResultOut]:
|
|
60
|
+
if db.get(Run, run_id) is None:
|
|
61
|
+
raise HTTPException(404, "run not found")
|
|
62
|
+
rows = db.scalars(select(EvalResult).where(EvalResult.run_id == run_id).order_by(EvalResult.scorer)).all()
|
|
63
|
+
return [
|
|
64
|
+
EvalResultOut(
|
|
65
|
+
scorer=r.scorer, applicable=r.applicable, score=r.score, passed=r.passed, reason=r.reason,
|
|
66
|
+
details=r.details, evaluated_at=r.evaluated_at,
|
|
67
|
+
)
|
|
68
|
+
for r in rows
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.get("/agents/{name}/eval-summary", response_model=SummaryOut)
|
|
73
|
+
def eval_summary(
|
|
74
|
+
name: str,
|
|
75
|
+
environment: Environment | None = None,
|
|
76
|
+
since: datetime | None = None,
|
|
77
|
+
db: Session = Depends(get_db),
|
|
78
|
+
) -> SummaryOut:
|
|
79
|
+
try:
|
|
80
|
+
agent = service.get_agent(db, name)
|
|
81
|
+
except LookupError as exc:
|
|
82
|
+
raise HTTPException(404, str(exc)) from exc
|
|
83
|
+
data = service.agent_summary(db, agent, environment=environment, since=_utc(since))
|
|
84
|
+
versions = []
|
|
85
|
+
for label in sorted(data, key=lambda v: (v == service.UNVERSIONED, v)):
|
|
86
|
+
entry = data[label]
|
|
87
|
+
stats = []
|
|
88
|
+
for scorer, c in sorted(entry["scorers"].items()):
|
|
89
|
+
lo, hi = wilson_interval(c.passed, c.n)
|
|
90
|
+
stats.append(ScorerStat(
|
|
91
|
+
scorer=scorer, n=c.n, passed=c.passed, pass_rate=(c.passed / c.n) if c.n else None,
|
|
92
|
+
ci_low=lo if c.n else None, ci_high=hi if c.n else None, mean_score=c.mean_score,
|
|
93
|
+
))
|
|
94
|
+
versions.append(VersionSummary(version=label, runs=entry["runs"], evaluated_runs=entry["evaluated_runs"], scorers=stats))
|
|
95
|
+
return SummaryOut(agent=agent.name, versions=versions)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("/agents/{name}/compare", response_model=CompareOut)
|
|
99
|
+
def compare_versions(
|
|
100
|
+
name: str,
|
|
101
|
+
baseline: str = Query(description="Version label of the known-good version"),
|
|
102
|
+
candidate: str = Query(description="Version label under test"),
|
|
103
|
+
baseline_environment: Environment | None = None,
|
|
104
|
+
candidate_environment: Environment | None = None,
|
|
105
|
+
since: datetime | None = None,
|
|
106
|
+
min_samples: int = Query(default=20, ge=1),
|
|
107
|
+
min_effect: float = Query(default=0.05, ge=0.0, le=1.0),
|
|
108
|
+
alpha: float = Query(default=0.05, gt=0.0, lt=1.0),
|
|
109
|
+
correct_multiple: bool = True,
|
|
110
|
+
db: Session = Depends(get_db),
|
|
111
|
+
) -> CompareOut:
|
|
112
|
+
if baseline == candidate and baseline_environment == candidate_environment:
|
|
113
|
+
raise HTTPException(422, "compare two different versions, or the same version in different environments")
|
|
114
|
+
try:
|
|
115
|
+
agent = service.get_agent(db, name)
|
|
116
|
+
since_utc = _utc(since)
|
|
117
|
+
base = service.version_counts(db, agent, baseline, environment=baseline_environment, since=since_utc)
|
|
118
|
+
cand = service.version_counts(db, agent, candidate, environment=candidate_environment, since=since_utc)
|
|
119
|
+
except LookupError as exc:
|
|
120
|
+
raise HTTPException(404, str(exc)) from exc
|
|
121
|
+
result = compare_counts(
|
|
122
|
+
base, cand, min_samples=min_samples, min_effect=min_effect, alpha=alpha, correct_multiple=correct_multiple
|
|
123
|
+
)
|
|
124
|
+
return CompareOut(
|
|
125
|
+
agent=agent.name, baseline=baseline, candidate=candidate, overall_verdict=result.overall_verdict,
|
|
126
|
+
alpha=result.alpha, min_samples=result.min_samples, min_effect=result.min_effect, notes=result.notes,
|
|
127
|
+
scorers=[ScorerComparisonOut(**asdict(row)) for row in result.scorers],
|
|
128
|
+
)
|