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.
Files changed (98) hide show
  1. agent_auditor/__init__.py +1 -0
  2. agent_auditor/__main__.py +3 -0
  3. agent_auditor/api/__init__.py +0 -0
  4. agent_auditor/api/app.py +108 -0
  5. agent_auditor/api/assistant_routes.py +118 -0
  6. agent_auditor/api/canary_routes.py +40 -0
  7. agent_auditor/api/canary_schemas.py +78 -0
  8. agent_auditor/api/deps.py +24 -0
  9. agent_auditor/api/drift_routes.py +53 -0
  10. agent_auditor/api/drift_schemas.py +69 -0
  11. agent_auditor/api/evals_routes.py +128 -0
  12. agent_auditor/api/evals_schemas.py +95 -0
  13. agent_auditor/api/fixes_routes.py +81 -0
  14. agent_auditor/api/insight_routes.py +76 -0
  15. agent_auditor/api/main.py +33 -0
  16. agent_auditor/api/regress_routes.py +82 -0
  17. agent_auditor/api/regress_schemas.py +102 -0
  18. agent_auditor/api/release_routes.py +96 -0
  19. agent_auditor/api/release_schemas.py +98 -0
  20. agent_auditor/api/rootcause_routes.py +45 -0
  21. agent_auditor/api/rootcause_schemas.py +75 -0
  22. agent_auditor/api/routes.py +195 -0
  23. agent_auditor/assistant.py +357 -0
  24. agent_auditor/audit.py +23 -0
  25. agent_auditor/canary/__init__.py +10 -0
  26. agent_auditor/canary/checks.py +233 -0
  27. agent_auditor/canary/decide.py +60 -0
  28. agent_auditor/canary/service.py +191 -0
  29. agent_auditor/cli.py +124 -0
  30. agent_auditor/config.py +43 -0
  31. agent_auditor/dashboard/app.css +236 -0
  32. agent_auditor/dashboard/index.html +18 -0
  33. agent_auditor/dashboard/js/00-core.js +206 -0
  34. agent_auditor/dashboard/js/10-drawer.js +78 -0
  35. agent_auditor/dashboard/js/20-overview.js +113 -0
  36. agent_auditor/dashboard/js/30-failures-runs.js +69 -0
  37. agent_auditor/dashboard/js/40-versions-quality.js +75 -0
  38. agent_auditor/dashboard/js/50-diagnose.js +114 -0
  39. agent_auditor/dashboard/js/60-ship.js +80 -0
  40. agent_auditor/dashboard/js/70-improve.js +89 -0
  41. agent_auditor/dashboard/js/80-ops.js +87 -0
  42. agent_auditor/dashboard/js/90-main.js +171 -0
  43. agent_auditor/data/dashboard_guide.md +81 -0
  44. agent_auditor/data/feature_matrix.json +45 -0
  45. agent_auditor/db.py +24 -0
  46. agent_auditor/demo.py +131 -0
  47. agent_auditor/drift/__init__.py +8 -0
  48. agent_auditor/drift/detect.py +223 -0
  49. agent_auditor/drift/service.py +168 -0
  50. agent_auditor/drift/signals.py +72 -0
  51. agent_auditor/drift/stats.py +144 -0
  52. agent_auditor/evals/__init__.py +16 -0
  53. agent_auditor/evals/compare.py +126 -0
  54. agent_auditor/evals/expect.py +79 -0
  55. agent_auditor/evals/scorers.py +296 -0
  56. agent_auditor/evals/service.py +241 -0
  57. agent_auditor/evals/stats.py +43 -0
  58. agent_auditor/evals/types.py +54 -0
  59. agent_auditor/fixes/__init__.py +4 -0
  60. agent_auditor/fixes/judge.py +74 -0
  61. agent_auditor/fixes/propose.py +139 -0
  62. agent_auditor/insights/__init__.py +1 -0
  63. agent_auditor/insights/bench.py +199 -0
  64. agent_auditor/insights/gap.py +43 -0
  65. agent_auditor/insights/service.py +302 -0
  66. agent_auditor/models.py +293 -0
  67. agent_auditor/regress/__init__.py +15 -0
  68. agent_auditor/regress/expectations.py +162 -0
  69. agent_auditor/regress/fingerprint.py +146 -0
  70. agent_auditor/regress/service.py +446 -0
  71. agent_auditor/release/__init__.py +9 -0
  72. agent_auditor/release/gate.py +173 -0
  73. agent_auditor/release/service.py +276 -0
  74. agent_auditor/rootcause/__init__.py +8 -0
  75. agent_auditor/rootcause/benchmark.py +96 -0
  76. agent_auditor/rootcause/change.py +111 -0
  77. agent_auditor/rootcause/classify.py +255 -0
  78. agent_auditor/rootcause/errors.py +83 -0
  79. agent_auditor/rootcause/service.py +202 -0
  80. agent_auditor/rootcause/sources.py +81 -0
  81. agent_auditor/rootcause/summary.py +99 -0
  82. agent_auditor/rootcause/synthetic.py +239 -0
  83. agent_auditor/schemas.py +123 -0
  84. agent_auditor/sdk/__init__.py +5 -0
  85. agent_auditor/sdk/_mapper.py +105 -0
  86. agent_auditor/sdk/canary.py +123 -0
  87. agent_auditor/sdk/fixes.py +109 -0
  88. agent_auditor/sdk/langgraph.py +178 -0
  89. agent_auditor/sdk/serialize.py +71 -0
  90. agent_auditor/sdk/tracer.py +993 -0
  91. agent_auditor/versioning.py +13 -0
  92. agent_auditor/worker.py +93 -0
  93. agent_auditor-0.13.0.dist-info/METADATA +281 -0
  94. agent_auditor-0.13.0.dist-info/RECORD +98 -0
  95. agent_auditor-0.13.0.dist-info/WHEEL +5 -0
  96. agent_auditor-0.13.0.dist-info/entry_points.txt +3 -0
  97. agent_auditor-0.13.0.dist-info/licenses/LICENSE +21 -0
  98. agent_auditor-0.13.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.13.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
File without changes
@@ -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
+ )