assay-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assay/__init__.py +2 -0
- assay/__main__.py +180 -0
- assay/agents.py +525 -0
- assay/alerts.py +397 -0
- assay/api.py +1090 -0
- assay/auth.py +194 -0
- assay/client.py +313 -0
- assay/config.py +73 -0
- assay/connect.py +287 -0
- assay/contracts.py +506 -0
- assay/cost.py +196 -0
- assay/coverage.py +119 -0
- assay/demo.py +753 -0
- assay/failures.py +846 -0
- assay/flaky.py +304 -0
- assay/gates.py +166 -0
- assay/ingest.py +602 -0
- assay/integrations.py +228 -0
- assay/learn.py +739 -0
- assay/measures/__init__.py +35 -0
- assay/measures/base.py +136 -0
- assay/measures/cost.py +168 -0
- assay/measures/errors.py +81 -0
- assay/measures/ground_truth.py +51 -0
- assay/measures/operations.py +127 -0
- assay/measures/pipeline.py +177 -0
- assay/models.py +137 -0
- assay/prompts.py +212 -0
- assay/rootcause.py +352 -0
- assay/runner.py +317 -0
- assay/scheduler.py +67 -0
- assay/schema.py +336 -0
- assay/sources/__init__.py +0 -0
- assay/sources/base.py +33 -0
- assay/sources/events.py +205 -0
- assay/sources/sql.py +257 -0
- assay/static/index.html +1622 -0
- assay/store.py +468 -0
- assay/trace.py +94 -0
- assay/units.py +22 -0
- assay/workflow.py +171 -0
- assay_server-0.1.0.dist-info/METADATA +64 -0
- assay_server-0.1.0.dist-info/RECORD +47 -0
- assay_server-0.1.0.dist-info/WHEEL +5 -0
- assay_server-0.1.0.dist-info/entry_points.txt +2 -0
- assay_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- assay_server-0.1.0.dist-info/top_level.txt +1 -0
assay/__init__.py
ADDED
assay/__main__.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Command line: python -m assay <command>"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
from assay import runner, store
|
|
11
|
+
from assay.config import Settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv=None) -> int:
|
|
15
|
+
p = argparse.ArgumentParser(prog="assay", description="Evaluation and observability for document-intelligence pipelines.")
|
|
16
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
17
|
+
|
|
18
|
+
s = sub.add_parser("serve", help="Run the API and dashboard")
|
|
19
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
20
|
+
s.add_argument("--port", type=int, default=8400)
|
|
21
|
+
s.add_argument("--every", type=int, metavar="MINUTES",
|
|
22
|
+
help="Also run measures every N minutes (overrides ASSAY_SCHEDULE_MINUTES)")
|
|
23
|
+
s.add_argument("--source", action="append", dest="sources", metavar="SOURCE",
|
|
24
|
+
help="Source to schedule, repeatable (overrides ASSAY_SCHEDULE_SOURCES)")
|
|
25
|
+
s.add_argument("--window-days", type=float, help="Window each scheduled run covers (default 1)")
|
|
26
|
+
|
|
27
|
+
r = sub.add_parser("run", help="Compute all measures once and store the results")
|
|
28
|
+
r.add_argument("--source", default="sql", help="sql (your pipeline database) or events:<tenant>")
|
|
29
|
+
r.add_argument("--days", type=int, default=7)
|
|
30
|
+
|
|
31
|
+
sub.add_parser("check-source", help="Test every mapped field against your pipeline database")
|
|
32
|
+
|
|
33
|
+
k = sub.add_parser("keys", help="Create, list and revoke API keys")
|
|
34
|
+
ks = k.add_subparsers(dest="keys_cmd", required=True)
|
|
35
|
+
kc = ks.add_parser("create", help="Create a key; the secret is printed once")
|
|
36
|
+
kc.add_argument("--tenant", required=True, help="Tenant the key belongs to, or '*' for a platform key")
|
|
37
|
+
kc.add_argument("--scopes", required=True, help="Comma-separated: ingest, read, manage, admin")
|
|
38
|
+
kc.add_argument("--name", required=True, help="What uses it, e.g. 'invoice pipeline (prod)'")
|
|
39
|
+
kc.add_argument("--expires-in-days", type=int)
|
|
40
|
+
kl = ks.add_parser("list", help="List keys (never shows secrets)")
|
|
41
|
+
kl.add_argument("--tenant")
|
|
42
|
+
kr = ks.add_parser("revoke", help="Revoke a key immediately")
|
|
43
|
+
kr.add_argument("id", type=int)
|
|
44
|
+
|
|
45
|
+
b = sub.add_parser("backfill", help="Replay past days so baselines and alerts work from day one")
|
|
46
|
+
b.add_argument("--source", default="sql", help="sql or events:<tenant>")
|
|
47
|
+
b.add_argument("--days", type=int, default=30)
|
|
48
|
+
b.add_argument("--window-days", type=float, default=1.0)
|
|
49
|
+
|
|
50
|
+
c = sub.add_parser("coverage", help="Which measures your data can answer, and what would unlock the rest")
|
|
51
|
+
c.add_argument("--source", default="sql", help="sql or events:<tenant>")
|
|
52
|
+
c.add_argument("--days", type=float, default=7)
|
|
53
|
+
sub.add_parser("demo", help="Load a synthetic demo tenant and backfill 7 weeks of daily runs")
|
|
54
|
+
sub.add_parser("schema", help="Print the v1 event schema as JSON Schema")
|
|
55
|
+
|
|
56
|
+
args = p.parse_args(argv)
|
|
57
|
+
if args.cmd == "schema":
|
|
58
|
+
from assay.schema import json_schema
|
|
59
|
+
print(json.dumps(json_schema(), indent=1))
|
|
60
|
+
return 0
|
|
61
|
+
settings = Settings.from_env()
|
|
62
|
+
|
|
63
|
+
if args.cmd == "serve":
|
|
64
|
+
if args.every is not None:
|
|
65
|
+
settings.schedule_minutes = args.every
|
|
66
|
+
if args.sources:
|
|
67
|
+
settings.schedule_sources = args.sources
|
|
68
|
+
if args.window_days is not None:
|
|
69
|
+
settings.schedule_window_days = args.window_days
|
|
70
|
+
import uvicorn
|
|
71
|
+
from assay.api import create_app
|
|
72
|
+
uvicorn.run(create_app(settings), host=args.host, port=args.port)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
engine = store.make_engine(settings.store_url)
|
|
76
|
+
from assay import integrations
|
|
77
|
+
|
|
78
|
+
if args.cmd == "run":
|
|
79
|
+
try:
|
|
80
|
+
source = runner.resolve_source(args.source, engine, settings)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
print(exc, file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
run_id = runner.run_measures(engine, source, runner.window_for_days(args.days),
|
|
85
|
+
notify=integrations.notifier(engine, source.name, settings.public_url,
|
|
86
|
+
settings.notifier()),
|
|
87
|
+
alert_min_n=settings.alert_min_n,
|
|
88
|
+
alert_after_runs=settings.alert_after_runs)
|
|
89
|
+
out = runner.latest_run(engine, source.name)
|
|
90
|
+
for mid, m in out["measures"].items():
|
|
91
|
+
val = m["overall"]["value"] if m["overall"] else None
|
|
92
|
+
shown = "—" if val is None else f"{val:.4g}"
|
|
93
|
+
print(f"{mid:24} {m['status']:10} {shown:>10} {m['reason'] or ''}")
|
|
94
|
+
with engine.connect() as conn:
|
|
95
|
+
a = store.alerts
|
|
96
|
+
live = conn.execute(select(a).where((a.c.source == source.name) & (a.c.state == "open"))).all()
|
|
97
|
+
print(f"run {run_id} stored · {len(live)} open alerts")
|
|
98
|
+
for r in live:
|
|
99
|
+
print(f" [{r.kind}] {r.message}")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
if args.cmd == "keys":
|
|
103
|
+
from assay import auth
|
|
104
|
+
if args.keys_cmd == "create":
|
|
105
|
+
try:
|
|
106
|
+
row, secret = auth.create_key(engine, args.tenant, args.name,
|
|
107
|
+
[s.strip() for s in args.scopes.split(",") if s.strip()],
|
|
108
|
+
args.expires_in_days)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
print(exc, file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
print(f"Created key {row['id']} for tenant {row['tenant']} with scopes {', '.join(row['scopes'])}.")
|
|
113
|
+
print(f"\n {secret}\n\nStore it now: it isn't shown again. Send it as 'Authorization: Bearer <key>'.")
|
|
114
|
+
print("Authentication is now required on this server." if not settings.admin_key else "")
|
|
115
|
+
return 0
|
|
116
|
+
if args.keys_cmd == "list":
|
|
117
|
+
for r in auth.list_keys(engine, args.tenant):
|
|
118
|
+
state = "revoked" if r["revoked_at"] else "active"
|
|
119
|
+
print(f"{r['id']:>4} {r['prefix']}… {r['tenant']:12} {','.join(r['scopes']):22} {state:8} "
|
|
120
|
+
f"last used {r['last_used_at'] or 'never'} {r['name']}")
|
|
121
|
+
return 0
|
|
122
|
+
if args.keys_cmd == "revoke":
|
|
123
|
+
ok = auth.revoke_key(engine, args.id)
|
|
124
|
+
print("Revoked." if ok else f"No active key {args.id}.")
|
|
125
|
+
return 0 if ok else 1
|
|
126
|
+
|
|
127
|
+
if args.cmd in ("backfill", "coverage"):
|
|
128
|
+
try:
|
|
129
|
+
source = runner.resolve_source(args.source, engine, settings)
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
print(exc, file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
if args.cmd == "backfill":
|
|
134
|
+
out = runner.backfill(engine, source, args.days, args.window_days,
|
|
135
|
+
settings.alert_min_n, settings.alert_after_runs)
|
|
136
|
+
print(f"{out['runs_created']} runs created, {out['skipped']} days already had one.")
|
|
137
|
+
return 0
|
|
138
|
+
from assay.coverage import compute
|
|
139
|
+
rep = compute(source, runner.window_for_days(args.days), runner.load_rates(engine, source.name))
|
|
140
|
+
for name, p in rep["records"].items():
|
|
141
|
+
print(f"{name:11} {'%d rows' % p['rows'] if p['available'] else 'not provided'}")
|
|
142
|
+
print()
|
|
143
|
+
for m in rep["measures"]:
|
|
144
|
+
print(f"{m['status']:8} {m['name']}")
|
|
145
|
+
for f in m["missing"]:
|
|
146
|
+
print(f" needs {f}")
|
|
147
|
+
for i in m["improve"]:
|
|
148
|
+
print(f" better with {i['field']}: {i['why']}")
|
|
149
|
+
c = rep["counts"]
|
|
150
|
+
print(f"\n{c['live']} live, {c['partial']} partial, {c['blocked']} blocked")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
if args.cmd == "check-source":
|
|
154
|
+
if not settings.source_url:
|
|
155
|
+
print("Set ASSAY_SOURCE_URL first.", file=sys.stderr)
|
|
156
|
+
return 2
|
|
157
|
+
from assay.sources.sql import SQLSource
|
|
158
|
+
report = SQLSource(settings.source_url).check()
|
|
159
|
+
bad = 0
|
|
160
|
+
for table, fields in report.items():
|
|
161
|
+
for field, err in fields.items():
|
|
162
|
+
print(f"{'ok ' if err is None else 'ERR'} {table}.{field}{'' if err is None else ' ' + err}")
|
|
163
|
+
bad += err is not None
|
|
164
|
+
if bad:
|
|
165
|
+
print(f"\n{bad} field(s) failed. Fix them in your mapping file (ASSAY_SOURCE_MAPPING), "
|
|
166
|
+
"or set them to \"NULL\" if your schema doesn't record them.", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
print("\nAll mapped fields work.")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
if args.cmd == "demo":
|
|
172
|
+
from assay.demo import seed
|
|
173
|
+
print(json.dumps(seed(engine), indent=2))
|
|
174
|
+
print("Demo loaded. Start the dashboard with: python -m assay serve")
|
|
175
|
+
return 0
|
|
176
|
+
return 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|