roshan5emerald commited on
Commit
de6fe49
·
verified ·
1 Parent(s): 47ee65f

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. server/app.py +153 -4
  2. visualisation/logiflow_visualizer.html +217 -125
server/app.py CHANGED
@@ -6,11 +6,14 @@
6
 
7
  """FastAPI application for the LogiFlow-RL OpenEnv environment."""
8
 
 
 
9
  from pathlib import Path
10
- from typing import Optional
11
 
12
- from fastapi import FastAPI
13
  from fastapi.responses import HTMLResponse
 
14
 
15
  try:
16
  from openenv.core.env_server.types import (
@@ -28,7 +31,10 @@ try:
28
  CrisisLogisticsObservation,
29
  CrisisLogisticsState,
30
  )
31
- from .crisis_logistics_env_environment import CrisisLogisticsEnvironment
 
 
 
32
  except ImportError:
33
  from openenv.core.env_server.types import (
34
  EnvironmentMetadata,
@@ -45,7 +51,10 @@ except ImportError:
45
  CrisisLogisticsObservation,
46
  CrisisLogisticsState,
47
  )
48
- from server.crisis_logistics_env_environment import CrisisLogisticsEnvironment
 
 
 
49
 
50
 
51
  app = FastAPI(
@@ -61,6 +70,114 @@ env = CrisisLogisticsEnvironment()
61
  VISUALIZER_PATH = Path(__file__).resolve().parent.parent / "visualisation" / "logiflow_visualizer.html"
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def _read_visualizer_html() -> str:
65
  """Load the standalone visualizer HTML bundled with the project."""
66
  if VISUALIZER_PATH.exists():
@@ -130,6 +247,38 @@ async def step_environment(request: StepRequest) -> StepResponse:
130
  )
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  @app.get("/state", response_model=CrisisLogisticsState, tags=["State Management"])
134
  async def get_state() -> CrisisLogisticsState:
135
  return env.state
 
6
 
7
  """FastAPI application for the LogiFlow-RL OpenEnv environment."""
8
 
9
+ import json
10
+ import os
11
  from pathlib import Path
12
+ from typing import Any, Dict, Literal, Optional
13
 
14
+ from fastapi import FastAPI, HTTPException
15
  from fastapi.responses import HTMLResponse
16
+ from pydantic import BaseModel
17
 
18
  try:
19
  from openenv.core.env_server.types import (
 
31
  CrisisLogisticsObservation,
32
  CrisisLogisticsState,
33
  )
34
+ from .crisis_logistics_env_environment import (
35
+ CrisisLogisticsEnvironment,
36
+ choose_resilient_action,
37
+ )
38
  except ImportError:
39
  from openenv.core.env_server.types import (
40
  EnvironmentMetadata,
 
51
  CrisisLogisticsObservation,
52
  CrisisLogisticsState,
53
  )
54
+ from server.crisis_logistics_env_environment import (
55
+ CrisisLogisticsEnvironment,
56
+ choose_resilient_action,
57
+ )
58
 
59
 
60
  app = FastAPI(
 
70
  VISUALIZER_PATH = Path(__file__).resolve().parent.parent / "visualisation" / "logiflow_visualizer.html"
71
 
72
 
73
+ class PolicyStepRequest(BaseModel):
74
+ mode: Literal["heuristic", "llm"] = "heuristic"
75
+ timeout_s: Optional[float] = None
76
+
77
+
78
+ class PolicyStepResponse(BaseModel):
79
+ observation: Dict[str, Any]
80
+ reward: float
81
+ done: bool
82
+ policy_mode: Literal["heuristic", "llm"]
83
+ action_source: Literal["heuristic", "llm"]
84
+ action: Dict[str, Any]
85
+ llm_model: Optional[str] = None
86
+ llm_raw_output: Optional[str] = None
87
+
88
+
89
+ def _build_policy_prompt(observation: CrisisLogisticsObservation, title: str) -> str:
90
+ return (
91
+ f"Task: {title}\n"
92
+ f"Objective: {observation.objective}\n"
93
+ f"Step: {observation.step_count + 1}/{observation.max_steps}\n"
94
+ f"Visible nodes: {observation.visible_node_ids}\n"
95
+ f"Observed node loads: {observation.observed_node_loads}\n"
96
+ f"Node capacities: {observation.node_capacities}\n"
97
+ f"Visible connectivity: {observation.visible_connectivity}\n"
98
+ f"Active disruptions: {observation.active_disruptions}\n"
99
+ f"In-transit shipments: {observation.in_transit_shipments[:8]}\n"
100
+ f"Incoming shipment: source={observation.pending_source_node}, volume={observation.incoming_load}\n"
101
+ f"Traffic event: {observation.event_label}\n"
102
+ f"Dynamic pressure: {observation.dynamic_pressure}\n"
103
+ f"Priority target: {observation.priority_target_name} (node {observation.priority_target_node})\n"
104
+ "Return exactly one JSON object with keys: reasoning, source_node, dest_node, shipment_volume."
105
+ )
106
+
107
+
108
+ def _extract_json_payload(text: str) -> Dict[str, Any]:
109
+ decoder = json.JSONDecoder()
110
+ candidates = []
111
+ for index, char in enumerate(text):
112
+ if char != "{":
113
+ continue
114
+ try:
115
+ payload, _ = decoder.raw_decode(text[index:])
116
+ except Exception:
117
+ continue
118
+ if isinstance(payload, dict):
119
+ candidates.append(payload)
120
+ if not candidates:
121
+ return {}
122
+ required = {"reasoning", "source_node", "dest_node", "shipment_volume"}
123
+ for payload in reversed(candidates):
124
+ if required.issubset(payload.keys()):
125
+ return payload
126
+ return candidates[-1]
127
+
128
+
129
+ def _resolve_llm_action(observation: CrisisLogisticsObservation) -> tuple[CrisisLogisticsAction, str, str]:
130
+ api_key = os.getenv("HF_TOKEN") or os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY")
131
+ if not api_key:
132
+ raise HTTPException(
133
+ status_code=503,
134
+ detail="LLM mode needs HF_TOKEN or OPENAI_API_KEY set in Space secrets.",
135
+ )
136
+ base_url = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
137
+ model_name = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
138
+
139
+ try:
140
+ from openai import OpenAI
141
+ except Exception as exc:
142
+ raise HTTPException(status_code=500, detail=f"openai client import failed: {exc}") from exc
143
+
144
+ prompt = _build_policy_prompt(observation, env.task.title)
145
+ system_prompt = (
146
+ "You are a logistics routing policy for a crisis supply chain environment. "
147
+ "Always return exactly one JSON object with keys: reasoning, source_node, dest_node, shipment_volume."
148
+ )
149
+
150
+ client = OpenAI(api_key=api_key, base_url=base_url)
151
+ try:
152
+ response = client.chat.completions.create(
153
+ model=model_name,
154
+ temperature=0.0,
155
+ max_tokens=180,
156
+ messages=[
157
+ {"role": "system", "content": system_prompt},
158
+ {"role": "user", "content": prompt},
159
+ ],
160
+ )
161
+ except Exception as exc:
162
+ raise HTTPException(status_code=502, detail=f"LLM request failed: {exc}") from exc
163
+
164
+ raw_text = (response.choices[0].message.content or "").strip()
165
+ payload = _extract_json_payload(raw_text)
166
+ if not payload:
167
+ raise HTTPException(
168
+ status_code=422,
169
+ detail=f"LLM output did not contain valid action JSON. Raw output: {raw_text[:600]}",
170
+ )
171
+ try:
172
+ action = CrisisLogisticsAction(**payload)
173
+ except Exception as exc:
174
+ raise HTTPException(
175
+ status_code=422,
176
+ detail=f"LLM output JSON could not be parsed as action: {exc}. Raw output: {raw_text[:600]}",
177
+ ) from exc
178
+ return action, model_name, raw_text
179
+
180
+
181
  def _read_visualizer_html() -> str:
182
  """Load the standalone visualizer HTML bundled with the project."""
183
  if VISUALIZER_PATH.exists():
 
247
  )
