alignscope 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.
alignscope/server.py ADDED
@@ -0,0 +1,298 @@
1
+ """
2
+ AlignScope — Dashboard Server
3
+
4
+ Serves the live dashboard frontend and handles data ingestion from:
5
+ 1. Built-in demo simulator (--demo mode)
6
+ 2. SDK clients via WebSocket (/ws/sdk)
7
+ 3. REST API posts (/api/log) for Tier 4 integration
8
+
9
+ Refactored from the original backend/main.py to work as part of
10
+ the installable package.
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ from typing import Set
17
+
18
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
19
+ from fastapi.staticfiles import StaticFiles
20
+ from fastapi.responses import FileResponse, JSONResponse
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+
23
+ from alignscope.simulator import MARLSimulator, SimulatorConfig
24
+ from alignscope.metrics import AlignmentMetrics
25
+ from alignscope.detector import DefectionDetector
26
+
27
+ # Locate frontend files
28
+ _PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
29
+ _FRONTEND_CANDIDATES = [
30
+ os.path.join(_PACKAGE_DIR, "_frontend"), # Installed via pip (bundled)
31
+ os.path.join(os.path.dirname(_PACKAGE_DIR), "frontend"), # Dev mode (source tree)
32
+ ]
33
+ FRONTEND_DIR = next((d for d in _FRONTEND_CANDIDATES if os.path.isdir(d)), None)
34
+
35
+
36
+ def create_app(demo: bool = False) -> FastAPI:
37
+ """Create the FastAPI application."""
38
+ app = FastAPI(title="AlignScope", version="0.1.0")
39
+
40
+ # CORS — allow any origin for SDK clients
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=["*"],
44
+ allow_methods=["*"],
45
+ allow_headers=["*"],
46
+ )
47
+
48
+ # Shared state for SDK data ingestion
49
+ # Each connected frontend gets its own queue (broadcast pattern)
50
+ app.state.frontend_queues: Set[asyncio.Queue] = set()
51
+ app.state.sdk_config = None
52
+ app.state.demo_mode = demo
53
+
54
+ # Serve frontend static files
55
+ if FRONTEND_DIR:
56
+ css_dir = os.path.join(FRONTEND_DIR, "css")
57
+ js_dir = os.path.join(FRONTEND_DIR, "js")
58
+ if os.path.isdir(css_dir):
59
+ app.mount("/css", StaticFiles(directory=css_dir), name="css")
60
+ if os.path.isdir(js_dir):
61
+ app.mount("/js", StaticFiles(directory=js_dir), name="js")
62
+
63
+ @app.get("/")
64
+ async def root():
65
+ if FRONTEND_DIR:
66
+ return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
67
+ return JSONResponse({"error": "Frontend not found"}, status_code=404)
68
+
69
+ # ============================================================
70
+ # REST API — Tier 4 (Any language)
71
+ # ============================================================
72
+
73
+ @app.get("/api/config")
74
+ async def get_config():
75
+ """Return current environment schema for discovery."""
76
+ if app.state.sdk_config:
77
+ return app.state.sdk_config
78
+ # Return demo config as default
79
+ sim = MARLSimulator()
80
+ return sim.get_config_payload()
81
+
82
+ @app.post("/api/log")
83
+ async def api_log(request: Request):
84
+ """
85
+ REST endpoint for Tier 4 integration.
86
+ Accepts JSON payloads from any language/framework.
87
+
88
+ curl -X POST http://localhost:8000/api/log \\
89
+ -H "Content-Type: application/json" \\
90
+ -d '{"step": 100, "agents": [...]}'
91
+ """
92
+ body = await request.json()
93
+ payload = {"type": "tick", "data": body} if "type" not in body else body
94
+ _broadcast(app, payload)
95
+ return {"status": "ok", "step": body.get("step")}
96
+
97
+ @app.post("/api/config")
98
+ async def set_config(request: Request):
99
+ """Set the environment config from an external source."""
100
+ body = await request.json()
101
+ app.state.sdk_config = body
102
+ return {"status": "ok"}
103
+
104
+ # ============================================================
105
+ # WebSocket — SDK ingestion endpoint
106
+ # ============================================================
107
+
108
+ @app.websocket("/ws/sdk")
109
+ async def ws_sdk_endpoint(websocket: WebSocket):
110
+ """Receives data from the AlignScope SDK (Python client)."""
111
+ await websocket.accept()
112
+ print("[SERVER] SDK client connected to /ws/sdk")
113
+ try:
114
+ while True:
115
+ data = await websocket.receive_text()
116
+ payload = json.loads(data)
117
+ if payload.get("type") == "config":
118
+ app.state.sdk_config = payload.get("data")
119
+ # Broadcast config to all frontends
120
+ _broadcast(app, {"type": "config", "data": app.state.sdk_config})
121
+ print("[SERVER] SDK config received and broadcast")
122
+ elif payload.get("type") == "tick":
123
+ _broadcast(app, payload)
124
+ except WebSocketDisconnect:
125
+ print("[SERVER] SDK client disconnected from /ws/sdk")
126
+
127
+ # ============================================================
128
+ # WebSocket — Dashboard frontend connection
129
+ # ============================================================
130
+
131
+ @app.websocket("/ws")
132
+ async def websocket_endpoint(websocket: WebSocket):
133
+ await websocket.accept()
134
+ print(f"[SERVER] Frontend connected to /ws (demo_mode={app.state.demo_mode})")
135
+
136
+ if app.state.demo_mode:
137
+ await _run_demo_simulation(websocket)
138
+ else:
139
+ await _run_sdk_relay(websocket, app)
140
+
141
+ return app
142
+
143
+
144
+ def _broadcast(app: FastAPI, payload: dict):
145
+ """Broadcast a payload to all connected frontend queues."""
146
+ for q in app.state.frontend_queues:
147
+ try:
148
+ q.put_nowait(payload)
149
+ except asyncio.QueueFull:
150
+ pass # Drop if a frontend is too slow
151
+
152
+
153
+ async def _run_demo_simulation(websocket: WebSocket):
154
+ """Run the built-in demo simulator and stream to frontend."""
155
+ sim = MARLSimulator(SimulatorConfig(seed=42))
156
+ metrics_engine = AlignmentMetrics()
157
+ detector = DefectionDetector()
158
+
159
+ try:
160
+ # Send config
161
+ await websocket.send_json({
162
+ "type": "config",
163
+ "data": sim.get_config_payload(),
164
+ })
165
+
166
+ while not sim.is_finished:
167
+ # Check for client commands
168
+ try:
169
+ msg = await asyncio.wait_for(websocket.receive_text(), timeout=0.01)
170
+ client_data = json.loads(msg)
171
+ if client_data.get("action") == "restart":
172
+ sim = MARLSimulator(SimulatorConfig(seed=None))
173
+ metrics_engine = AlignmentMetrics()
174
+ detector = DefectionDetector()
175
+ await websocket.send_json({
176
+ "type": "config",
177
+ "data": sim.get_config_payload(),
178
+ })
179
+ await websocket.send_json({"type": "restart"})
180
+ continue
181
+ except asyncio.TimeoutError:
182
+ pass
183
+
184
+ # Advance simulation
185
+ tick_data = sim.step()
186
+ tick_metrics = metrics_engine.update(tick_data)
187
+ events = detector.analyze(tick_data, tick_metrics)
188
+ relationships = sim.get_agent_relationships()
189
+
190
+ payload = {
191
+ "type": "tick",
192
+ "data": {
193
+ "tick": tick_data["tick"],
194
+ "agents": tick_data["agents"],
195
+ "objectives": tick_data["objectives"],
196
+ "team_scores": tick_data["team_scores"],
197
+ "metrics": {
198
+ "agent_metrics": tick_metrics["agent_metrics"],
199
+ "pair_metrics": tick_metrics["pair_metrics"],
200
+ "team_metrics": {
201
+ str(k): v for k, v in tick_metrics["team_metrics"].items()
202
+ },
203
+ "overall_alignment_score": tick_metrics["overall_alignment_score"],
204
+ },
205
+ "relationships": relationships,
206
+ "events": [
207
+ {
208
+ "tick": e["tick"],
209
+ "type": e["type"],
210
+ "agent_id": e.get("agent_id"),
211
+ "team": e.get("team"),
212
+ "severity": e.get("severity", 0.5),
213
+ "description": e["description"],
214
+ }
215
+ for e in events
216
+ ],
217
+ },
218
+ }
219
+
220
+ await websocket.send_json(payload)
221
+ await asyncio.sleep(0.05)
222
+
223
+ await websocket.send_json({
224
+ "type": "episode_complete",
225
+ "data": {
226
+ "total_ticks": sim.tick,
227
+ "defection_summary": detector.get_summary(),
228
+ },
229
+ })
230
+
231
+ except WebSocketDisconnect:
232
+ pass
233
+
234
+
235
+ async def _run_sdk_relay(websocket: WebSocket, app: FastAPI):
236
+ """Relay data from SDK clients to the dashboard frontend.
237
+
238
+ Each frontend connection gets its own asyncio.Queue. The SDK
239
+ ingestion endpoint broadcasts to ALL queues, so every browser
240
+ tab/window receives every tick.
241
+ """
242
+ # Create a dedicated queue for this frontend connection
243
+ my_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
244
+ app.state.frontend_queues.add(my_queue)
245
+ print(f"[SERVER] Frontend relay started (total clients: {len(app.state.frontend_queues)})")
246
+
247
+ try:
248
+ # Send config if available
249
+ if app.state.sdk_config:
250
+ await websocket.send_json({
251
+ "type": "config",
252
+ "data": app.state.sdk_config,
253
+ })
254
+
255
+ while True:
256
+ # Check for client commands (non-blocking)
257
+ try:
258
+ msg = await asyncio.wait_for(websocket.receive_text(), timeout=0.01)
259
+ client_data = json.loads(msg)
260
+ if client_data.get("action") == "restart":
261
+ await websocket.send_json({"type": "restart"})
262
+ continue
263
+ except asyncio.TimeoutError:
264
+ pass
265
+
266
+ # Check for SDK data from this connection's queue
267
+ try:
268
+ payload = my_queue.get_nowait()
269
+ await websocket.send_json(payload)
270
+ except asyncio.QueueEmpty:
271
+ await asyncio.sleep(0.05)
272
+
273
+ except WebSocketDisconnect:
274
+ pass
275
+ finally:
276
+ app.state.frontend_queues.discard(my_queue)
277
+ print(f"[SERVER] Frontend relay stopped (total clients: {len(app.state.frontend_queues)})")
278
+
279
+
280
+ def run_server(host: str = "0.0.0.0", port: int = 8000, demo: bool = False):
281
+ """Start the AlignScope dashboard server."""
282
+ import uvicorn
283
+
284
+ app = create_app(demo=demo)
285
+
286
+ if not FRONTEND_DIR:
287
+ print("⚠️ Frontend files not found. Dashboard UI will not be available.")
288
+ print(" Run from the project root or install via pip.")
289
+
290
+ mode = "demo" if demo else "SDK"
291
+ print(f"\n🔬 AlignScope Dashboard")
292
+ print(f" Mode: {mode}")
293
+ print(f" Dashboard: http://localhost:{port}")
294
+ print(f" REST API: http://localhost:{port}/api/log")
295
+ print(f" WebSocket: ws://localhost:{port}/ws/sdk")
296
+ print()
297
+
298
+ uvicorn.run(app, host=host, port=port, log_level="info")