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 ADDED
@@ -0,0 +1,3 @@
1
+ """EpiDash — light MCP-controlled dashboard service."""
2
+
3
+ __version__ = "0.1.0"
@@ -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