248
 
249
 
250
+ @app.post("/policy_step", response_model=PolicyStepResponse, tags=["Environment Control"])
251
+ async def policy_step(request: PolicyStepRequest) -> PolicyStepResponse:
252
+ """Execute one environment step using either heuristic or strict LLM policy mode."""
253
+ # Build current observation snapshot for policy selection.
254
+ observation = env._get_observation("Policy evaluation snapshot.")
255
+
256
+ if request.mode == "heuristic":
257
+ action = choose_resilient_action(observation)
258
+ policy_mode = "heuristic"
259
+ action_source = "heuristic"
260
+ llm_model = None
261
+ llm_raw_output = None
262
+ elif request.mode == "llm":
263
+ action, llm_model, llm_raw_output = _resolve_llm_action(observation)
264
+ policy_mode = "llm"
265
+ action_source = "llm"
266
+ else:
267
+ raise HTTPException(status_code=400, detail=f"Unsupported policy mode: {request.mode}")
268
+
269
+ next_observation = env.step(action, timeout_s=request.timeout_s)
270
+ return PolicyStepResponse(
271
+ observation=next_observation.model_dump(),
272
+ reward=float(next_observation.reward or 0.0),
273
+ done=next_observation.done,
274
+ policy_mode=policy_mode,
275
+ action_source=action_source,
276
+ action=action.model_dump(exclude_none=True),
277
+ llm_model=llm_model,
278
+ llm_raw_output=llm_raw_output if request.mode == "llm" else None,
279
+ )
280
+
281
+
282
  @app.get("/state", response_model=CrisisLogisticsState, tags=["State Management"])
283
  async def get_state() -> CrisisLogisticsState:
284
  return env.state
visualisation/logiflow_visualizer.html CHANGED
@@ -1,9 +1,9 @@
1
- <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>LogiFlow-RL · Live Environment Visualizer</title>
7
  <style>
8
  @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap');
9
 
@@ -94,6 +94,28 @@
94
  .badge-warning { background: rgba(255,171,0,0.15); color: var(--amber); }
95
  .badge-critical { background: rgba(255,61,87,0.15); color: var(--red); }
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  /* Network SVG */
98
  .network-wrap { position: relative; }
99
  .network-svg { width: 100%; height: 220px; }
@@ -181,7 +203,7 @@
181
  <div class="logo-icon">LF</div>
182
  <div class="logo-text">
183
  <h1>LOGIFLOW-RL</h1>
184
- <p>Live Environment Visualizer · 12-node crisis network</p>
185
  </div>
186
  </div>
187
  <div class="status-pill">
@@ -194,17 +216,17 @@
194
  <div class="controls">
195
  <input class="server-input" id="serverUrl" type="text" value="http://localhost:8000" placeholder="http://localhost:8000 or HuggingFace Space URL" />
196
  <select id="taskSelect">
197
- <option value="easy">Easy Steady-State</option>
198
- <option value="medium">Medium Flash Sale</option>
199
- <option value="hard">Hard Cascading Disruption</option>
200
  </select>
201
- <button class="btn-primary" onclick="connectAndReset()" style="padding:9px 18px; background:var(--accent); color:var(--bg); border:none; border-radius:8px; font-family:var(--sans); font-size:12px; font-weight:600; cursor:pointer;"> Connect &amp; Reset</button>
202
- <button class="btn-auto" id="autoBtn" onclick="toggleAuto()" style="padding:9px 18px; background:var(--surface2); border:1px solid var(--border); color:var(--amber); border-radius:8px; font-family:var(--sans); font-size:12px; font-weight:600; cursor:pointer;"> Auto Play</button>
203
  <button class="btn-secondary" onclick="useDemoMode()" style="padding:9px 18px; background:var(--surface); border:1px solid var(--border); color:var(--text); border-radius:8px; font-family:var(--sans); font-size:12px; font-weight:600; cursor:pointer;">Demo Mode</button>
204
  </div>
205
 
206
  <div class="demo-banner" id="demoBanner" style="display:none;">
207
- DEMO MODE Simulating environment locally. To use live data, start your FastAPI server and click Connect.
208
  </div>
209
 
210
  <div class="main-grid">
@@ -215,10 +237,10 @@
215
  <!-- Hubs -->
216
  <div class="card" style="margin-bottom:16px;">
217
  <div class="card-header">
218
- <span class="card-title">Hub Network · Live Load</span>
219
  <div style="display:flex;align-items:center;gap:10px;">
220
  <span id="eventBadge" class="event-tag event-normal">NORMAL</span>
221
- <span style="font-family:var(--mono);font-size:10px;color:var(--muted);">Step <span id="stepCount"></span> / <span id="maxSteps"></span></span>
222
  </div>
223
  </div>
224
  <div class="card-body">
@@ -230,24 +252,24 @@
230
  <!-- Hub cards -->
231
  <div class="hub-grid" id="hubGrid">
232
  <div class="hub-card" id="hub0">
233
- <div class="hub-name">Hub 0 · Alpha</div>
234
- <div class="hub-load-val" id="hub0val"><span>%</span></div>
235
  <div class="bar-track"><div class="bar-fill green" id="hub0bar" style="width:0%"></div></div>
236
- <div class="hub-drain" id="hub0drain">Drain: %/step</div>
237
  <div class="hub-status-badge badge-optimal" id="hub0badge">OPTIMAL</div>
238
  </div>
239
  <div class="hub-card" id="hub1">
240
- <div class="hub-name">Hub 1 · Beta</div>
241
- <div class="hub-load-val" id="hub1val"><span>%</span></div>
242
  <div class="bar-track"><div class="bar-fill green" id="hub1bar" style="width:0%"></div></div>
243
- <div class="hub-drain" id="hub1drain">Drain: %/step</div>
244
  <div class="hub-status-badge badge-optimal" id="hub1badge">OPTIMAL</div>
245
  </div>
246
  <div class="hub-card" id="hub2">
247
- <div class="hub-name">Hub 2 · Gamma</div>
248
- <div class="hub-load-val" id="hub2val"><span>%</span></div>
249
  <div class="bar-track"><div class="bar-fill green" id="hub2bar" style="width:0%"></div></div>
250
- <div class="hub-drain" id="hub2drain">Drain: %/step</div>
251
  <div class="hub-status-badge badge-optimal" id="hub2badge">OPTIMAL</div>
252
  </div>
253
  </div>
@@ -266,23 +288,23 @@
266
  <!-- Shipment source -->
267
  <rect x="20" y="70" width="80" height="40" rx="8" fill="#1a2236" stroke="#1e2d45" stroke-width="1.5"/>
268
  <text x="60" y="87" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">INCOMING</text>
269
- <text x="60" y="101" text-anchor="middle" fill="#ff6b35" font-family="Space Mono" font-size="13" font-weight="700" id="netIncoming"></text>
270
  <!-- Arrows to hubs -->
271
  <line x1="100" y1="90" x2="190" y2="55" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="line0"/>
272
  <line x1="100" y1="90" x2="190" y2="90" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="line1"/>
273
  <line x1="100" y1="90" x2="190" y2="125" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="line2"/>
274
  <!-- Hub 0 node -->
275
  <rect x="195" y="30" width="100" height="50" rx="8" fill="#111827" stroke="#1e2d45" stroke-width="1.5" id="netHub0"/>
