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 ADDED
@@ -0,0 +1,2 @@
1
+ """Assay: evaluation and observability for document-intelligence pipelines."""
2
+ __version__ = "0.1.0"
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())