epidash 0.2.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.
- epidash/__init__.py +3 -0
- epidash/api/__init__.py +1 -0
- epidash/api/routes_public.py +284 -0
- epidash/cli.py +76 -0
- epidash/config.py +113 -0
- epidash/epidbot_client.py +152 -0
- epidash/logging.py +22 -0
- epidash/main.py +133 -0
- epidash/mcpsrv/__init__.py +1 -0
- epidash/mcpsrv/audit.py +44 -0
- epidash/mcpsrv/auth.py +104 -0
- epidash/mcpsrv/server.py +406 -0
- epidash/schema.sql +78 -0
- epidash/static/app.js +739 -0
- epidash/static/epidbot.webp +0 -0
- epidash/static/geo/br-states.json +1 -0
- epidash/static/index.html +37 -0
- epidash/static/kwar-ai-logo.webp +0 -0
- epidash/static/style.css +314 -0
- epidash/static/vendor/plotly.min.js +3882 -0
- epidash/store.py +503 -0
- epidash-0.2.0.dist-info/METADATA +153 -0
- epidash-0.2.0.dist-info/RECORD +25 -0
- epidash-0.2.0.dist-info/WHEEL +4 -0
- epidash-0.2.0.dist-info/licenses/LICENSE +21 -0
epidash/__init__.py
ADDED
epidash/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Public REST API: published dashboards + the viewer tweak flow."""
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Public API routes.
|
|
2
|
+
|
|
3
|
+
- GET /api/dashboards — published dashboards (listing only)
|
|
4
|
+
- GET /api/dashboards/{slug} — one published dashboard with charts
|
|
5
|
+
- POST /api/dashboards/{slug}/like — like a published dashboard
|
|
6
|
+
- DELETE /api/dashboards/{slug}/like — undo a like
|
|
7
|
+
- GET /api/dashboards/{slug}/export — download the dashboard as JSON
|
|
8
|
+
- POST /api/dashboards/{slug}/tweak — forward a viewer request to EpidBot
|
|
9
|
+
- POST /api/dashboards/{slug}/ask — ask EpidBot a question about a dashboard
|
|
10
|
+
- GET /api/tweaks/{tweak_id} — poll a tweak's status
|
|
11
|
+
|
|
12
|
+
(/healthz lives on the root app, see main.py.)
|
|
13
|
+
|
|
14
|
+
Draft dashboards are never exposed here; control goes through MCP only.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import datetime as _dt
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
22
|
+
from fastapi.responses import JSONResponse
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
|
|
25
|
+
from epidash.config import get_settings
|
|
26
|
+
from epidash.epidbot_client import (
|
|
27
|
+
EpidBotClient,
|
|
28
|
+
EpidBotError,
|
|
29
|
+
build_question_message,
|
|
30
|
+
build_tweak_message,
|
|
31
|
+
)
|
|
32
|
+
from epidash.logging import get_logger
|
|
33
|
+
from epidash.store import Store
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
router = APIRouter(prefix="/api", include_in_schema=True)
|
|
38
|
+
|
|
39
|
+
_store: Store | None = None
|
|
40
|
+
_epidbot: EpidBotClient | None = None
|
|
41
|
+
|
|
42
|
+
# Sliding-window rate limiter: slug -> list of submission timestamps.
|
|
43
|
+
_tweak_windows: dict[str, list[float]] = {}
|
|
44
|
+
_ask_windows: dict[str, list[float]] = {}
|
|
45
|
+
_like_windows: dict[str, list[float]] = {}
|
|
46
|
+
|
|
47
|
+
# Like actions per dashboard per hour (likes + unlikes combined).
|
|
48
|
+
LIKE_RATE_LIMIT_PER_HOUR = 60
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_store(store: Store) -> None:
|
|
52
|
+
global _store, _epidbot
|
|
53
|
+
_store = store
|
|
54
|
+
_epidbot = EpidBotClient()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_store() -> Store:
|
|
58
|
+
if _store is None:
|
|
59
|
+
raise RuntimeError("Store not initialised; app startup did not run")
|
|
60
|
+
return _store
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TweakRequest(BaseModel):
|
|
64
|
+
"""Viewer request to change a dashboard."""
|
|
65
|
+
|
|
66
|
+
request: str = Field(min_length=3, max_length=2000)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rate_limited(slug: str, windows: dict[str, list[float]], limit: int) -> bool:
|
|
70
|
+
"""True when this slug exceeded its hourly budget for `windows`."""
|
|
71
|
+
now = _dt.datetime.now(_dt.timezone.utc).timestamp()
|
|
72
|
+
window = [t for t in windows.get(slug, []) if now - t < 3600]
|
|
73
|
+
if len(window) >= limit:
|
|
74
|
+
windows[slug] = window
|
|
75
|
+
return True
|
|
76
|
+
window.append(now)
|
|
77
|
+
windows[slug] = window
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tweak_rate_limited(slug: str) -> bool:
|
|
82
|
+
return _rate_limited(slug, _tweak_windows, get_settings().tweak_rate_limit_per_hour)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_published(store: Store, slug: str) -> dict:
|
|
86
|
+
"""Published dashboard by slug or 404 (drafts are MCP-only)."""
|
|
87
|
+
dashboard = store.get_dashboard_by_slug(slug)
|
|
88
|
+
if dashboard is None or dashboard["status"] != "published":
|
|
89
|
+
raise HTTPException(status_code=404, detail="Dashboard not found")
|
|
90
|
+
return dashboard
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _public_dashboard(dashboard: dict) -> dict:
|
|
94
|
+
"""Shape of a dashboard exposed publicly (no internal status detail)."""
|
|
95
|
+
return {
|
|
96
|
+
"slug": dashboard["slug"],
|
|
97
|
+
"title": dashboard["title"],
|
|
98
|
+
"description": dashboard["description"],
|
|
99
|
+
"created_by": dashboard["created_by"],
|
|
100
|
+
"layout": dashboard["layout"],
|
|
101
|
+
"updated_at": dashboard["updated_at"],
|
|
102
|
+
"published_at": dashboard["published_at"],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/dashboards")
|
|
107
|
+
async def list_published_dashboards() -> list[dict]:
|
|
108
|
+
store = get_store()
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
"slug": d["slug"],
|
|
112
|
+
"title": d["title"],
|
|
113
|
+
"description": d["description"],
|
|
114
|
+
"created_by": d["created_by"],
|
|
115
|
+
"likes": store.get_likes(d["id"]),
|
|
116
|
+
"updated_at": d["updated_at"],
|
|
117
|
+
}
|
|
118
|
+
for d in store.list_dashboards(include_unpublished=False)
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.get("/dashboards/{slug}")
|
|
123
|
+
async def get_published_dashboard(slug: str) -> dict:
|
|
124
|
+
store = get_store()
|
|
125
|
+
dashboard = _get_published(store, slug)
|
|
126
|
+
try:
|
|
127
|
+
store.record_view(dashboard["id"])
|
|
128
|
+
except Exception as e: # noqa: BLE001 — view counting is best effort
|
|
129
|
+
logger.debug("View count failed for %s: %s", slug, e)
|
|
130
|
+
return {
|
|
131
|
+
**_public_dashboard(dashboard),
|
|
132
|
+
"likes": store.get_likes(dashboard["id"]),
|
|
133
|
+
"charts": store.list_charts(dashboard["id"]),
|
|
134
|
+
"tweaks_enabled": get_settings().epidbot_configured,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.post("/dashboards/{slug}/like")
|
|
139
|
+
async def like_dashboard(slug: str) -> dict:
|
|
140
|
+
store = get_store()
|
|
141
|
+
dashboard = _get_published(store, slug)
|
|
142
|
+
if _rate_limited(slug, _like_windows, LIKE_RATE_LIMIT_PER_HOUR):
|
|
143
|
+
raise HTTPException(status_code=429, detail="Too many like actions; try again later")
|
|
144
|
+
return {"slug": slug, "likes": store.record_like(dashboard["id"])}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.delete("/dashboards/{slug}/like")
|
|
148
|
+
async def unlike_dashboard(slug: str) -> dict:
|
|
149
|
+
store = get_store()
|
|
150
|
+
dashboard = _get_published(store, slug)
|
|
151
|
+
if _rate_limited(slug, _like_windows, LIKE_RATE_LIMIT_PER_HOUR):
|
|
152
|
+
raise HTTPException(status_code=429, detail="Too many like actions; try again later")
|
|
153
|
+
return {"slug": slug, "likes": store.remove_like(dashboard["id"])}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.get("/dashboards/{slug}/export")
|
|
157
|
+
async def export_dashboard(slug: str) -> JSONResponse:
|
|
158
|
+
"""Download the full dashboard definition (re-importable via MCP)."""
|
|
159
|
+
store = get_store()
|
|
160
|
+
dashboard = _get_published(store, slug)
|
|
161
|
+
payload = {
|
|
162
|
+
"app": "epidash",
|
|
163
|
+
"slug": dashboard["slug"],
|
|
164
|
+
"title": dashboard["title"],
|
|
165
|
+
"description": dashboard["description"],
|
|
166
|
+
"created_by": dashboard["created_by"],
|
|
167
|
+
"layout": dashboard["layout"],
|
|
168
|
+
"published_at": dashboard["published_at"],
|
|
169
|
+
"updated_at": dashboard["updated_at"],
|
|
170
|
+
"likes": store.get_likes(dashboard["id"]),
|
|
171
|
+
"charts": [
|
|
172
|
+
{
|
|
173
|
+
"kind": c["kind"],
|
|
174
|
+
"title": c["title"],
|
|
175
|
+
"data": c["data"],
|
|
176
|
+
"spec": c["spec"],
|
|
177
|
+
"position": c["position"],
|
|
178
|
+
}
|
|
179
|
+
for c in store.list_charts(dashboard["id"])
|
|
180
|
+
],
|
|
181
|
+
}
|
|
182
|
+
return JSONResponse(
|
|
183
|
+
payload,
|
|
184
|
+
headers={
|
|
185
|
+
"Content-Disposition": f'attachment; filename="{slug}.json"',
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TweakSubmitted(BaseModel):
|
|
191
|
+
tweak_id: str
|
|
192
|
+
status: str = "pending"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.post("/dashboards/{slug}/tweak", status_code=202)
|
|
196
|
+
async def submit_tweak(slug: str, body: TweakRequest, request: Request) -> TweakSubmitted:
|
|
197
|
+
store = get_store()
|
|
198
|
+
dashboard = _get_published(store, slug)
|
|
199
|
+
settings = get_settings()
|
|
200
|
+
if not settings.epidbot_configured:
|
|
201
|
+
raise HTTPException(
|
|
202
|
+
status_code=503,
|
|
203
|
+
detail="EpidBot integration is not configured on this server",
|
|
204
|
+
)
|
|
205
|
+
if _tweak_rate_limited(slug):
|
|
206
|
+
raise HTTPException(
|
|
207
|
+
status_code=429, detail="Too many tweak requests; try again later"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
tweak = store.create_tweak(dashboard["id"], body.request, kind="tweak")
|
|
211
|
+
asyncio.create_task(_process_tweak(tweak["id"], dashboard, body.request))
|
|
212
|
+
return TweakSubmitted(tweak_id=tweak["id"], status=tweak["status"])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@router.post("/dashboards/{slug}/ask", status_code=202)
|
|
216
|
+
async def ask_about_dashboard(slug: str, body: TweakRequest, request: Request) -> TweakSubmitted:
|
|
217
|
+
"""Viewer question about a published dashboard — an EpidBot chat turn
|
|
218
|
+
that answers from the dashboard data without modifying it."""
|
|
219
|
+
store = get_store()
|
|
220
|
+
dashboard = _get_published(store, slug)
|
|
221
|
+
settings = get_settings()
|
|
222
|
+
if not settings.epidbot_configured:
|
|
223
|
+
raise HTTPException(
|
|
224
|
+
status_code=503,
|
|
225
|
+
detail="EpidBot integration is not configured on this server",
|
|
226
|
+
)
|
|
227
|
+
if _rate_limited(slug, _ask_windows, settings.ask_rate_limit_per_hour):
|
|
228
|
+
raise HTTPException(
|
|
229
|
+
status_code=429, detail="Too many questions; try again later"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
tweak = store.create_tweak(dashboard["id"], body.request, kind="question")
|
|
233
|
+
asyncio.create_task(_process_question(tweak["id"], dashboard, body.request))
|
|
234
|
+
return TweakSubmitted(tweak_id=tweak["id"], status=tweak["status"])
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def _process_epidbot_turn(tweak_id: str, message: str, action: str) -> None:
|
|
238
|
+
"""Background task: run one EpidBot chat turn and record the outcome."""
|
|
239
|
+
store = get_store()
|
|
240
|
+
assert _epidbot is not None
|
|
241
|
+
store.update_tweak(tweak_id, status="processing")
|
|
242
|
+
try:
|
|
243
|
+
result = await _epidbot.run_tweak(message)
|
|
244
|
+
content = result.get("content") or ""
|
|
245
|
+
store.update_tweak(tweak_id, status="completed", response=content)
|
|
246
|
+
except EpidBotError as e:
|
|
247
|
+
logger.warning("%s %s failed: %s", action, tweak_id, e)
|
|
248
|
+
store.update_tweak(tweak_id, status="failed", error=str(e))
|
|
249
|
+
except Exception as e: # noqa: BLE001 — never crash the background task
|
|
250
|
+
logger.error("%s %s crashed: %s", action, tweak_id, e)
|
|
251
|
+
store.update_tweak(tweak_id, status="failed", error=f"internal error: {e}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _process_tweak(tweak_id: str, dashboard: dict, request_text: str) -> None:
|
|
255
|
+
"""Background task: send the tweak turn to EpidBot and record the result."""
|
|
256
|
+
message = build_tweak_message(
|
|
257
|
+
dashboard, get_store().list_charts(dashboard["id"]), request_text
|
|
258
|
+
)
|
|
259
|
+
await _process_epidbot_turn(tweak_id, message, "Tweak")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _process_question(tweak_id: str, dashboard: dict, question: str) -> None:
|
|
263
|
+
"""Background task: send the question turn to EpidBot (read-only)."""
|
|
264
|
+
message = build_question_message(
|
|
265
|
+
dashboard, get_store().list_charts(dashboard["id"]), question
|
|
266
|
+
)
|
|
267
|
+
await _process_epidbot_turn(tweak_id, message, "Question")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@router.get("/tweaks/{tweak_id}")
|
|
271
|
+
async def get_tweak(tweak_id: str) -> dict:
|
|
272
|
+
tweak = get_store().get_tweak(tweak_id)
|
|
273
|
+
if tweak is None:
|
|
274
|
+
raise HTTPException(status_code=404, detail="Tweak not found")
|
|
275
|
+
return {
|
|
276
|
+
"tweak_id": tweak["id"],
|
|
277
|
+
"dashboard_id": tweak["dashboard_id"],
|
|
278
|
+
"kind": tweak.get("kind", "tweak"),
|
|
279
|
+
"status": tweak["status"],
|
|
280
|
+
"response": tweak["response"],
|
|
281
|
+
"error": tweak["error"],
|
|
282
|
+
"created_at": tweak["created_at"],
|
|
283
|
+
"updated_at": tweak["updated_at"],
|
|
284
|
+
}
|
epidash/cli.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Control-key management CLI.
|
|
2
|
+
|
|
3
|
+
Usage (inside the container / on the server):
|
|
4
|
+
|
|
5
|
+
python -m epidash.cli add-key "epidbot"
|
|
6
|
+
python -m epidash.cli list-keys
|
|
7
|
+
python -m epidash.cli revoke 2
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import secrets
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from epidash.config import get_settings
|
|
15
|
+
from epidash.logging import get_logger
|
|
16
|
+
from epidash.store import Store
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _store() -> Store:
|
|
22
|
+
return Store(get_settings().db_path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _generate_key() -> str:
|
|
26
|
+
return "dk_" + secrets.token_urlsafe(24)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: list[str] | None = None) -> int:
|
|
30
|
+
parser = argparse.ArgumentParser(prog="epidash.cli", description=__doc__)
|
|
31
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
32
|
+
|
|
33
|
+
p_add = sub.add_parser("add-key", help="create a new control key (raw key shown once)")
|
|
34
|
+
p_add.add_argument("name", help="human-readable name, e.g. 'epidbot'")
|
|
35
|
+
|
|
36
|
+
sub.add_parser("list-keys", help="list control keys (hashes only)")
|
|
37
|
+
|
|
38
|
+
p_revoke = sub.add_parser("revoke", help="revoke a key by id")
|
|
39
|
+
p_revoke.add_argument("key_id", type=int)
|
|
40
|
+
|
|
41
|
+
args = parser.parse_args(argv)
|
|
42
|
+
store = _store()
|
|
43
|
+
|
|
44
|
+
if args.command == "add-key":
|
|
45
|
+
raw = _generate_key()
|
|
46
|
+
meta = store.add_key(raw, name=args.name)
|
|
47
|
+
print(f"Key created (id={meta['id']}, name={meta['name']!s},"
|
|
48
|
+
f" prefix={meta['key_prefix']}...).")
|
|
49
|
+
print("Store it now — it is shown only once:")
|
|
50
|
+
print(raw)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
if args.command == "list-keys":
|
|
54
|
+
keys = store.list_keys()
|
|
55
|
+
if not keys:
|
|
56
|
+
print("No control keys.")
|
|
57
|
+
return 0
|
|
58
|
+
for k in keys:
|
|
59
|
+
status = "revoked" if k["revoked"] else "active"
|
|
60
|
+
last = k["last_used_at"] or "never"
|
|
61
|
+
print(f"id={k['id']} {k['key_prefix']}... {status:<8} "
|
|
62
|
+
f"name={k['name']} last_used={last}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
if args.command == "revoke":
|
|
66
|
+
if store.revoke_key(args.key_id):
|
|
67
|
+
print(f"Key {args.key_id} revoked.")
|
|
68
|
+
return 0
|
|
69
|
+
print(f"Key {args.key_id} not found.", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
return 2
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
raise SystemExit(main())
|
epidash/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Environment-based configuration.
|
|
2
|
+
|
|
3
|
+
Every setting has a sane local default so the service runs out of the box
|
|
4
|
+
(``uv run uvicorn epidash.main:app``); production values are injected via
|
|
5
|
+
environment variables (see docker-compose.yml / the Ansible role).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _env_str(name: str, default: str = "") -> str:
|
|
13
|
+
value = os.environ.get(name)
|
|
14
|
+
return value.strip() if value is not None else default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
18
|
+
raw = os.environ.get(name)
|
|
19
|
+
if raw is None:
|
|
20
|
+
return default
|
|
21
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _env_int(name: str, default: int) -> int:
|
|
25
|
+
raw = os.environ.get(name)
|
|
26
|
+
if raw is None or not raw.strip():
|
|
27
|
+
return default
|
|
28
|
+
try:
|
|
29
|
+
return int(raw.strip())
|
|
30
|
+
except ValueError:
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Settings:
|
|
36
|
+
"""Runtime configuration, read once from the environment."""
|
|
37
|
+
|
|
38
|
+
#: SQLite database file (WAL mode).
|
|
39
|
+
db_path: str = field(default_factory=lambda: _env_str("EPIDASH_DB_PATH", "data/epidash.db"))
|
|
40
|
+
|
|
41
|
+
#: Public base URL, used to build share links (published dashboard URLs).
|
|
42
|
+
base_url: str = field(default_factory=lambda: _env_str("EPIDASH_BASE_URL", "http://localhost:8000"))
|
|
43
|
+
|
|
44
|
+
#: Path the MCP streamable-HTTP endpoint is served under.
|
|
45
|
+
mcp_path: str = field(default_factory=lambda: _env_str("EPIDASH_MCP_PATH", "/mcp"))
|
|
46
|
+
|
|
47
|
+
#: Server name advertised during the MCP handshake.
|
|
48
|
+
server_name: str = field(default_factory=lambda: _env_str("EPIDASH_SERVER_NAME", "epidash"))
|
|
49
|
+
|
|
50
|
+
#: Hosts allowed by the MCP DNS-rebinding protection (Host header values).
|
|
51
|
+
#: The SDK compares the full Host header (port included) and accepts
|
|
52
|
+
#: ``host:*`` wildcard-port patterns.
|
|
53
|
+
allowed_hosts: list[str] = field(
|
|
54
|
+
default_factory=lambda: [
|
|
55
|
+
h.strip()
|
|
56
|
+
for h in _env_str("EPIDASH_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*").split(",")
|
|
57
|
+
if h.strip()
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
#: Keep DNS-rebinding protection on in production; tests may disable it.
|
|
62
|
+
dns_rebinding_protection: bool = field(
|
|
63
|
+
default_factory=lambda: _env_bool("EPIDASH_DNS_REBINDING_PROTECTION", True)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
#: Optional pre-shared ``dk_`` control key (seeded at startup, used by
|
|
67
|
+
#: infra automation so EpidBot and epidash get the same secret).
|
|
68
|
+
bootstrap_key: str = field(default_factory=lambda: _env_str("EPIDASH_BOOTSTRAP_KEY"))
|
|
69
|
+
|
|
70
|
+
#: EpidBot base URL (e.g. https://api.epidbot.kwar-ai.com.br) enabling the
|
|
71
|
+
#: tweak flow. Empty disables it.
|
|
72
|
+
epidbot_base_url: str = field(default_factory=lambda: _env_str("EPIDBOT_BASE_URL"))
|
|
73
|
+
|
|
74
|
+
#: Shared secret sent as ``X-Internal-Request`` (EpidBot's
|
|
75
|
+
#: INTERNAL_API_SECRET) or a regular ``ek_`` API key sent as Bearer.
|
|
76
|
+
epidbot_internal_secret: str = field(
|
|
77
|
+
default_factory=lambda: _env_str("EPIDBOT_INTERNAL_SECRET")
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
#: Tweak submissions allowed per dashboard slug per hour (LLM cost guard).
|
|
81
|
+
tweak_rate_limit_per_hour: int = field(
|
|
82
|
+
default_factory=lambda: _env_int("EPIDASH_TWEAK_RATE_LIMIT_PER_HOUR", 20)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
#: Viewer questions allowed per dashboard slug per hour (LLM cost guard).
|
|
86
|
+
ask_rate_limit_per_hour: int = field(
|
|
87
|
+
default_factory=lambda: _env_int("EPIDASH_ASK_RATE_LIMIT_PER_HOUR", 30)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
#: How long to wait for EpidBot to finish a tweak (seconds).
|
|
91
|
+
tweak_timeout_s: int = field(default_factory=lambda: _env_int("EPIDASH_TWEAK_TIMEOUT_S", 300))
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def epidbot_configured(self) -> bool:
|
|
95
|
+
"""True when the tweak flow can reach EpidBot."""
|
|
96
|
+
return bool(self.epidbot_base_url) and bool(self.epidbot_internal_secret)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_settings: Settings | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_settings() -> Settings:
|
|
103
|
+
"""Process-wide settings singleton (tests use ``set_settings``)."""
|
|
104
|
+
global _settings
|
|
105
|
+
if _settings is None:
|
|
106
|
+
_settings = Settings()
|
|
107
|
+
return _settings
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def set_settings(settings: Settings | None) -> None:
|
|
111
|
+
"""Replace the settings singleton (mainly for tests)."""
|
|
112
|
+
global _settings
|
|
113
|
+
_settings = settings
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Client for EpidBot's chat API — powers the viewer "tweak" flow.
|
|
2
|
+
|
|
3
|
+
Authentication uses the internal-service pattern already established by
|
|
4
|
+
EpidBot's file-upload endpoint: the shared ``INTERNAL_API_SECRET`` sent as
|
|
5
|
+
an ``X-Internal-Request`` header. An ``ek_`` API key sent as a Bearer
|
|
6
|
+
token is supported as a fallback for local experiments.
|
|
7
|
+
|
|
8
|
+
Flow: submit the tweak as a chat job (POST /api/v1/chat), then poll
|
|
9
|
+
GET /api/v1/chat/{job_id} until the agent finishes. The agent is expected
|
|
10
|
+
to mutate the dashboard through its EpiDash MCP tools during that turn.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from epidash.config import get_settings
|
|
19
|
+
from epidash.logging import get_logger
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
POLL_INTERVAL_S = 2.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EpidBotError(RuntimeError):
|
|
27
|
+
"""A tweak round-trip against EpidBot failed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _compact_charts(charts: list[dict]) -> list[dict]:
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
"id": c["id"],
|
|
34
|
+
"kind": c["kind"],
|
|
35
|
+
"title": c["title"],
|
|
36
|
+
"data": c["data"],
|
|
37
|
+
"spec": c["spec"],
|
|
38
|
+
"position": c["position"],
|
|
39
|
+
}
|
|
40
|
+
for c in charts
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_tweak_message(dashboard: dict, charts: list[dict], request: str) -> str:
|
|
45
|
+
"""Prompt sent to the EpidBot agent for one tweak turn."""
|
|
46
|
+
compact = _compact_charts(charts)
|
|
47
|
+
return (
|
|
48
|
+
f"A viewer of the EpiDash dashboard '{dashboard.get('title', '')}' "
|
|
49
|
+
f"(dashboard_id={dashboard['id']}, slug={dashboard['slug']}) requests "
|
|
50
|
+
f"changes to it.\n\n"
|
|
51
|
+
f"Current charts (JSON):\n{compact}\n\n"
|
|
52
|
+
f"Viewer request: {request}\n\n"
|
|
53
|
+
"Use your EpiDash MCP tools to apply the requested changes to this "
|
|
54
|
+
"dashboard (update_chart / add_chart / remove_chart as needed). Keep "
|
|
55
|
+
"the existing visual style unless asked otherwise, keep data "
|
|
56
|
+
"envelopes consistent with each chart kind, and reply with a one or "
|
|
57
|
+
"two sentence summary of what you changed."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_question_message(dashboard: dict, charts: list[dict], question: str) -> str:
|
|
62
|
+
"""Prompt sent to the EpidBot agent when a viewer asks a question.
|
|
63
|
+
|
|
64
|
+
Same chat pipeline as tweaks, but the agent must answer from the
|
|
65
|
+
dashboard data and leave the dashboard untouched.
|
|
66
|
+
"""
|
|
67
|
+
compact = _compact_charts(charts)
|
|
68
|
+
return (
|
|
69
|
+
f"A viewer of the EpiDash dashboard '{dashboard.get('title', '')}' "
|
|
70
|
+
f"(dashboard_id={dashboard['id']}, slug={dashboard['slug']}) is asking "
|
|
71
|
+
f"a QUESTION about it.\n\n"
|
|
72
|
+
f"Current charts (JSON):\n{compact}\n\n"
|
|
73
|
+
f"Viewer question: {question}\n\n"
|
|
74
|
+
"Answer the question based on the dashboard's data. This is a "
|
|
75
|
+
"read-only turn: do NOT use your EpiDash MCP tools to modify the "
|
|
76
|
+
"dashboard. Reply with a concise, self-contained answer (a short "
|
|
77
|
+
"paragraph at most); cite the specific numbers or charts you used."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class EpidBotClient:
|
|
82
|
+
"""Submits tweak turns to EpidBot and waits for completion."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
base_url: Optional[str] = None,
|
|
87
|
+
internal_secret: Optional[str] = None,
|
|
88
|
+
timeout_s: Optional[int] = None,
|
|
89
|
+
):
|
|
90
|
+
settings = get_settings()
|
|
91
|
+
self.base_url = (base_url or settings.epidbot_base_url).rstrip("/")
|
|
92
|
+
self.internal_secret = internal_secret or settings.epidbot_internal_secret
|
|
93
|
+
self.timeout_s = timeout_s or settings.tweak_timeout_s
|
|
94
|
+
|
|
95
|
+
def _headers(self) -> dict[str, str]:
|
|
96
|
+
headers = {"Content-Type": "application/json"}
|
|
97
|
+
if self.internal_secret.startswith("ek_"):
|
|
98
|
+
headers["Authorization"] = f"Bearer {self.internal_secret}"
|
|
99
|
+
else:
|
|
100
|
+
headers["X-Internal-Request"] = self.internal_secret
|
|
101
|
+
return headers
|
|
102
|
+
|
|
103
|
+
async def _submit(self, client: httpx.AsyncClient, message: str) -> tuple[str, Optional[int]]:
|
|
104
|
+
response = await client.post(
|
|
105
|
+
f"{self.base_url}/api/v1/chat",
|
|
106
|
+
json={"message": message, "locale": "en"},
|
|
107
|
+
headers=self._headers(),
|
|
108
|
+
timeout=30,
|
|
109
|
+
)
|
|
110
|
+
if response.status_code != 200:
|
|
111
|
+
raise EpidBotError(
|
|
112
|
+
f"EpidBot chat submit failed: HTTP {response.status_code}: "
|
|
113
|
+
f"{response.text[:200]}"
|
|
114
|
+
)
|
|
115
|
+
payload = response.json()
|
|
116
|
+
return payload["job_id"], payload.get("session_id")
|
|
117
|
+
|
|
118
|
+
async def _poll(
|
|
119
|
+
self, client: httpx.AsyncClient, job_id: str
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
deadline = asyncio.get_event_loop().time() + self.timeout_s
|
|
122
|
+
while True:
|
|
123
|
+
response = await client.get(
|
|
124
|
+
f"{self.base_url}/api/v1/chat/{job_id}",
|
|
125
|
+
headers=self._headers(),
|
|
126
|
+
timeout=15,
|
|
127
|
+
)
|
|
128
|
+
if response.status_code != 200:
|
|
129
|
+
raise EpidBotError(
|
|
130
|
+
f"EpidBot chat poll failed: HTTP {response.status_code}"
|
|
131
|
+
)
|
|
132
|
+
payload = response.json()
|
|
133
|
+
status = payload.get("status")
|
|
134
|
+
if status == "completed":
|
|
135
|
+
return payload
|
|
136
|
+
if status == "failed":
|
|
137
|
+
raise EpidBotError(
|
|
138
|
+
f"EpidBot chat job failed: {payload.get('error') or 'unknown error'}"
|
|
139
|
+
)
|
|
140
|
+
if asyncio.get_event_loop().time() > deadline:
|
|
141
|
+
raise EpidBotError(
|
|
142
|
+
f"EpidBot chat job {job_id} timed out after {self.timeout_s}s"
|
|
143
|
+
)
|
|
144
|
+
await asyncio.sleep(POLL_INTERVAL_S)
|
|
145
|
+
|
|
146
|
+
async def run_tweak(self, message: str) -> dict[str, Any]:
|
|
147
|
+
"""Submit the tweak turn and wait for the agent to finish."""
|
|
148
|
+
async with httpx.AsyncClient() as client:
|
|
149
|
+
job_id, _session_id = await self._submit(client, message)
|
|
150
|
+
logger.info("EpiDash tweak submitted to EpidBot (job_id=%s)", job_id)
|
|
151
|
+
result = await self._poll(client, job_id)
|
|
152
|
+
return result
|
epidash/logging.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Minimal logging setup for EpiDash (keeps epidash self-contained)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _level() -> str:
|
|
11
|
+
return os.environ.get("EPIDASH_LOG_LEVEL", "INFO").upper()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_logger(name: str) -> logging.Logger:
|
|
15
|
+
logger = logging.getLogger(name)
|
|
16
|
+
if not logging.getLogger("epidash").handlers:
|
|
17
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
18
|
+
handler.setFormatter(logging.Formatter(_FORMAT))
|
|
19
|
+
root = logging.getLogger("epidash")
|
|
20
|
+
root.addHandler(handler)
|
|
21
|
+
root.setLevel(_level())
|
|
22
|
+
return logger
|