276
- <text x="245" y="51" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">HUB 0 · ALPHA</text>
277
- <text x="245" y="69" text-anchor="middle" fill="#e8edf5" font-family="Space Mono" font-size="15" font-weight="700" id="netHub0val">%</text>
278
  <!-- Hub 1 node -->
279
  <rect x="195" y="65" width="100" height="50" rx="8" fill="#111827" stroke="#1e2d45" stroke-width="1.5" id="netHub1"/>
280
- <text x="245" y="86" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">HUB 1 · BETA</text>
281
- <text x="245" y="104" text-anchor="middle" fill="#e8edf5" font-family="Space Mono" font-size="15" font-weight="700" id="netHub1val">%</text>
282
  <!-- Hub 2 node -->
283
  <rect x="195" y="100" width="100" height="50" rx="8" fill="#111827" stroke="#1e2d45" stroke-width="1.5" id="netHub2"/>
284
- <text x="245" y="121" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">HUB 2 · GAMMA</text>
285
- <text x="245" y="139" text-anchor="middle" fill="#e8edf5" font-family="Space Mono" font-size="15" font-weight="700" id="netHub2val">%</text>
286
  <!-- Arrow to delivery -->
287
  <line x1="295" y1="55" x2="400" y2="90" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="outLine0"/>
288
  <line x1="295" y1="90" x2="400" y2="90" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="outLine1"/>
@@ -290,9 +312,9 @@
290
  <!-- Delivery node -->
291
  <rect x="405" y="65" width="90" height="50" rx="8" fill="#1a2236" stroke="#1e2d45" stroke-width="1.5"/>
292
  <text x="450" y="87" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">DELIVERY</text>
293
- <text x="450" y="103" text-anchor="middle" fill="#00e676" font-family="Space Mono" font-size="11" font-weight="700" id="netScore">SCORE: </text>
294
  <!-- Optimal zone annotation -->
295
- <text x="245" y="162" text-anchor="middle" fill="#1e2d45" font-family="Space Mono" font-size="9">OPTIMAL ZONE: 30–70%</text>
296
  <line x1="200" y1="170" x2="290" y2="170" stroke="#1e2d45" stroke-width="1" stroke-dasharray="3 2"/>
297
  </svg>
298
  </div>
@@ -305,23 +327,25 @@
305
  <div class="card-header">
306
  <span class="card-title">Agent Action</span>
307
  <div class="incoming-panel" style="margin:0;padding:6px 12px;">
308
- <span style="font-size:16px;">📦</span>
309
  <div>
310
- <div class="incoming-val" id="incomingVal" style="font-size:15px;"></div>
311
  <div class="incoming-sub">Incoming load %</div>
312
  </div>
313
  </div>
314
  </div>
315
  <div class="card-body">
316
- <div style="font-size:11px;color:var(--muted);margin-bottom:8px;font-family:var(--mono);">Select route option for the current source node:</div>
317
- <div class="action-btns" id="actionBtns">
318
- <button class="action-btn" id="act0" onclick="selectAction(0)">Route 0<br><span style="font-size:9px;color:var(--muted)" id="act0load">—%</span></button>
319
- <button class="action-btn" id="act1" onclick="selectAction(1)">Route 1<br><span style="font-size:9px;color:var(--muted)" id="act1load">—%</span></button>
320
- <button class="action-btn" id="act2" onclick="selectAction(2)">Route 2<br><span style="font-size:9px;color:var(--muted)" id="act2load">—%</span></button>
 
321
  </div>
322
  <div style="margin-top:10px;">
323
- <button class="submit-btn" id="submitBtn" onclick="sendAction()" disabled> ROUTE SHIPMENT</button>
324
  </div>
 
325
  <div style="margin-top:8px; font-family:var(--mono); font-size:10px; color:var(--muted); min-height:16px;" id="lastMsg"></div>
326
  </div>
327
  </div>
@@ -341,7 +365,7 @@
341
  <path d="M10 65 A55 55 0 0 1 110 65" fill="none" stroke="#00d4ff" stroke-width="8" stroke-linecap="round"
342
  stroke-dasharray="173" stroke-dashoffset="173" id="scoreArc" style="transition:stroke-dashoffset 0.5s;"/>
343
  </svg>
344
- <div class="score-number" id="scoreNum"></div>
345
  </div>
346
  <div class="score-label">CUMULATIVE SCORE</div>
347
  </div>
@@ -349,48 +373,59 @@
349
  <div style="margin-top:10px;">
350
  <div class="stat-row">
351
  <span class="stat-label">Last Reward</span>
352
- <span class="stat-val" id="lastReward"></span>
353
  </div>
354
  <div class="stat-row">
355
  <span class="stat-label">Bottlenecks</span>
356
- <span class="stat-val red" id="bottlenecks"></span>
357
  </div>
358
  <div class="stat-row">
359
- <span class="stat-label">Overloaded Hubs</span>
360
- <span class="stat-val" id="overloaded"></span>
361
  </div>
362
  <div class="stat-row">
363
  <span class="stat-label">Task</span>
364
- <span class="stat-val blue" id="taskName"></span>
365
  </div>
366
  <div class="stat-row">
367
  <span class="stat-label">Difficulty</span>
368
- <span class="stat-val" id="difficulty"></span>
369
  </div>
370
  <div class="stat-row">
371
  <span class="stat-label">In Transit</span>
372
- <span class="stat-val amber" id="inTransit"></span>
373
  </div>
374
  <div class="stat-row">
375
  <span class="stat-label">Retail Delivered</span>
376
- <span class="stat-val green" id="retailDelivered"></span>
377
  </div>
378
  <div class="stat-row">
379
  <span class="stat-label">SLA Success</span>
380
- <span class="stat-val blue" id="slaSuccess"></span>
381
  </div>
382
  <div class="stat-row">
383
  <span class="stat-label">Dynamic Pressure</span>
384
- <span class="stat-val amber" id="dynamicPressure"></span>
385
  </div>
386
  <div class="stat-row">
387
  <span class="stat-label">Priority Target</span>
388
- <span class="stat-val" id="priorityTarget"></span>
389
  </div>
390
  </div>
391
  </div>
392
  </div>
393
 
 
 
 
 
 
 
 
 
 
 
 
394
  <!-- Reward sparkline -->
395
  <div class="card" style="margin-bottom:14px;">
396
  <div class="card-header"><span class="card-title">Reward History</span></div>
@@ -423,9 +458,8 @@
423
  </div>
424
 
425
  <script>
426
- // ── State ──
427
  let obs = null;
428
- let selectedAction = null;
429
  let rewardHistory = [];
430
  let autoInterval = null;
431
  let demoMode = false;
@@ -439,7 +473,7 @@
439
  };
440
  let demoState = null;
441
 
442
- // ── API ──
443
  function serverUrl() { return document.getElementById('serverUrl').value.replace(/\/$/, ''); }
444
  function taskId() { return document.getElementById('taskSelect').value; }
445
 
@@ -451,7 +485,7 @@
451
  return r.json();
452
  }
453
 
454
- // ── Connect & Reset ──
455
  async function connectAndReset() {
456
  demoMode = false;
457
  document.getElementById('demoBanner').style.display = 'none';
@@ -459,7 +493,7 @@
459
  try {
460
  await apiCall('/health');
461
  setStatus(true);
462
- log('Connected ', 'good');
463
  await resetEnv();
464
  } catch(e) {
465
  setStatus(false);
@@ -474,42 +508,51 @@
474
  obs = res.observation;
475
  rewardHistory = [];
476
  demoStep = 0;
477
- selectedAction = null;
478
- updateUI();
479
- log(`Episode reset → task: ${obs.task_id}`, 'info');
480
- document.getElementById('submitBtn').disabled = false;
481
  } catch(e) { log('Reset failed: ' + e.message, 'bad'); }
482
  }
