provekit 0.2.0__tar.gz → 0.6.0__tar.gz
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.
- {provekit-0.2.0 → provekit-0.6.0}/PKG-INFO +24 -1
- provekit-0.6.0/provekit/__init__.py +18 -0
- provekit-0.6.0/provekit/auto.py +11 -0
- provekit-0.6.0/provekit/config.py +68 -0
- provekit-0.6.0/provekit/demo.py +118 -0
- provekit-0.6.0/provekit/eval.py +74 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/main.py +9 -1
- provekit-0.6.0/provekit/mcp.py +83 -0
- provekit-0.6.0/provekit/migrations/versions/a1b2c3d4e5f6_feedback_sessions.py +49 -0
- provekit-0.6.0/provekit/migrations/versions/b2c3d4e5f6a7_datasets.py +54 -0
- provekit-0.6.0/provekit/migrations/versions/c3d4e5f6a7b8_experiments.py +59 -0
- provekit-0.6.0/provekit/migrations/versions/d4e5f6a7b8c9_alerts.py +40 -0
- provekit-0.6.0/provekit/migrations/versions/e5f6a7b8c9d0_admin.py +30 -0
- provekit-0.6.0/provekit/models.py +188 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/observability.py +2 -2
- provekit-0.6.0/provekit/routers/admin.py +82 -0
- provekit-0.6.0/provekit/routers/alerts.py +124 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/routers/auth.py +3 -1
- provekit-0.6.0/provekit/routers/datasets.py +151 -0
- provekit-0.6.0/provekit/routers/experiments.py +141 -0
- provekit-0.6.0/provekit/routers/metrics.py +143 -0
- provekit-0.6.0/provekit/routers/projects.py +140 -0
- provekit-0.6.0/provekit/routers/traces.py +275 -0
- provekit-0.6.0/provekit/scorers.py +63 -0
- provekit-0.6.0/provekit/services/limits.py +73 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/services/otel.py +25 -0
- provekit-0.6.0/provekit/services/redact.py +48 -0
- provekit-0.6.0/provekit/services/sealing.py +49 -0
- provekit-0.6.0/provekit/services/share.py +54 -0
- provekit-0.6.0/provekit/services/workspace.py +61 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/trace.py +152 -12
- {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/PKG-INFO +24 -1
- {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/SOURCES.txt +34 -3
- provekit-0.6.0/provekit.egg-info/entry_points.txt +3 -0
- provekit-0.6.0/provekit.egg-info/requires.txt +52 -0
- {provekit-0.2.0 → provekit-0.6.0}/pyproject.toml +44 -7
- provekit-0.6.0/tests/test_admin.py +72 -0
- provekit-0.6.0/tests/test_alerts.py +55 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_auth.py +4 -2
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_cov_limits_auth.py +13 -30
- provekit-0.6.0/tests/test_coverage_extra.py +295 -0
- provekit-0.6.0/tests/test_datasets.py +65 -0
- provekit-0.6.0/tests/test_eval.py +73 -0
- provekit-0.6.0/tests/test_experiments.py +51 -0
- provekit-0.6.0/tests/test_feedback.py +65 -0
- provekit-0.6.0/tests/test_hardening.py +49 -0
- provekit-0.6.0/tests/test_key_read_api.py +81 -0
- provekit-0.6.0/tests/test_mcp.py +61 -0
- provekit-0.6.0/tests/test_metrics.py +74 -0
- provekit-0.6.0/tests/test_projects.py +79 -0
- provekit-0.6.0/tests/test_redact.py +56 -0
- provekit-0.6.0/tests/test_scorers.py +32 -0
- provekit-0.6.0/tests/test_share.py +58 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_trace_sdk.py +78 -0
- provekit-0.2.0/provekit/config.py +0 -77
- provekit-0.2.0/provekit/models.py +0 -100
- provekit-0.2.0/provekit/routers/traces.py +0 -125
- provekit-0.2.0/provekit/services/__init__.py +0 -0
- provekit-0.2.0/provekit/services/limits.py +0 -109
- provekit-0.2.0/provekit/services/masking.py +0 -45
- provekit-0.2.0/provekit/services/runstore.py +0 -93
- provekit-0.2.0/provekit/services/sealing.py +0 -106
- provekit-0.2.0/provekit/services/workspace.py +0 -27
- provekit-0.2.0/provekit.egg-info/requires.txt +0 -26
- provekit-0.2.0/tests/test_runstore.py +0 -64
- {provekit-0.2.0 → provekit-0.6.0}/README.md +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/database.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/migrations/env.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/migrations/script.py.mako +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/migrations/versions/9d2c06bfa4e6_baseline.py +0 -0
- {provekit-0.2.0/provekit → provekit-0.6.0/provekit/routers}/__init__.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/routers/apikeys.py +0 -0
- {provekit-0.2.0/provekit/routers → provekit-0.6.0/provekit/services}/__init__.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/services/apikey.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/services/auth.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/services/deploy.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/services/email.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit/services/netguard.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/dependency_links.txt +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/provekit.egg-info/top_level.txt +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/setup.cfg +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_account_recovery.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_apikeys.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_auth_hardening.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_autoinstrument.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_netguard.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_observability.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_otel.py +0 -0
- {provekit-0.2.0 → provekit-0.6.0}/tests/test_traces_tree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: provekit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Drop-in agent tracing — add one decorator, get a project key, review every run your agent makes.
|
|
5
5
|
Author: ProveKit contributors
|
|
6
6
|
License: MIT
|
|
@@ -38,6 +38,29 @@ Requires-Dist: opentelemetry-api<2,>=1.20; extra == "trace"
|
|
|
38
38
|
Requires-Dist: opentelemetry-sdk<2,>=1.20; extra == "trace"
|
|
39
39
|
Requires-Dist: openinference-instrumentation-openai>=0.1; extra == "trace"
|
|
40
40
|
Requires-Dist: openinference-instrumentation-anthropic>=0.1; extra == "trace"
|
|
41
|
+
Provides-Extra: http
|
|
42
|
+
Requires-Dist: opentelemetry-instrumentation-httpx>=0.40b0; extra == "http"
|
|
43
|
+
Requires-Dist: opentelemetry-instrumentation-requests>=0.40b0; extra == "http"
|
|
44
|
+
Requires-Dist: opentelemetry-instrumentation-urllib>=0.40b0; extra == "http"
|
|
45
|
+
Provides-Extra: trace-all
|
|
46
|
+
Requires-Dist: opentelemetry-api<2,>=1.20; extra == "trace-all"
|
|
47
|
+
Requires-Dist: opentelemetry-sdk<2,>=1.20; extra == "trace-all"
|
|
48
|
+
Requires-Dist: openinference-instrumentation-openai>=0.1; extra == "trace-all"
|
|
49
|
+
Requires-Dist: openinference-instrumentation-anthropic>=0.1; extra == "trace-all"
|
|
50
|
+
Requires-Dist: openinference-instrumentation-langchain>=0.1; extra == "trace-all"
|
|
51
|
+
Requires-Dist: openinference-instrumentation-llama-index>=0.1; extra == "trace-all"
|
|
52
|
+
Requires-Dist: openinference-instrumentation-crewai>=0.1; extra == "trace-all"
|
|
53
|
+
Requires-Dist: openinference-instrumentation-bedrock>=0.1; extra == "trace-all"
|
|
54
|
+
Requires-Dist: openinference-instrumentation-litellm>=0.1; extra == "trace-all"
|
|
55
|
+
Requires-Dist: openinference-instrumentation-groq>=0.1; extra == "trace-all"
|
|
56
|
+
Requires-Dist: openinference-instrumentation-mistralai>=0.1; extra == "trace-all"
|
|
57
|
+
Requires-Dist: openinference-instrumentation-dspy>=0.1; extra == "trace-all"
|
|
58
|
+
Requires-Dist: openinference-instrumentation-haystack>=0.1; extra == "trace-all"
|
|
59
|
+
Requires-Dist: opentelemetry-instrumentation-httpx>=0.40b0; extra == "trace-all"
|
|
60
|
+
Requires-Dist: opentelemetry-instrumentation-requests>=0.40b0; extra == "trace-all"
|
|
61
|
+
Requires-Dist: opentelemetry-instrumentation-urllib>=0.40b0; extra == "trace-all"
|
|
62
|
+
Provides-Extra: mcp
|
|
63
|
+
Requires-Dist: mcp>=1.2; extra == "mcp"
|
|
41
64
|
Provides-Extra: dev
|
|
42
65
|
Requires-Dist: pytest==9.1.1; extra == "dev"
|
|
43
66
|
Requires-Dist: pytest-cov==6.0.0; extra == "dev"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""ProveKit — drop-in agent tracing. One SDK on the client captures the whole flow.
|
|
2
|
+
|
|
3
|
+
import provekit.trace as pk # the canonical import
|
|
4
|
+
pk.init() # or, zero-code: import provekit.auto
|
|
5
|
+
|
|
6
|
+
@pk.trace(name="my-agent") # optional: group one run under a named root
|
|
7
|
+
def run(q): ...
|
|
8
|
+
|
|
9
|
+
Everything beneath your code — LLM providers, agent frameworks, outbound HTTP — is
|
|
10
|
+
captured automatically. `init`, `configure`, and `span` are also re-exported here for
|
|
11
|
+
`import provekit as pk` convenience; the `trace` decorator lives on the `provekit.trace`
|
|
12
|
+
submodule (a same-named top-level export would shadow that module).
|
|
13
|
+
"""
|
|
14
|
+
from provekit import scorers
|
|
15
|
+
from provekit.eval import evaluate
|
|
16
|
+
from provekit.trace import configure, init, span
|
|
17
|
+
|
|
18
|
+
__all__ = ["init", "configure", "span", "evaluate", "scorers"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Zero-code activation: `import provekit.auto` turns tracing on with no other calls.
|
|
2
|
+
|
|
3
|
+
import provekit.auto # reads PROVEKIT_API_KEY / PROVEKIT_ENDPOINT and instruments
|
|
4
|
+
|
|
5
|
+
Put this once at your program's entrypoint (before the libraries you want traced do their
|
|
6
|
+
work). Everything instrumented below it is then captured automatically. Fail-open: if the
|
|
7
|
+
env vars are unset or OpenTelemetry isn't installed, this is a silent no-op.
|
|
8
|
+
"""
|
|
9
|
+
from provekit.trace import init
|
|
10
|
+
|
|
11
|
+
init()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""ProveKit configuration."""
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings(BaseSettings):
|
|
8
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
9
|
+
|
|
10
|
+
database_url: str = "sqlite:///./provekit.db"
|
|
11
|
+
cors_origins: str = "http://localhost:3001,http://localhost:3000"
|
|
12
|
+
|
|
13
|
+
# Hosted mode: a login wall, secure cookies, and stricter outbound-URL guarding.
|
|
14
|
+
hosted: bool = False
|
|
15
|
+
|
|
16
|
+
# Signs session/reset/verify tokens (any string → a Fernet key). Optional for local
|
|
17
|
+
# SQLite (a key file is auto-generated); required for any other database.
|
|
18
|
+
secret_key: str = ""
|
|
19
|
+
|
|
20
|
+
# Optional OTLP/HTTP collector to mirror captured runs to another backend.
|
|
21
|
+
otel_export_url: str = ""
|
|
22
|
+
|
|
23
|
+
# Optional Sentry DSN for error reporting (off when unset).
|
|
24
|
+
sentry_dsn: str = ""
|
|
25
|
+
|
|
26
|
+
# Web app base URL (for links in emails).
|
|
27
|
+
web_base_url: str = "http://localhost:3001"
|
|
28
|
+
|
|
29
|
+
# Email (account recovery / verification). No SMTP host → links are logged, not sent.
|
|
30
|
+
smtp_host: str = ""
|
|
31
|
+
smtp_port: int = 587
|
|
32
|
+
smtp_user: str = ""
|
|
33
|
+
smtp_password: str = ""
|
|
34
|
+
smtp_from: str = ""
|
|
35
|
+
smtp_starttls: bool = True
|
|
36
|
+
require_email_verification: bool = False
|
|
37
|
+
|
|
38
|
+
# Optional Redis (shared rate-limit windows across workers). Unset → in-memory.
|
|
39
|
+
redis_url: str = ""
|
|
40
|
+
|
|
41
|
+
thread_pool_size: int = 200
|
|
42
|
+
|
|
43
|
+
# Mask PII (emails, cards, SSNs, phones, common secret keys) in captured span
|
|
44
|
+
# input/output/error before it's stored. Off by default; a safety net for hosted use.
|
|
45
|
+
redact_pii: bool = False
|
|
46
|
+
|
|
47
|
+
# Bootstrap platform operators: any account whose email is in this comma-list is treated
|
|
48
|
+
# as a superuser (admin console), even before the DB flag is set. e.g. "you@co.com".
|
|
49
|
+
superuser_emails: str = ""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def superuser_email_list(self) -> list[str]:
|
|
53
|
+
return [e.strip().lower() for e in self.superuser_emails.split(",") if e.strip()]
|
|
54
|
+
|
|
55
|
+
# Quotas (protect shared infra). 0 disables a given limit.
|
|
56
|
+
ingest_rate_per_min: int = 600 # trace-ingest requests per project per minute
|
|
57
|
+
login_attempts_per_min: int = 10 # login attempts per email+IP per minute
|
|
58
|
+
runs_retention: int = 10000 # keep last N spans per project (older ones pruned)
|
|
59
|
+
max_body_bytes: int = 2_000_000 # reject request bodies larger than this
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def cors_origin_list(self) -> list[str]:
|
|
63
|
+
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@lru_cache
|
|
67
|
+
def get_settings() -> Settings:
|
|
68
|
+
return Settings()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""`provekit-demo` — a zero-setup smoke test.
|
|
2
|
+
|
|
3
|
+
Sends a small gallery of traces to your portal so a fresh install + project key can be
|
|
4
|
+
verified in ~10 seconds. No LLM key needed: the model is mocked, but recorded as a real
|
|
5
|
+
gen_ai LLM span (model, tokens, temperature) so the portal renders it as a proper chat call.
|
|
6
|
+
|
|
7
|
+
pip install "provekit[trace]"
|
|
8
|
+
export PROVEKIT_API_KEY=pk_... # a project key from the portal
|
|
9
|
+
export PROVEKIT_ENDPOINT=https://your-provekit-host
|
|
10
|
+
provekit-demo
|
|
11
|
+
|
|
12
|
+
For richer, framework-native examples (LangGraph, a deep multi-agent orchestrator) see the
|
|
13
|
+
examples/ folder: https://github.com/MobirizerServices/ProveKit/tree/main/examples
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from . import trace as pk
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("provekit-demo")
|
|
25
|
+
|
|
26
|
+
_KB = {
|
|
27
|
+
"refund": "I've started your refund — it lands in 3–5 business days.",
|
|
28
|
+
"vpn": "Forget the VPN profile and re-add it; that clears the stale auth token.",
|
|
29
|
+
"pricing": "The Pro plan is $49/seat/mo; annual billing saves ~15%.",
|
|
30
|
+
"export": "Settings → Data → Export builds a downloadable JSON of your workspace.",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _mock_llm(span, model: str, prompt: str, temperature: float = 0.7) -> str:
|
|
35
|
+
"""A deterministic, offline stand-in for an LLM — recorded as a real gen_ai LLM span."""
|
|
36
|
+
key = next((k for k in _KB if k in prompt.lower()), None)
|
|
37
|
+
answer = _KB.get(key, "Thanks for reaching out — a teammate will follow up shortly.")
|
|
38
|
+
if span:
|
|
39
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
40
|
+
span.set_attribute("gen_ai.request.temperature", temperature)
|
|
41
|
+
span.set_attribute("gen_ai.input.messages", prompt)
|
|
42
|
+
span.set_attribute("gen_ai.usage.input_tokens", 40 + len(prompt) // 4)
|
|
43
|
+
span.set_attribute("gen_ai.usage.output_tokens", 12 + len(answer) // 4)
|
|
44
|
+
span.set_attribute("gen_ai.output.messages", answer)
|
|
45
|
+
span.set_attribute("gen_ai.response.finish_reasons", "stop")
|
|
46
|
+
time.sleep(0.01)
|
|
47
|
+
return answer
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pk.trace(name="support-agent")
|
|
51
|
+
def _support_agent(question: str, model: str = "gpt-4o") -> str:
|
|
52
|
+
with pk.span("retrieve") as s:
|
|
53
|
+
if s:
|
|
54
|
+
s.set_attribute("gen_ai.tool.name", "kb_search")
|
|
55
|
+
s.set_attribute("gen_ai.input.messages", question)
|
|
56
|
+
log.info("retrieved 3 candidate docs")
|
|
57
|
+
with pk.span("chat") as s:
|
|
58
|
+
answer = _mock_llm(s, model, question)
|
|
59
|
+
pk.score("helpfulness", score=0.9, comment="demo auto-score")
|
|
60
|
+
return answer
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pk.trace(name="support-agent", session_id="conv-demo-1")
|
|
64
|
+
def _support_turn(question: str) -> str:
|
|
65
|
+
with pk.span("chat") as s:
|
|
66
|
+
return _mock_llm(s, "gpt-4o-mini", question)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pk.trace(name="flaky-agent")
|
|
70
|
+
def _flaky_agent(question: str) -> str:
|
|
71
|
+
with pk.span("call-upstream") as s:
|
|
72
|
+
if s:
|
|
73
|
+
s.set_attribute("gen_ai.tool.name", "billing_api")
|
|
74
|
+
raise RuntimeError("upstream billing_api returned 503")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv=None) -> None:
|
|
78
|
+
p = argparse.ArgumentParser(
|
|
79
|
+
prog="provekit-demo",
|
|
80
|
+
description="Send a small gallery of demo traces to your ProveKit portal (no LLM key needed).")
|
|
81
|
+
p.add_argument("--api-key", default=os.environ.get("PROVEKIT_API_KEY"),
|
|
82
|
+
help="project key (default: $PROVEKIT_API_KEY)")
|
|
83
|
+
p.add_argument("--endpoint", default=os.environ.get("PROVEKIT_ENDPOINT"),
|
|
84
|
+
help="portal URL (default: $PROVEKIT_ENDPOINT)")
|
|
85
|
+
args = p.parse_args(argv)
|
|
86
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
87
|
+
|
|
88
|
+
if not (args.api_key and args.endpoint):
|
|
89
|
+
raise SystemExit("Set PROVEKIT_API_KEY and PROVEKIT_ENDPOINT (or pass --api-key / --endpoint).\n"
|
|
90
|
+
"Grab a key from your portal → Project keys.")
|
|
91
|
+
if not pk.configure(api_key=args.api_key, endpoint=args.endpoint):
|
|
92
|
+
raise SystemExit("ProveKit could not start — check your key/endpoint.")
|
|
93
|
+
|
|
94
|
+
for q, model in (("I want a refund for my order", "gpt-4o"),
|
|
95
|
+
("VPN won't connect after the update", "gpt-4o"),
|
|
96
|
+
("What's your pricing for a 20-person team?", "claude-sonnet-5"),
|
|
97
|
+
("How do I export my data?", "gpt-4o-mini")):
|
|
98
|
+
print("→", _support_agent(q, model=model)[:60])
|
|
99
|
+
|
|
100
|
+
# a two-turn conversation (same session) so sessions light up
|
|
101
|
+
_support_turn("Is the Pro plan monthly?")
|
|
102
|
+
_support_turn("And does annual billing save money?")
|
|
103
|
+
|
|
104
|
+
# a failed run so the dashboard error-rate + a red trace show up
|
|
105
|
+
try:
|
|
106
|
+
_flaky_agent("charge dispute")
|
|
107
|
+
except RuntimeError as exc:
|
|
108
|
+
print("✗ flaky-agent failed as designed:", exc)
|
|
109
|
+
|
|
110
|
+
from opentelemetry import trace as _t
|
|
111
|
+
provider = _t.get_tracer_provider()
|
|
112
|
+
if hasattr(provider, "force_flush"):
|
|
113
|
+
provider.force_flush()
|
|
114
|
+
print("\nDone — open your ProveKit portal:", args.endpoint.rstrip("/") + "/traces")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Offline evaluation from the client: run a target over a dataset, score each output, and
|
|
2
|
+
record it as an experiment in your portal — the basis of a CI regression gate.
|
|
3
|
+
|
|
4
|
+
import provekit as pk
|
|
5
|
+
|
|
6
|
+
def target(question: str) -> str:
|
|
7
|
+
return my_agent(question)
|
|
8
|
+
|
|
9
|
+
summary = pk.evaluate("qa-golden", target, scorers=["exact_match", "contains"])
|
|
10
|
+
assert summary["mean_score"] >= 0.8 # fail the build on a regression
|
|
11
|
+
|
|
12
|
+
`scorers` are names from provekit.scorers (exact_match/contains/regex_match/json_valid) or
|
|
13
|
+
your own `fn(output, expected) -> float`. The dataset is a name or id from your portal.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from . import scorers as _scorers
|
|
22
|
+
from . import trace as _trace
|
|
23
|
+
|
|
24
|
+
_TIMEOUT = 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get(endpoint, headers, path):
|
|
28
|
+
r = httpx.get(f"{endpoint}{path}", headers=headers, timeout=_TIMEOUT, follow_redirects=False)
|
|
29
|
+
r.raise_for_status()
|
|
30
|
+
return r.json()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _post(endpoint, headers, path, body):
|
|
34
|
+
r = httpx.post(f"{endpoint}{path}", headers=headers, json=body, timeout=_TIMEOUT, follow_redirects=False)
|
|
35
|
+
r.raise_for_status()
|
|
36
|
+
return r.json()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_dataset_id(endpoint, headers, dataset) -> int:
|
|
40
|
+
if isinstance(dataset, int):
|
|
41
|
+
return dataset
|
|
42
|
+
for d in _get(endpoint, headers, "/v1/datasets"):
|
|
43
|
+
if d.get("name") == dataset:
|
|
44
|
+
return d["id"]
|
|
45
|
+
raise ValueError(f"dataset {dataset!r} not found")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def evaluate(dataset, target, scorers, *, name: str = "experiment") -> dict:
|
|
49
|
+
"""Run `target(input)` over every item in `dataset`, score each output with `scorers`,
|
|
50
|
+
post the results as a new experiment, and return its summary (id, result_count,
|
|
51
|
+
scorer_means, mean_score). Raises if ProveKit isn't configured."""
|
|
52
|
+
if not _trace.configure():
|
|
53
|
+
raise RuntimeError("ProveKit not configured: set PROVEKIT_API_KEY and PROVEKIT_ENDPOINT")
|
|
54
|
+
endpoint, headers = _trace._endpoint, {"Authorization": f"Bearer {_trace._api_key}"}
|
|
55
|
+
|
|
56
|
+
dsid = _resolve_dataset_id(endpoint, headers, dataset)
|
|
57
|
+
items = _get(endpoint, headers, f"/v1/datasets/{dsid}/items")
|
|
58
|
+
exp = _post(endpoint, headers, "/v1/experiments", {"name": name, "dataset_id": dsid})
|
|
59
|
+
eid = exp["id"]
|
|
60
|
+
|
|
61
|
+
for it in items:
|
|
62
|
+
try:
|
|
63
|
+
output = target(it["input"])
|
|
64
|
+
except Exception as exc: # a target failure is a data point, not a crash
|
|
65
|
+
output = f"ERROR: {exc}"
|
|
66
|
+
if not isinstance(output, str):
|
|
67
|
+
output = json.dumps(output, default=str)
|
|
68
|
+
expected = it.get("expected", "")
|
|
69
|
+
scores = _scorers.run_scorers(scorers, output, expected)
|
|
70
|
+
_post(endpoint, headers, f"/v1/experiments/{eid}/results",
|
|
71
|
+
{"item_id": it.get("id"), "input": it["input"], "output": output,
|
|
72
|
+
"expected": expected, "scores": scores})
|
|
73
|
+
|
|
74
|
+
return _get(endpoint, headers, f"/v1/experiments/{eid}")
|
|
@@ -16,7 +16,7 @@ from .observability import (
|
|
|
16
16
|
init_sentry,
|
|
17
17
|
setup_logging,
|
|
18
18
|
)
|
|
19
|
-
from .routers import apikeys, auth, traces
|
|
19
|
+
from .routers import admin, alerts, apikeys, auth, datasets, experiments, metrics, projects, traces
|
|
20
20
|
|
|
21
21
|
logging.basicConfig(level=logging.INFO)
|
|
22
22
|
settings = get_settings()
|
|
@@ -81,6 +81,14 @@ app.include_router(apikeys.router)
|
|
|
81
81
|
app.include_router(traces.router)
|
|
82
82
|
app.include_router(traces.ws_router)
|
|
83
83
|
app.include_router(traces.runs_router)
|
|
84
|
+
app.include_router(datasets.router)
|
|
85
|
+
app.include_router(datasets.key_router)
|
|
86
|
+
app.include_router(experiments.router)
|
|
87
|
+
app.include_router(experiments.key_router)
|
|
88
|
+
app.include_router(metrics.router)
|
|
89
|
+
app.include_router(alerts.router)
|
|
90
|
+
app.include_router(projects.router)
|
|
91
|
+
app.include_router(admin.router)
|
|
84
92
|
|
|
85
93
|
|
|
86
94
|
@app.get("/")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""ProveKit MCP server — debug your traces from any MCP client (Claude Desktop, Cursor, …).
|
|
2
|
+
|
|
3
|
+
This is a *debug channel*, not a second client SDK: your agent still installs only the one
|
|
4
|
+
`provekit` tracing SDK. This server runs wherever you like (your laptop, CI) and reads the
|
|
5
|
+
already-captured traces from your portal over HTTP, authenticated by the same project key.
|
|
6
|
+
|
|
7
|
+
pip install "provekit[mcp]"
|
|
8
|
+
export PROVEKIT_API_KEY=pk_... # your project key
|
|
9
|
+
export PROVEKIT_ENDPOINT=https://provekit.your-co.com
|
|
10
|
+
provekit-mcp # speaks MCP over stdio
|
|
11
|
+
|
|
12
|
+
Then point an MCP client at the `provekit-mcp` command. It exposes tools to list recent
|
|
13
|
+
traces, surface failures, and pull a full span tree — so the assistant can reason over an
|
|
14
|
+
agent run and tell you what broke, with no code added to the traced app.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
_TIMEOUT = 15
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _base() -> tuple[str, dict]:
|
|
26
|
+
"""Resolve the portal URL + auth header from the environment, or raise a clear error."""
|
|
27
|
+
key = os.environ.get("PROVEKIT_API_KEY")
|
|
28
|
+
endpoint = os.environ.get("PROVEKIT_ENDPOINT")
|
|
29
|
+
if not key or not endpoint:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"Set PROVEKIT_API_KEY (your pk_ project key) and PROVEKIT_ENDPOINT "
|
|
32
|
+
"(your portal URL) before starting the ProveKit MCP server."
|
|
33
|
+
)
|
|
34
|
+
return endpoint.rstrip("/"), {"Authorization": f"Bearer {key}"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get(path: str, **params):
|
|
38
|
+
url, headers = _base()
|
|
39
|
+
r = httpx.get(f"{url}/v1{path}", headers=headers, params=params, timeout=_TIMEOUT,
|
|
40
|
+
follow_redirects=False)
|
|
41
|
+
r.raise_for_status()
|
|
42
|
+
return r.json()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---- the callable data layer (import-safe; used by the MCP tools and by tests) ----
|
|
46
|
+
def list_traces(limit: int = 20, status: str | None = None) -> list[dict]:
|
|
47
|
+
"""Recent traces, newest first. Pass status='failed' to see only failed runs."""
|
|
48
|
+
return _get("/traces", limit=limit, **({"status": status} if status else {}))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def list_failures(limit: int = 20) -> list[dict]:
|
|
52
|
+
"""Just the traces that ended in a failure — the ones worth debugging first."""
|
|
53
|
+
return list_traces(limit=limit, status="failed")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_trace(trace_id: str) -> list[dict]:
|
|
57
|
+
"""Every span of one trace (the full nested flow), in start order."""
|
|
58
|
+
return _get(f"/traces/{trace_id}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_server(): # pragma: no cover - needs the optional `mcp` dep; glue over the tested data layer
|
|
62
|
+
"""Construct the FastMCP server with the trace tools registered. Imported lazily so the
|
|
63
|
+
module stays importable (and testable) without the optional `mcp` dependency. The three
|
|
64
|
+
data-layer functions above are registered directly as MCP tools."""
|
|
65
|
+
from mcp.server.fastmcp import FastMCP
|
|
66
|
+
|
|
67
|
+
server = FastMCP("provekit")
|
|
68
|
+
server.add_tool(list_traces, name="provekit_list_traces",
|
|
69
|
+
description="List recent ProveKit traces (agent runs). status='failed' filters to failures.")
|
|
70
|
+
server.add_tool(list_failures, name="provekit_list_failures",
|
|
71
|
+
description="List recent traces that ended in a failure — start debugging here.")
|
|
72
|
+
server.add_tool(get_trace, name="provekit_get_trace",
|
|
73
|
+
description="Fetch the full span tree of one trace by trace_id to inspect what happened.")
|
|
74
|
+
return server
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> None: # pragma: no cover - blocks serving MCP over stdio; exercised by hand
|
|
78
|
+
"""Console-script entrypoint: `provekit-mcp`. Speaks MCP over stdio."""
|
|
79
|
+
build_server().run()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__": # pragma: no cover
|
|
83
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""feedback table + runs.session_id
|
|
2
|
+
|
|
3
|
+
Revision ID: a1b2c3d4e5f6
|
|
4
|
+
Revises: 9d2c06bfa4e6
|
|
5
|
+
Create Date: 2026-07-19 00:00:00.000000
|
|
6
|
+
"""
|
|
7
|
+
from alembic import op
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
revision = 'a1b2c3d4e5f6'
|
|
12
|
+
down_revision = '9d2c06bfa4e6'
|
|
13
|
+
branch_labels = None
|
|
14
|
+
depends_on = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade() -> None:
|
|
18
|
+
op.create_table('feedback',
|
|
19
|
+
sa.Column('id', sa.Integer(), nullable=False),
|
|
20
|
+
sa.Column('workspace_id', sa.Integer(), nullable=True),
|
|
21
|
+
sa.Column('trace_id', sa.String(length=32), nullable=False),
|
|
22
|
+
sa.Column('name', sa.String(length=120), nullable=False),
|
|
23
|
+
sa.Column('score', sa.Float(), nullable=True),
|
|
24
|
+
sa.Column('value', sa.String(length=200), nullable=False),
|
|
25
|
+
sa.Column('comment', sa.Text(), nullable=False),
|
|
26
|
+
sa.Column('source', sa.String(length=16), nullable=False),
|
|
27
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
28
|
+
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_feedback_workspace_id_workspaces')),
|
|
29
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_feedback'))
|
|
30
|
+
)
|
|
31
|
+
with op.batch_alter_table('feedback', schema=None) as batch_op:
|
|
32
|
+
batch_op.create_index(batch_op.f('ix_feedback_trace_id'), ['trace_id'], unique=False)
|
|
33
|
+
batch_op.create_index(batch_op.f('ix_feedback_workspace_id'), ['workspace_id'], unique=False)
|
|
34
|
+
|
|
35
|
+
with op.batch_alter_table('runs', schema=None) as batch_op:
|
|
36
|
+
batch_op.add_column(sa.Column('session_id', sa.String(length=64), nullable=False,
|
|
37
|
+
server_default=''))
|
|
38
|
+
batch_op.create_index(batch_op.f('ix_runs_session_id'), ['session_id'], unique=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def downgrade() -> None:
|
|
42
|
+
with op.batch_alter_table('runs', schema=None) as batch_op:
|
|
43
|
+
batch_op.drop_index(batch_op.f('ix_runs_session_id'))
|
|
44
|
+
batch_op.drop_column('session_id')
|
|
45
|
+
|
|
46
|
+
with op.batch_alter_table('feedback', schema=None) as batch_op:
|
|
47
|
+
batch_op.drop_index(batch_op.f('ix_feedback_workspace_id'))
|
|
48
|
+
batch_op.drop_index(batch_op.f('ix_feedback_trace_id'))
|
|
49
|
+
op.drop_table('feedback')
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""datasets + dataset_items
|
|
2
|
+
|
|
3
|
+
Revision ID: b2c3d4e5f6a7
|
|
4
|
+
Revises: a1b2c3d4e5f6
|
|
5
|
+
Create Date: 2026-07-19 01:00:00.000000
|
|
6
|
+
"""
|
|
7
|
+
from alembic import op
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
revision = 'b2c3d4e5f6a7'
|
|
12
|
+
down_revision = 'a1b2c3d4e5f6'
|
|
13
|
+
branch_labels = None
|
|
14
|
+
depends_on = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade() -> None:
|
|
18
|
+
op.create_table('datasets',
|
|
19
|
+
sa.Column('id', sa.Integer(), nullable=False),
|
|
20
|
+
sa.Column('workspace_id', sa.Integer(), nullable=True),
|
|
21
|
+
sa.Column('name', sa.String(length=160), nullable=False),
|
|
22
|
+
sa.Column('description', sa.Text(), nullable=False),
|
|
23
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
24
|
+
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_datasets_workspace_id_workspaces')),
|
|
25
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_datasets'))
|
|
26
|
+
)
|
|
27
|
+
with op.batch_alter_table('datasets', schema=None) as batch_op:
|
|
28
|
+
batch_op.create_index(batch_op.f('ix_datasets_workspace_id'), ['workspace_id'], unique=False)
|
|
29
|
+
|
|
30
|
+
op.create_table('dataset_items',
|
|
31
|
+
sa.Column('id', sa.Integer(), nullable=False),
|
|
32
|
+
sa.Column('workspace_id', sa.Integer(), nullable=True),
|
|
33
|
+
sa.Column('dataset_id', sa.Integer(), nullable=False),
|
|
34
|
+
sa.Column('input', sa.Text(), nullable=False),
|
|
35
|
+
sa.Column('expected', sa.Text(), nullable=False),
|
|
36
|
+
sa.Column('meta', sa.JSON(), nullable=False),
|
|
37
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
38
|
+
sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], name=op.f('fk_dataset_items_dataset_id_datasets')),
|
|
39
|
+
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_dataset_items_workspace_id_workspaces')),
|
|
40
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_dataset_items'))
|
|
41
|
+
)
|
|
42
|
+
with op.batch_alter_table('dataset_items', schema=None) as batch_op:
|
|
43
|
+
batch_op.create_index(batch_op.f('ix_dataset_items_dataset_id'), ['dataset_id'], unique=False)
|
|
44
|
+
batch_op.create_index(batch_op.f('ix_dataset_items_workspace_id'), ['workspace_id'], unique=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def downgrade() -> None:
|
|
48
|
+
with op.batch_alter_table('dataset_items', schema=None) as batch_op:
|
|
49
|
+
batch_op.drop_index(batch_op.f('ix_dataset_items_workspace_id'))
|
|
50
|
+
batch_op.drop_index(batch_op.f('ix_dataset_items_dataset_id'))
|
|
51
|
+
op.drop_table('dataset_items')
|
|
52
|
+
with op.batch_alter_table('datasets', schema=None) as batch_op:
|
|
53
|
+
batch_op.drop_index(batch_op.f('ix_datasets_workspace_id'))
|
|
54
|
+
op.drop_table('datasets')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""experiments + experiment_results
|
|
2
|
+
|
|
3
|
+
Revision ID: c3d4e5f6a7b8
|
|
4
|
+
Revises: b2c3d4e5f6a7
|
|
5
|
+
Create Date: 2026-07-19 02:00:00.000000
|
|
6
|
+
"""
|
|
7
|
+
from alembic import op
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
revision = 'c3d4e5f6a7b8'
|
|
12
|
+
down_revision = 'b2c3d4e5f6a7'
|
|
13
|
+
branch_labels = None
|
|
14
|
+
depends_on = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade() -> None:
|
|
18
|
+
op.create_table('experiments',
|
|
19
|
+
sa.Column('id', sa.Integer(), nullable=False),
|
|
20
|
+
sa.Column('workspace_id', sa.Integer(), nullable=True),
|
|
21
|
+
sa.Column('name', sa.String(length=160), nullable=False),
|
|
22
|
+
sa.Column('dataset_id', sa.Integer(), nullable=True),
|
|
23
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
24
|
+
sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], name=op.f('fk_experiments_dataset_id_datasets')),
|
|
25
|
+
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_experiments_workspace_id_workspaces')),
|
|
26
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_experiments'))
|
|
27
|
+
)
|
|
28
|
+
with op.batch_alter_table('experiments', schema=None) as batch_op:
|
|
29
|
+
batch_op.create_index(batch_op.f('ix_experiments_dataset_id'), ['dataset_id'], unique=False)
|
|
30
|
+
batch_op.create_index(batch_op.f('ix_experiments_workspace_id'), ['workspace_id'], unique=False)
|
|
31
|
+
|
|
32
|
+
op.create_table('experiment_results',
|
|
33
|
+
sa.Column('id', sa.Integer(), nullable=False),
|
|
34
|
+
sa.Column('workspace_id', sa.Integer(), nullable=True),
|
|
35
|
+
sa.Column('experiment_id', sa.Integer(), nullable=False),
|
|
36
|
+
sa.Column('item_id', sa.Integer(), nullable=True),
|
|
37
|
+
sa.Column('input', sa.Text(), nullable=False),
|
|
38
|
+
sa.Column('output', sa.Text(), nullable=False),
|
|
39
|
+
sa.Column('expected', sa.Text(), nullable=False),
|
|
40
|
+
sa.Column('scores', sa.JSON(), nullable=False),
|
|
41
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
42
|
+
sa.ForeignKeyConstraint(['experiment_id'], ['experiments.id'], name=op.f('fk_experiment_results_experiment_id_experiments')),
|
|
43
|
+
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_experiment_results_workspace_id_workspaces')),
|
|
44
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_experiment_results'))
|
|
45
|
+
)
|
|
46
|
+
with op.batch_alter_table('experiment_results', schema=None) as batch_op:
|
|
47
|
+
batch_op.create_index(batch_op.f('ix_experiment_results_experiment_id'), ['experiment_id'], unique=False)
|
|
48
|
+
batch_op.create_index(batch_op.f('ix_experiment_results_workspace_id'), ['workspace_id'], unique=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def downgrade() -> None:
|
|
52
|
+
with op.batch_alter_table('experiment_results', schema=None) as batch_op:
|
|
53
|
+
batch_op.drop_index(batch_op.f('ix_experiment_results_workspace_id'))
|
|
54
|
+
batch_op.drop_index(batch_op.f('ix_experiment_results_experiment_id'))
|
|
55
|
+
op.drop_table('experiment_results')
|
|
56
|
+
with op.batch_alter_table('experiments', schema=None) as batch_op:
|
|
57
|
+
batch_op.drop_index(batch_op.f('ix_experiments_workspace_id'))
|
|
58
|
+
batch_op.drop_index(batch_op.f('ix_experiments_dataset_id'))
|
|
59
|
+
op.drop_table('experiments')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""alerts
|
|
2
|
+
|
|
3
|
+
Revision ID: d4e5f6a7b8c9
|
|
4
|
+
Revises: c3d4e5f6a7b8
|
|
5
|
+
Create Date: 2026-07-19 03:00:00.000000
|
|
6
|
+
"""
|
|
7
|
+
from alembic import op
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
revision = 'd4e5f6a7b8c9'
|
|
12
|
+
down_revision = 'c3d4e5f6a7b8'
|
|
13
|
+
branch_labels = None
|
|
14
|
+
depends_on = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade() -> None:
|
|
18
|
+
op.create_table('alerts',
|
|
19
|
+
sa.Column('id', sa.Integer(), nullable=False),
|
|
20
|
+
sa.Column('workspace_id', sa.Integer(), nullable=True),
|
|
21
|
+
sa.Column('name', sa.String(length=160), nullable=False),
|
|
22
|
+
sa.Column('metric', sa.String(length=32), nullable=False),
|
|
23
|
+
sa.Column('comparator', sa.String(length=4), nullable=False),
|
|
24
|
+
sa.Column('threshold', sa.Float(), nullable=False),
|
|
25
|
+
sa.Column('window_hours', sa.Integer(), nullable=False),
|
|
26
|
+
sa.Column('email', sa.String(length=255), nullable=False),
|
|
27
|
+
sa.Column('enabled', sa.Boolean(), nullable=False),
|
|
28
|
+
sa.Column('last_triggered_at', sa.DateTime(), nullable=True),
|
|
29
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
30
|
+
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], name=op.f('fk_alerts_workspace_id_workspaces')),
|
|
31
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_alerts'))
|
|
32
|
+
)
|
|
33
|
+
with op.batch_alter_table('alerts', schema=None) as batch_op:
|
|
34
|
+
batch_op.create_index(batch_op.f('ix_alerts_workspace_id'), ['workspace_id'], unique=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def downgrade() -> None:
|
|
38
|
+
with op.batch_alter_table('alerts', schema=None) as batch_op:
|
|
39
|
+
batch_op.drop_index(batch_op.f('ix_alerts_workspace_id'))
|
|
40
|
+
op.drop_table('alerts')
|