483
 
484
  async function sendAction() {
485
- if (selectedAction === null) return;
486
  try {
487
- const res = await apiCall('/step', 'POST', { action: { target_hub: selectedAction } });
488
  obs = res.observation;
489
  rewardHistory.push(obs.last_reward);
490
- const action = obs.last_action || {};
491
- const routeText = action.source_node !== undefined ? `${action.source_node}→${action.dest_node}` : `option ${selectedAction}`;
492
- log(`Step ${obs.step_count}: ${routeText} | reward ${obs.last_reward.toFixed(3)} | ${obs.event_label}`, obs.last_reward > 0.5 ? 'good' : obs.last_reward > 0.2 ? 'warn' : 'bad');
493
- selectedAction = null;
494
- document.getElementById(`act0`).classList.remove('selected');
495
- document.getElementById(`act1`).classList.remove('selected');
496
- document.getElementById(`act2`).classList.remove('selected');
 
 
 
 
 
 
 
 
497
  updateUI();
498
  if (obs.done) {
499
  log(`Episode complete! Final score: ${obs.cumulative_score.toFixed(3)}`, 'good');
500
  document.getElementById('submitBtn').disabled = true;
501
  stopAuto();
502
  }
503
- } catch(e) { log('Step failed: ' + e.message, 'bad'); }
 
 
 
504
  }
505
-
506
- // ── Demo Mode ──
507
  function useDemoMode() {
508
  demoMode = true;
509
  document.getElementById('demoBanner').style.display = 'flex';
510
  setStatus(true);
511
  resetDemo();
512
- log('Demo mode activated simulating Phase 1 environment', 'info');
513
  }
514
 
515
  function resetDemo() {
@@ -526,11 +569,11 @@
526
  task_id: taskId()
527
  };
528
  rewardHistory = [];
529
- selectedAction = null;
530
- buildDemoObs(0);
531
  updateUI();
532
- document.getElementById('submitBtn').disabled = false;
533
- log(`Demo reset → task: ${taskId()}`, 'info');
 
534
  }
535
 
536
  function buildDemoObs(incoming) {
@@ -546,23 +589,29 @@
546
  last_reward: s.step === 0 ? 0 : reward,
547
  event_label: s.step < s.events.length ? s.events[s.step] : 'normal',
548
  message: `Step ${s.step}: Balance gap ${balanceGap.toFixed(1)}%`,
549
- objective: s.task_id === 'easy' ? 'Keep all hubs in the 30–70% utilization band.' : s.task_id === 'medium' ? 'Absorb the flash-sale surge without any hub overloading.' : 'Stabilize through repeated surge waves and weather disruptions.',
550
  done: s.step >= s.max_steps
551
  };
552
  }
553
 
 
 
 
 
 
 
 
 
554
  function demoAction(hub) {
555
  const s = demoState;
556
  const step = s.step;
557
  if (step >= s.max_steps) return;
558
  const incoming = s.incoming[step];
559
- // apply drain to non-selected hubs
560
  for (let i = 0; i < 3; i++) {
561
  if (i !== hub) s.hub_loads[i] = Math.max(0, s.hub_loads[i] - s.drain_rates[i]);
562
  }
563
- // add incoming to selected hub
564
  s.hub_loads[hub] = Math.min(120, s.hub_loads[hub] + incoming);
565
- const overloaded = s.hub_loads.filter(l => l > 100).length;
566
  if (overloaded > 0) s.bottlenecks++;
567
  const balanceGap = Math.max(...s.hub_loads) - Math.min(...s.hub_loads);
568
  const reward = Math.max(0, 1 - overloaded * 0.3 - balanceGap / 100);
@@ -571,11 +620,7 @@
571
  const nextIncoming = step + 1 < s.incoming.length ? s.incoming[step + 1] : 0;
572
  buildDemoObs(nextIncoming);
573
  rewardHistory.push(reward);
574
- log(`Step ${step+1}: Hub ${hub} | reward ${reward.toFixed(3)} | ${obs.event_label}`, reward > 0.6 ? 'good' : reward > 0.3 ? 'warn' : 'bad');
575
- selectedAction = null;
576
- document.getElementById('act0').classList.remove('selected');
577
- document.getElementById('act1').classList.remove('selected');
578
- document.getElementById('act2').classList.remove('selected');
579
  updateUI();
580
  if (obs.done) {
581
  log(`Episode complete! Final score: ${s.score.toFixed(3)}`, 'good');
@@ -585,52 +630,104 @@
585
  }
586
 
587
  function sendActionWrapper() {
588
- if (selectedAction === null) return;
589
- if (demoMode) { demoAction(selectedAction); }
590
- else { sendAction(); }
 
 
 
 
591
  }
592
  document.getElementById('submitBtn').onclick = sendActionWrapper;
593
 
594
- // Auto-play: picks the lowest-loaded hub
595
  function toggleAuto() {
596
  if (autoInterval) { stopAuto(); return; }
597
  const btn = document.getElementById('autoBtn');
598
- btn.textContent = ' Stop Auto';
599
  btn.style.borderColor = 'var(--amber)';
600
  btn.style.background = 'rgba(255,171,0,0.1)';
601
  autoInterval = setInterval(() => {
602
  if (!obs || obs.done) { stopAuto(); return; }
603
- // Heuristic: pick lowest-loaded hub
604
- const loads = obs.hub_loads;
605
- let best = 0;
606
- for (let i = 1; i < loads.length; i++) { if (loads[i] < loads[best]) best = i; }
607
- selectAction(best);
608
- if (demoMode) demoAction(best);
609
- else sendAction();
610
  }, 900);
611
  }
612
 
613
  function stopAuto() {
614
  if (autoInterval) { clearInterval(autoInterval); autoInterval = null; }
615
  const btn = document.getElementById('autoBtn');
616
- btn.textContent = 'Auto Play';
617
  btn.style.borderColor = 'var(--border)';
618
  btn.style.background = 'var(--surface2)';
619
  }
 
 
 
 
 
 
 
 
620
 
621
- // ── UI ──
622
- function selectAction(i) {
623
- selectedAction = i;
624
- for (let j = 0; j < 3; j++) {
625
- document.getElementById(`act${j}`).classList.toggle('selected', j === i);
626
- }
627
- // highlight network line
628
- for (let j = 0; j < 3; j++) {
629
- const line = document.getElementById(`line${j}`);
630
- line.setAttribute('stroke', j === i ? '#00d4ff' : '#1e2d45');
631
- line.setAttribute('stroke-width', j === i ? '2.5' : '1.5');
632
- line.setAttribute('marker-end', j === i ? 'url(#arrActive)' : 'url(#arr)');
 
 
 
 
 
633
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  }
635
 
636
  function updateUI() {
@@ -644,14 +741,6 @@
644
  const pct = Math.min(load, 120);
645
  document.getElementById(`hub${i}val`).childNodes[0].textContent = load.toFixed(1);
646
  document.getElementById(`hub${i}drain`).textContent = `Drain: ${drains[i]}%/step`;
647
- const source = obs.pending_source_node || 0;
648
- const options = (obs.connectivity && obs.connectivity[String(source)]) || [];
649
- const target = options[i];
650
- const targetText = target !== undefined && obs.node_loads && obs.node_loads[target] !== undefined
651
- ? `N${source}→N${target} ${obs.node_loads[target].toFixed(0)}`
652
- : load.toFixed(1) + '%';
653
- document.getElementById(`act${i}load`).textContent = targetText;
654
-
655
  const bar = document.getElementById(`hub${i}bar`);
656
  bar.style.width = Math.min(pct, 100) + '%';
657
  bar.className = 'bar-fill ' + (load > 100 ? 'red' : load > 70 ? 'amber' : 'green');
@@ -697,10 +786,10 @@
697
  // Event badge
698
  const eb = document.getElementById('eventBadge');
699
  const ev = obs.event_label || 'normal';
700
- if (ev.includes('flash')) { eb.className = 'event-tag event-flash'; eb.textContent = ' FLASH SALE'; }
701
- else if (ev.includes('weather')) { eb.className = 'event-tag event-weather'; eb.textContent = '🌩 WEATHER'; }
702
  else if (ev.includes('supplier')) { eb.className = 'event-tag event-weather'; eb.textContent = 'SUPPLIER RISK'; }
703
- else { eb.className = 'event-tag event-normal'; eb.textContent = ' NORMAL'; }
704
 
705
  // Score arc (173 = half circumference of r=55 arc)
706
  const score = obs.cumulative_score || 0;
@@ -713,18 +802,19 @@
713
  const lrEl = document.getElementById('lastReward');
714
  lrEl.textContent = lr.toFixed(3);
715
  lrEl.className = 'stat-val ' + (lr > 0.6 ? 'green' : lr > 0.3 ? 'amber' : 'red');
716
- document.getElementById('bottlenecks').textContent = obs.overloaded_hubs > 0 ? '' + obs.overloaded_hubs : '0';
717
  document.getElementById('overloaded').textContent = obs.overloaded_hubs;
718
- document.getElementById('taskName').textContent = obs.task_id || '';
719
- document.getElementById('difficulty').textContent = (obs.difficulty || '').toUpperCase();
720
  document.getElementById('inTransit').textContent = (obs.in_transit_shipments || []).length;
721
  document.getElementById('retailDelivered').textContent = (obs.retail_delivered || 0).toFixed(1);
722
  document.getElementById('slaSuccess').textContent = ((obs.sla_success_rate || 0) * 100).toFixed(0) + '%';
723
  document.getElementById('dynamicPressure').textContent = ((obs.dynamic_pressure || 0) * 100).toFixed(0) + '%';
724
- document.getElementById('priorityTarget').textContent = obs.priority_target_name || ('Node ' + (obs.priority_target_node ?? ''));
725
- document.getElementById('objective').textContent = obs.objective || '';
726
  document.getElementById('lastMsg').textContent = obs.message || '';
727
 
 
728
  drawSparkline();
729
  }
730
 
@@ -798,3 +888,5 @@
798
  </body>
799
  </html>
800
 
 
 
 
1
+ <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LogiFlow-RL · Live Environment Visualizer</title>
7
  <style>
8
  @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@300;400;500;600&display=swap');
9
 
 
94
  .badge-warning { background: rgba(255,171,0,0.15); color: var(--amber); }
95
  .badge-critical { background: rgba(255,61,87,0.15); color: var(--red); }
96
 
97
+ /* Full node panel (12-node live state) */
98
+ .node-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
99
+ .node-chip { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px; transition: border-color 0.2s, box-shadow 0.2s, opacity 0.2s; }
100
+ .node-chip.visible { border-color: #335a89; }
101
+ .node-chip.hidden { opacity: 0.58; border-style: dashed; }
102
+ .node-chip.overload { border-color: var(--red); box-shadow: 0 0 10px rgba(255,61,87,0.18); }
103
+ .node-head { display: flex; justify-content: space-between; align-items: center; gap: 6px; margin-bottom: 5px; }
104
+ .node-id { font-family: var(--mono); font-size: 10px; color: var(--muted); }
105
+ .node-type { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; }
106
+ .node-type.supplier { color: #7cc0ff; }
107
+ .node-type.warehouse { color: #9f93ff; }
108
+ .node-type.distribution { color: #ffab00; }
109
+ .node-type.retail { color: #00e676; }
110
+ .node-name { font-size: 10px; color: var(--text); margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
111
+ .node-metrics { display: flex; justify-content: space-between; align-items: center; font-family: var(--mono); font-size: 10px; color: var(--muted); margin-bottom: 5px; }
112
+ .node-meter { height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; }
113
+ .node-meter-fill { height: 100%; transition: width 0.3s ease; }
114
+ .node-meter-fill.ok { background: var(--green); }
115
+ .node-meter-fill.warn { background: var(--amber); }
116
+ .node-meter-fill.bad { background: var(--red); }
117
+ .node-flags { margin-top: 5px; font-family: var(--mono); font-size: 9px; color: var(--amber); min-height: 10px; }
118
+
119
  /* Network SVG */
120
  .network-wrap { position: relative; }
121
  .network-svg { width: 100%; height: 220px; }
 
203
  <div class="logo-icon">LF</div>
204
  <div class="logo-text">
205
  <h1>LOGIFLOW-RL</h1>
206
+ <p>Live Environment Visualizer · 12-node crisis network</p>
207
  </div>
208
  </div>
209
  <div class="status-pill">
 
216
  <div class="controls">
217
  <input class="server-input" id="serverUrl" type="text" value="http://localhost:8000" placeholder="http://localhost:8000 or HuggingFace Space URL" />
218
  <select id="taskSelect">
219
+ <option value="easy">Easy — Steady-State</option>
220
+ <option value="medium">Medium — Flash Sale</option>
221
+ <option value="hard">Hard — Cascading Disruption</option>
222
  </select>
223
+ <button class="btn-primary" onclick="connectAndReset()" style="padding:9px 18px; background:var(--accent); color:var(--bg); border:none; border-radius:8px; font-family:var(--sans); font-size:12px; font-weight:600; cursor:pointer;">⟳ Connect &amp; Reset</button>
224
+ <button class="btn-auto" id="autoBtn" onclick="toggleAuto()" style="padding:9px 18px; background:var(--surface2); border:1px solid var(--border); color:var(--amber); border-radius:8px; font-family:var(--sans); font-size:12px; font-weight:600; cursor:pointer;">â–¶ Auto Play</button>
225
  <button class="btn-secondary" onclick="useDemoMode()" style="padding:9px 18px; background:var(--surface); border:1px solid var(--border); color:var(--text); border-radius:8px; font-family:var(--sans); font-size:12px; font-weight:600; cursor:pointer;">Demo Mode</button>
226
  </div>
227
 
228
  <div class="demo-banner" id="demoBanner" style="display:none;">
229
+ ⚠ DEMO MODE — Simulating environment locally. To use live data, start your FastAPI server and click Connect.
230
  </div>
231
 
232
  <div class="main-grid">
 
237
  <!-- Hubs -->
238
  <div class="card" style="margin-bottom:16px;">
239
  <div class="card-header">
240
+ <span class="card-title">Tier Summary (3 Aggregates)</span>
241
  <div style="display:flex;align-items:center;gap:10px;">
242
  <span id="eventBadge" class="event-tag event-normal">NORMAL</span>
243
+ <span style="font-family:var(--mono);font-size:10px;color:var(--muted);">Step <span id="stepCount">—</span> / <span id="maxSteps">—</span></span>
244
  </div>
245
  </div>
246
  <div class="card-body">
 
252
  <!-- Hub cards -->
253
  <div class="hub-grid" id="hubGrid">
254
  <div class="hub-card" id="hub0">
255
+ <div class="hub-name">Tier 0 · Suppliers</div>
256
+ <div class="hub-load-val" id="hub0val">—<span>%</span></div>
257
  <div class="bar-track"><div class="bar-fill green" id="hub0bar" style="width:0%"></div></div>
258
+ <div class="hub-drain" id="hub0drain">Drain: —%/step</div>
259
  <div class="hub-status-badge badge-optimal" id="hub0badge">OPTIMAL</div>
260
  </div>
261
  <div class="hub-card" id="hub1">
262
+ <div class="hub-name">Tier 1 · Warehouses</div>
263
+ <div class="hub-load-val" id="hub1val">—<span>%</span></div>
264
  <div class="bar-track"><div class="bar-fill green" id="hub1bar" style="width:0%"></div></div>
265
+ <div class="hub-drain" id="hub1drain">Drain: —%/step</div>
266
  <div class="hub-status-badge badge-optimal" id="hub1badge">OPTIMAL</div>
267
  </div>
268
  <div class="hub-card" id="hub2">
269
+ <div class="hub-name">Tier 2 · Downstream</div>
270
+ <div class="hub-load-val" id="hub2val">—<span>%</span></div>
271
  <div class="bar-track"><div class="bar-fill green" id="hub2bar" style="width:0%"></div></div>
272
+ <div class="hub-drain" id="hub2drain">Drain: —%/step</div>
273
  <div class="hub-status-badge badge-optimal" id="hub2badge">OPTIMAL</div>
274
  </div>
275
  </div>
 
288
  <!-- Shipment source -->
289
  <rect x="20" y="70" width="80" height="40" rx="8" fill="#1a2236" stroke="#1e2d45" stroke-width="1.5"/>
290
  <text x="60" y="87" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">INCOMING</text>
291
+ <text x="60" y="101" text-anchor="middle" fill="#ff6b35" font-family="Space Mono" font-size="13" font-weight="700" id="netIncoming">—</text>
292
  <!-- Arrows to hubs -->
293
  <line x1="100" y1="90" x2="190" y2="55" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="line0"/>
294
  <line x1="100" y1="90" x2="190" y2="90" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="line1"/>
295
  <line x1="100" y1="90" x2="190" y2="125" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="line2"/>
296
  <!-- Hub 0 node -->
297
  <rect x="195" y="30" width="100" height="50" rx="8" fill="#111827" stroke="#1e2d45" stroke-width="1.5" id="netHub0"/>
298
+ <text x="245" y="51" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">HUB 0 · ALPHA</text>
299
+ <text x="245" y="69" text-anchor="middle" fill="#e8edf5" font-family="Space Mono" font-size="15" font-weight="700" id="netHub0val">—%</text>
300
  <!-- Hub 1 node -->
301
  <rect x="195" y="65" width="100" height="50" rx="8" fill="#111827" stroke="#1e2d45" stroke-width="1.5" id="netHub1"/>
302
+ <text x="245" y="86" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">HUB 1 · BETA</text>
303
+ <text x="245" y="104" text-anchor="middle" fill="#e8edf5" font-family="Space Mono" font-size="15" font-weight="700" id="netHub1val">—%</text>
304
  <!-- Hub 2 node -->
305
  <rect x="195" y="100" width="100" height="50" rx="8" fill="#111827" stroke="#1e2d45" stroke-width="1.5" id="netHub2"/>
306
+ <text x="245" y="121" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">HUB 2 · GAMMA</text>
307
+ <text x="245" y="139" text-anchor="middle" fill="#e8edf5" font-family="Space Mono" font-size="15" font-weight="700" id="netHub2val">—%</text>
308
  <!-- Arrow to delivery -->
309
  <line x1="295" y1="55" x2="400" y2="90" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="outLine0"/>
310
  <line x1="295" y1="90" x2="400" y2="90" stroke="#1e2d45" stroke-width="1.5" marker-end="url(#arr)" id="outLine1"/>
 
312
  <!-- Delivery node -->
313
  <rect x="405" y="65" width="90" height="50" rx="8" fill="#1a2236" stroke="#1e2d45" stroke-width="1.5"/>
314
  <text x="450" y="87" text-anchor="middle" fill="#5a7090" font-family="Space Mono" font-size="9" font-weight="700">DELIVERY</text>
315
+ <text x="450" y="103" text-anchor="middle" fill="#00e676" font-family="Space Mono" font-size="11" font-weight="700" id="netScore">SCORE: —</text>
316
  <!-- Optimal zone annotation -->
317
+ <text x="245" y="162" text-anchor="middle" fill="#1e2d45" font-family="Space Mono" font-size="9">OPTIMAL ZONE: 30–70%</text>
318
  <line x1="200" y1="170" x2="290" y2="170" stroke="#1e2d45" stroke-width="1" stroke-dasharray="3 2"/>
319
  </svg>
320
  </div>
 
327
  <div class="card-header">
328
  <span class="card-title">Agent Action</span>
329
  <div class="incoming-panel" style="margin:0;padding:6px 12px;">
330
+ <span style="font-size:16px;">📦</span>
331
  <div>
332
+ <div class="incoming-val" id="incomingVal" style="font-size:15px;">—</div>
333
  <div class="incoming-sub">Incoming load %</div>
334
  </div>
335
  </div>
336
  </div>
337
  <div class="card-body">
338
+ <div style="font-size:11px;color:var(--muted);margin-bottom:8px;font-family:var(--mono);">Policy mode:</div>
339
+ <div style="display:flex;gap:8px;margin-bottom:10px;">
340
+ <select id="policyMode" style="flex:1;">
341
+ <option value="heuristic">Heuristic</option>
342
+ <option value="llm">LLM</option>
343
+ </select>
344
  </div>
345
  <div style="margin-top:10px;">
346
+ <button class="submit-btn" id="submitBtn" onclick="sendAction()" disabled>→ RUN POLICY STEP</button>
347
  </div>
348
+ <div style="margin-top:8px; font-family:var(--mono); font-size:10px; color:var(--muted);" id="policyAudit">Source: —</div>
349
  <div style="margin-top:8px; font-family:var(--mono); font-size:10px; color:var(--muted); min-height:16px;" id="lastMsg"></div>
350
  </div>
351
  </div>
 
365
  <path d="M10 65 A55 55 0 0 1 110 65" fill="none" stroke="#00d4ff" stroke-width="8" stroke-linecap="round"
366
  stroke-dasharray="173" stroke-dashoffset="173" id="scoreArc" style="transition:stroke-dashoffset 0.5s;"/>
367
  </svg>
368
+ <div class="score-number" id="scoreNum">—</div>
369
  </div>
370
  <div class="score-label">CUMULATIVE SCORE</div>
371
  </div>
 
373
  <div style="margin-top:10px;">
374
  <div class="stat-row">
375
  <span class="stat-label">Last Reward</span>
376
+ <span class="stat-val" id="lastReward">—</span>
377
  </div>
378
  <div class="stat-row">
379
  <span class="stat-label">Bottlenecks</span>
380
+ <span class="stat-val red" id="bottlenecks">—</span>
381
  </div>
382
  <div class="stat-row">
383
+ <span class="stat-label">Overloaded Nodes</span>
384
+ <span class="stat-val" id="overloaded">—</span>
385
  </div>
386
  <div class="stat-row">
387
  <span class="stat-label">Task</span>
388
+ <span class="stat-val blue" id="taskName">—</span>
389
  </div>
390
  <div class="stat-row">
391
  <span class="stat-label">Difficulty</span>
392
+ <span class="stat-val" id="difficulty">—</span>
393
  </div>
394
  <div class="stat-row">
395
  <span class="stat-label">In Transit</span>
396
+ <span class="stat-val amber" id="inTransit">—</span>
397
  </div>
398
  <div class="stat-row">
399
  <span class="stat-label">Retail Delivered</span>
400
+ <span class="stat-val green" id="retailDelivered">—</span>
401
  </div>
402
  <div class="stat-row">
403
  <span class="stat-label">SLA Success</span>
404
+ <span class="stat-val blue" id="slaSuccess">—</span>
405
  </div>
406
  <div class="stat-row">
407
  <span class="stat-label">Dynamic Pressure</span>
408
+ <span class="stat-val amber" id="dynamicPressure">—</span>
409
  </div>
410
  <div class="stat-row">
411
  <span class="stat-label">Priority Target</span>
412
+ <span class="stat-val" id="priorityTarget">—</span>
413
  </div>
414
  </div>
415
  </div>
416
  </div>
417
 
418
+ <!-- Full node state -->
419
+ <div class="card" style="margin-bottom:14px;">
420
+ <div class="card-header">
421
+ <span class="card-title">12-Node Live State</span>
422
+ <span id="nodeGridMeta" style="font-family:var(--mono);font-size:10px;color:var(--muted);">-</span>
423
+ </div>
424
+ <div class="card-body" style="padding:12px;">
425
+ <div class="node-grid" id="nodeGrid"></div>
426
+ </div>
427
+ </div>
428
+
429
  <!-- Reward sparkline -->
430
  <div class="card" style="margin-bottom:14px;">
431
  <div class="card-header"><span class="card-title">Reward History</span></div>
 
458
  </div>
459
 
460
  <script>
461
+ // ── State ──
462
  let obs = null;
 
463
  let rewardHistory = [];
464
  let autoInterval = null;
465
  let demoMode = false;
 
473
  };
474
  let demoState = null;
475
 
476
+ // ── API ──
477
  function serverUrl() { return document.getElementById('serverUrl').value.replace(/\/$/, ''); }
478
  function taskId() { return document.getElementById('taskSelect').value; }
479
 
 
485
  return r.json();
486
  }
487
 
488
+ // ── Connect & Reset ──
489
  async function connectAndReset() {
490
  demoMode = false;
491
  document.getElementById('demoBanner').style.display = 'none';
 
493
  try {
494
  await apiCall('/health');
495
  setStatus(true);
496
+ log('Connected ✓', 'good');
497
  await resetEnv();
498
  } catch(e) {
499
  setStatus(false);
 
508
  obs = res.observation;
509
  rewardHistory = [];
510
  demoStep = 0;
511
+ updateUI();
512
+ log(`Episode reset → task: ${obs.task_id}`, 'info');
513
+ document.getElementById('submitBtn').disabled = false;
514
+ document.getElementById('policyAudit').textContent = 'Source: -';
515
  } catch(e) { log('Reset failed: ' + e.message, 'bad'); }
516
  }
517
 
518
  async function sendAction() {
519
+ const mode = (document.getElementById('policyMode')?.value || 'heuristic');
520
  try {
521
+ const res = await apiCall('/policy_step', 'POST', { mode: mode });
522
  obs = res.observation;
523
  rewardHistory.push(obs.last_reward);
524
+ const action = res.action || obs.last_action || {};
525
+ const routeText = action.source_node !== undefined
526
+ ? `${action.source_node}->${action.dest_node} vol=${Number(action.shipment_volume || 0).toFixed(1)}`
527
+ : 'unstructured action';
528
+ const audit = res.action_source === 'llm'
529
+ ? `Source: LLM (${res.llm_model || 'model'})`
530
+ : 'Source: Heuristic';
531
+ document.getElementById('policyAudit').textContent = audit;
532
+ if (res.action_source === 'llm' && res.llm_raw_output) {
533
+ log(`LLM raw: ${String(res.llm_raw_output).slice(0, 120).replaceAll('\n', ' ')}`, 'info');
534
+ }
535
+ log(
536
+ `Step ${obs.step_count}: [${(res.action_source || mode).toUpperCase()}] ${routeText} | reward ${obs.last_reward.toFixed(3)} | ${obs.event_label}`,
537
+ obs.last_reward > 0.5 ? 'good' : obs.last_reward > 0.2 ? 'warn' : 'bad'
538
+ );
539
  updateUI();
540
  if (obs.done) {
541
  log(`Episode complete! Final score: ${obs.cumulative_score.toFixed(3)}`, 'good');
542
  document.getElementById('submitBtn').disabled = true;
543
  stopAuto();
544
  }
545
+ } catch(e) {
546
+ document.getElementById('policyAudit').textContent = `Source: ${mode.toUpperCase()} error`;
547
+ log(`Policy step failed (${mode}): ${e.message}`, 'bad');
548
+ }
549
  }
 
 
550
  function useDemoMode() {
551
  demoMode = true;
552
  document.getElementById('demoBanner').style.display = 'flex';
553
  setStatus(true);
554
  resetDemo();
555
+ log('Demo mode activated — simulating Phase 1 environment', 'info');
556
  }
557
 
558
  function resetDemo() {
 
569
  task_id: taskId()
570
  };
571
  rewardHistory = [];
572
+ buildDemoObs(0);
 
573
  updateUI();
574
+ document.getElementById('submitBtn').disabled = false;
575
+ document.getElementById('policyAudit').textContent = 'Source: -';
576
+ log(`Demo reset → task: ${taskId()}`, 'info');
577
  }
578
 
579
  function buildDemoObs(incoming) {
 
589
  last_reward: s.step === 0 ? 0 : reward,
590
  event_label: s.step < s.events.length ? s.events[s.step] : 'normal',
591
  message: `Step ${s.step}: Balance gap ${balanceGap.toFixed(1)}%`,
592
+ objective: s.task_id === 'easy' ? 'Keep all hubs in the 30–70% utilization band.' : s.task_id === 'medium' ? 'Absorb the flash-sale surge without any hub overloading.' : 'Stabilize through repeated surge waves and weather disruptions.',
593
  done: s.step >= s.max_steps
594
  };
595
  }
596
 
597
+ function chooseBestHub(loads) {
598
+ let best = 0;
599
+ for (let i = 1; i < loads.length; i++) {
600
+ if (loads[i] < loads[best]) best = i;
601
+ }
602
+ return best;
603
+ }
604
+
605
  function demoAction(hub) {
606
  const s = demoState;
607
  const step = s.step;
608
  if (step >= s.max_steps) return;
609
  const incoming = s.incoming[step];
 
610
  for (let i = 0; i < 3; i++) {
611
  if (i !== hub) s.hub_loads[i] = Math.max(0, s.hub_loads[i] - s.drain_rates[i]);
612
  }
 
613
  s.hub_loads[hub] = Math.min(120, s.hub_loads[hub] + incoming);
614
+ const overloaded = s.hub_loads.filter((l) => l > 100).length;
615
  if (overloaded > 0) s.bottlenecks++;
616
  const balanceGap = Math.max(...s.hub_loads) - Math.min(...s.hub_loads);
617
  const reward = Math.max(0, 1 - overloaded * 0.3 - balanceGap / 100);
 
620
  const nextIncoming = step + 1 < s.incoming.length ? s.incoming[step + 1] : 0;
621
  buildDemoObs(nextIncoming);
622
  rewardHistory.push(reward);
623
+ log(`Step ${step + 1}: -> Hub ${hub} | reward ${reward.toFixed(3)} | ${obs.event_label}`, reward > 0.6 ? 'good' : reward > 0.3 ? 'warn' : 'bad');
 
 
 
 
624
  updateUI();
625
  if (obs.done) {
626
  log(`Episode complete! Final score: ${s.score.toFixed(3)}`, 'good');
 
630
  }
631
 
632
  function sendActionWrapper() {
633
+ if (demoMode) {
634
+ const loads = Array.isArray(obs?.hub_loads) ? obs.hub_loads : [0, 0, 0];
635
+ const hub = chooseBestHub(loads);
636
+ demoAction(hub);
637
+ return;
638
+ }
639
+ sendAction();
640
  }
641
  document.getElementById('submitBtn').onclick = sendActionWrapper;
642
 
 
643
  function toggleAuto() {
644
  if (autoInterval) { stopAuto(); return; }
645
  const btn = document.getElementById('autoBtn');
646
+ btn.textContent = 'Pause Auto';
647
  btn.style.borderColor = 'var(--amber)';
648
  btn.style.background = 'rgba(255,171,0,0.1)';
649
  autoInterval = setInterval(() => {
650
  if (!obs || obs.done) { stopAuto(); return; }
651
+ if (demoMode) {
652
+ const loads = Array.isArray(obs.hub_loads) ? obs.hub_loads : [0, 0, 0];
653
+ demoAction(chooseBestHub(loads));
654
+ } else {
655
+ sendAction();
656
+ }
 
657
  }, 900);
658
  }
659
 
660
  function stopAuto() {
661
  if (autoInterval) { clearInterval(autoInterval); autoInterval = null; }
662
  const btn = document.getElementById('autoBtn');
663
+ btn.textContent = 'Auto Play';
664
  btn.style.borderColor = 'var(--border)';
665
  btn.style.background = 'var(--surface2)';
666
  }
667
+ function esc(text) {
668
+ return String(text ?? '')
669
+ .replaceAll('&', '&amp;')
670
+ .replaceAll('<', '&lt;')
671
+ .replaceAll('>', '&gt;')
672
+ .replaceAll('"', '&quot;')
673
+ .replaceAll("'", '&#39;');
674
+ }
675
 
676
+ function renderNodeGrid() {
677
+ const grid = document.getElementById('nodeGrid');
678
+ const meta = document.getElementById('nodeGridMeta');
679
+ if (!grid || !meta) return;
680
+
681
+ const nodeLoads = Array.isArray(obs.node_loads) ? obs.node_loads : [];
682
+ const capacities = Array.isArray(obs.node_capacities) ? obs.node_capacities : [];
683
+ const nodeNames = Array.isArray(obs.node_names) ? obs.node_names : [];
684
+ const nodeTypes = Array.isArray(obs.node_types) ? obs.node_types : [];
685
+ const visibleSet = new Set(Array.isArray(obs.visible_node_ids) ? obs.visible_node_ids : []);
686
+ const disruptions = Array.isArray(obs.active_disruptions) ? obs.active_disruptions : [];
687
+ const disruptedSet = new Set(disruptions.map((d) => Number(d.node_id)));
688
+
689
+ if (!nodeLoads.length || !capacities.length) {
690
+ meta.textContent = 'legacy mode';
691
+ grid.innerHTML = '<div class="node-chip" style="grid-column:1/-1;font-size:10px;color:var(--muted);">Live node fields are unavailable in this mode.</div>';
692
+ return;
693
  }
694
+
695
+ const overloadedCount = nodeLoads.filter((load, i) => Number(load) > Number(capacities[i] || 1)).length;
696
+ meta.textContent = `${nodeLoads.length} nodes | ${overloadedCount} overloaded`;
697
+
698
+ const cards = nodeLoads.map((load, index) => {
699
+ const cap = Number(capacities[index] || 1);
700
+ const loadNum = Number(load || 0);
701
+ const util = Math.max(0, (loadNum / Math.max(cap, 1)) * 100);
702
+ const isVisible = !visibleSet.size || visibleSet.has(index);
703
+ const isOverloaded = loadNum > cap;
704
+ const meterClass = util > 100 ? 'bad' : util > 75 ? 'warn' : 'ok';
705
+ const nodeType = String(nodeTypes[index] || 'node');
706
+ const nodeName = String(nodeNames[index] || `Node ${index}`);
707
+ const flags = [];
708
+ if (!isVisible) flags.push('hidden');
709
+ if (disruptedSet.has(index)) flags.push('disrupted');
710
+
711
+ return `
712
+ <div class="node-chip ${isVisible ? 'visible' : 'hidden'} ${isOverloaded ? 'overload' : ''}">
713
+ <div class="node-head">
714
+ <span class="node-id">N${index}</span>
715
+ <span class="node-type ${esc(nodeType)}">${esc(nodeType)}</span>
716
+ </div>
717
+ <div class="node-name" title="${esc(nodeName)}">${esc(nodeName)}</div>
718
+ <div class="node-metrics">
719
+ <span>${loadNum.toFixed(1)}</span>
720
+ <span>${util.toFixed(0)}%</span>
721
+ </div>
722
+ <div class="node-meter">
723
+ <div class="node-meter-fill ${meterClass}" style="width:${Math.min(util, 100)}%"></div>
724
+ </div>
725
+ <div class="node-flags">${flags.length ? flags.join(' | ') : '&nbsp;'}</div>
726
+ </div>
727
+ `;
728
+ });
729
+
730
+ grid.innerHTML = cards.join('');
731
  }
732
 
733
  function updateUI() {
 
741
  const pct = Math.min(load, 120);
742
  document.getElementById(`hub${i}val`).childNodes[0].textContent = load.toFixed(1);
743
  document.getElementById(`hub${i}drain`).textContent = `Drain: ${drains[i]}%/step`;
 
 
 
 
 
 
 
 
744
  const bar = document.getElementById(`hub${i}bar`);
745
  bar.style.width = Math.min(pct, 100) + '%';
746
  bar.className = 'bar-fill ' + (load > 100 ? 'red' : load > 70 ? 'amber' : 'green');
 
786
  // Event badge
787
  const eb = document.getElementById('eventBadge');
788
  const ev = obs.event_label || 'normal';
789
+ if (ev.includes('flash')) { eb.className = 'event-tag event-flash'; eb.textContent = 'âš¡ FLASH SALE'; }
790
+ else if (ev.includes('weather')) { eb.className = 'event-tag event-weather'; eb.textContent = '🌩 WEATHER'; }
791
  else if (ev.includes('supplier')) { eb.className = 'event-tag event-weather'; eb.textContent = 'SUPPLIER RISK'; }
792
+ else { eb.className = 'event-tag event-normal'; eb.textContent = '✓ NORMAL'; }
793
 
794
  // Score arc (173 = half circumference of r=55 arc)
795
  const score = obs.cumulative_score || 0;
 
802
  const lrEl = document.getElementById('lastReward');
803
  lrEl.textContent = lr.toFixed(3);
804
  lrEl.className = 'stat-val ' + (lr > 0.6 ? 'green' : lr > 0.3 ? 'amber' : 'red');
805
+ document.getElementById('bottlenecks').textContent = obs.overloaded_hubs > 0 ? 'âš  ' + obs.overloaded_hubs : '0';
806
  document.getElementById('overloaded').textContent = obs.overloaded_hubs;
807
+ document.getElementById('taskName').textContent = obs.task_id || '—';
808
+ document.getElementById('difficulty').textContent = (obs.difficulty || '—').toUpperCase();
809
  document.getElementById('inTransit').textContent = (obs.in_transit_shipments || []).length;
810
  document.getElementById('retailDelivered').textContent = (obs.retail_delivered || 0).toFixed(1);
811
  document.getElementById('slaSuccess').textContent = ((obs.sla_success_rate || 0) * 100).toFixed(0) + '%';
812
  document.getElementById('dynamicPressure').textContent = ((obs.dynamic_pressure || 0) * 100).toFixed(0) + '%';
813
+ document.getElementById('priorityTarget').textContent = obs.priority_target_name || ('Node ' + (obs.priority_target_node ?? '—'));
814
+ document.getElementById('objective').textContent = obs.objective || '—';
815
  document.getElementById('lastMsg').textContent = obs.message || '';
816
 
817
+ renderNodeGrid();
818
  drawSparkline();
819
  }
820
 
 
888
  </body>
889
  </html>
890
 
891
+
892
+