QuantumTransformer commited on
Commit
11dba38
·
verified ·
1 Parent(s): 28811ce

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -42,9 +42,10 @@ COPY --from=builder /app/env /app/env
42
 
43
  ENV PATH="/app/.venv/bin:$PATH"
44
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
 
 
45
 
46
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
47
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
48
 
49
- ENV ENABLE_WEB_INTERFACE=true
50
  CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
 
42
 
43
  ENV PATH="/app/.venv/bin:$PATH"
44
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
45
+ # Gradio /web UI disabled; use HTML dashboard at /investigate (see server/app.py).
46
+ ENV ENABLE_WEB_INTERFACE=false
47
 
48
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
49
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
50
 
 
51
  CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -6,11 +6,11 @@ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
9
- base_path: /web
10
  tags:
11
  - openenv
12
  - ad-fraud
13
  - reinforcement-learning
 
14
  ---
15
 
16
  # Ad Fraud Investigation Environment
@@ -266,7 +266,7 @@ ad_fraud_env/
266
  | `/tasks` | GET | Task list with configs and action schema |
267
  | `/baseline` | GET | Baseline scores (cached or live) |
268
  | `/grader` | GET | Last episode's grader result |
269
- | `/web` | GET | Auto-generated Gradio UI |
270
 
271
  ## License
272
 
 
6
  sdk: docker
7
  pinned: false
8
  app_port: 8000
 
9
  tags:
10
  - openenv
11
  - ad-fraud
12
  - reinforcement-learning
13
+ base_path: /web
14
  ---
15
 
16
  # Ad Fraud Investigation Environment
 
266
  | `/tasks` | GET | Task list with configs and action schema |
267
  | `/baseline` | GET | Baseline scores (cached or live) |
268
  | `/grader` | GET | Last episode's grader result |
269
+ | `/investigate` | GET | HTML investigation dashboard (also `/` redirects here) |
270
 
271
  ## License
272
 
openenv_ad_fraud_env.egg-info/SOURCES.txt CHANGED
@@ -28,6 +28,7 @@ openenv_ad_fraud_env.egg-info/top_level.txt
28
  server/__init__.py
29
  server/app.py
30
  server/environment.py
 
31
  tests/test_data_generation.py
32
  tests/test_environment.py
33
  tests/test_graders.py
 
28
  server/__init__.py
29
  server/app.py
30
  server/environment.py
31
+ server/investigate_ui.py
32
  tests/test_data_generation.py
33
  tests/test_environment.py
34
  tests/test_graders.py
server/app.py CHANGED
@@ -3,6 +3,8 @@ FastAPI application for the Ad Fraud Investigation Environment.
3
 
4
  Creates the OpenEnv server via create_app() and registers custom HTTP
5
  endpoints required by the hackathon: /tasks, /baseline, /grader.
 
 
6
  """
7
 
8
  from __future__ import annotations
@@ -21,9 +23,13 @@ except ImportError:
21
  from models import AdReviewAction, AdReviewObservation
22
 
23
  from .environment import AdFraudEnvironment, get_last_grader_result
 
24
 
25
  logger = logging.getLogger(__name__)
26
 
 
 
 
27
  app = create_app(
28
  AdFraudEnvironment,
29
  AdReviewAction,
@@ -31,6 +37,8 @@ app = create_app(
31
  env_name="ad_fraud_env",
32
  )
33
 
 
 
34
 
35
  # ------------------------------------------------------------------
36
  # Custom endpoints required by the competition
@@ -97,7 +105,7 @@ async def grader() -> Dict[str, Any]:
97
  result = get_last_grader_result()
98
  if not result:
99
  return {
100
- "error": "No completed episode. Run an episode via WebSocket first.",
101
  "grader_score": None,
102
  }
103
  return result
 
3
 
4
  Creates the OpenEnv server via create_app() and registers custom HTTP
5
  endpoints required by the hackathon: /tasks, /baseline, /grader.
6
+
7
+ Gradio is disabled (ENABLE_WEB_INTERFACE=false); the HTML UI lives at /investigate.
8
  """
9
 
10
  from __future__ import annotations
 
23
  from models import AdReviewAction, AdReviewObservation
24
 
25
  from .environment import AdFraudEnvironment, get_last_grader_result
26
+ from .investigate_ui import register_investigate_ui
27
 
28
  logger = logging.getLogger(__name__)
29
 
30
+ # Do not mount OpenEnv's Gradio stack (single FastAPI process on port 8000).
31
+ os.environ.setdefault("ENABLE_WEB_INTERFACE", "false")
32
+
33
  app = create_app(
34
  AdFraudEnvironment,
35
  AdReviewAction,
 
37
  env_name="ad_fraud_env",
38
  )
39
 
40
+ register_investigate_ui(app)
41
+
42
 
43
  # ------------------------------------------------------------------
44
  # Custom endpoints required by the competition
 
105
  result = get_last_grader_result()
106
  if not result:
107
  return {
108
+ "error": "No completed episode. Run an episode via WebSocket or the /investigate UI.",
109
  "grader_score": None,
110
  }
111
  return result
server/investigate_ui.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pure HTML investigation dashboard: persistent env + JSON API.
3
+
4
+ OpenEnv's HTTP POST /reset and /step spin up a new environment each call,
5
+ so this UI uses a singleton AdFraudEnvironment for multi-step episodes.
6
+ Does not replace /ws or competition endpoints.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any, Dict, Optional
13
+
14
+ from fastapi import Body, FastAPI, HTTPException
15
+ from fastapi.responses import FileResponse, RedirectResponse
16
+ from openenv.core.env_server import serialize_observation
17
+ from pydantic import BaseModel, Field
18
+
19
+ try:
20
+ from ..models import AdReviewAction
21
+ from .environment import AdFraudEnvironment
22
+ except ImportError:
23
+ from models import AdReviewAction
24
+ from server.environment import AdFraudEnvironment
25
+
26
+ _ui_env: Optional[AdFraudEnvironment] = None
27
+
28
+
29
+ def _get_ui_env() -> AdFraudEnvironment:
30
+ global _ui_env
31
+ if _ui_env is None:
32
+ _ui_env = AdFraudEnvironment()
33
+ return _ui_env
34
+
35
+
36
+ class UIResetBody(BaseModel):
37
+ task_id: str = Field(default="task_1")
38
+ seed: int = Field(default=42, ge=0)
39
+
40
+
41
+ def register_investigate_ui(app: FastAPI) -> None:
42
+ static_dir = Path(__file__).resolve().parent / "static"
43
+
44
+ @app.get("/", include_in_schema=False)
45
+ async def root_to_investigate() -> RedirectResponse:
46
+ return RedirectResponse(url="/investigate", status_code=302)
47
+
48
+ @app.get("/investigate", include_in_schema=False)
49
+ async def investigate_page() -> FileResponse:
50
+ path = static_dir / "investigate_hq.html"
51
+ if not path.is_file():
52
+ raise HTTPException(status_code=404, detail="investigate_hq.html missing")
53
+ return FileResponse(path, media_type="text/html; charset=utf-8")
54
+
55
+ @app.post("/investigate/api/reset", tags=["Investigation UI"])
56
+ async def investigate_api_reset(body: UIResetBody) -> Dict[str, Any]:
57
+ env = _get_ui_env()
58
+ obs = env.reset(task_id=body.task_id, seed=body.seed)
59
+ return serialize_observation(obs)
60
+
61
+ @app.post("/investigate/api/step", tags=["Investigation UI"])
62
+ async def investigate_api_step(body: Dict[str, Any] = Body(...)) -> Dict[str, Any]:
63
+ try:
64
+ action = AdReviewAction(**body)
65
+ except Exception as e:
66
+ raise HTTPException(status_code=422, detail=str(e)) from e
67
+ env = _get_ui_env()
68
+ obs = env.step(action)
69
+ return serialize_observation(obs)
70
+
71
+ @app.get("/investigate/api/state", tags=["Investigation UI"])
72
+ async def investigate_api_state() -> Dict[str, Any]:
73
+ return _get_ui_env().state.model_dump()
server/static/investigate_hq.html ADDED
@@ -0,0 +1,741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Ad Fraud Investigation — OpenEnv</title>
7
+ <meta name="description" content="Interactive ad fraud review RL environment — OpenEnv compatible." />
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
9
+ <style>
10
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
11
+ :root {
12
+ --bg: #0b0e17;
13
+ --surface: rgba(255,255,255,0.04);
14
+ --surface-hover: rgba(255,255,255,0.08);
15
+ --border: rgba(255,255,255,0.08);
16
+ --text: #e2e8f0;
17
+ --text-dim: #94a3b8;
18
+ --accent: #6366f1;
19
+ --accent-glow: rgba(99,102,241,0.35);
20
+ --green: #22c55e;
21
+ --green-glow: rgba(34,197,94,0.25);
22
+ --amber: #f59e0b;
23
+ --amber-glow: rgba(245,158,11,0.25);
24
+ --red: #ef4444;
25
+ --red-glow: rgba(239,68,68,0.2);
26
+ --cyan: #06b6d4;
27
+ --radius: 16px;
28
+ --radius-sm: 10px;
29
+ }
30
+ body {
31
+ font-family: 'Inter', -apple-system, sans-serif;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ min-height: 100vh;
35
+ overflow-x: hidden;
36
+ }
37
+ body::before, body::after {
38
+ content: '';
39
+ position: fixed;
40
+ border-radius: 50%;
41
+ filter: blur(120px);
42
+ opacity: 0.28;
43
+ pointer-events: none;
44
+ z-index: 0;
45
+ }
46
+ body::before {
47
+ width: 600px; height: 600px;
48
+ background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
49
+ top: -200px; left: -100px;
50
+ animation: float1 20s ease-in-out infinite;
51
+ }
52
+ body::after {
53
+ width: 500px; height: 500px;
54
+ background: radial-gradient(circle, var(--cyan) 0%, transparent 70%);
55
+ bottom: -150px; right: -100px;
56
+ animation: float2 25s ease-in-out infinite;
57
+ }
58
+ @keyframes float1 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(80px,60px)} }
59
+ @keyframes float2 { 0%,100%{transform:translate(0,0)} 50%{transform:translate(-60px,-80px)} }
60
+ .container { max-width: 1320px; margin: 0 auto; padding: 24px 20px; position: relative; z-index: 1; }
61
+ header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; flex-wrap: wrap; gap: 16px; }
62
+ .logo { display: flex; align-items: center; gap: 14px; }
63
+ .logo-icon {
64
+ width: 48px; height: 48px;
65
+ background: linear-gradient(135deg, var(--accent), var(--cyan));
66
+ border-radius: 14px;
67
+ display: grid; place-items: center;
68
+ font-size: 22px;
69
+ box-shadow: 0 4px 20px var(--accent-glow);
70
+ }
71
+ .logo h1 {
72
+ font-size: 1.28rem;
73
+ font-weight: 700;
74
+ background: linear-gradient(135deg, #fff 30%, var(--cyan));
75
+ -webkit-background-clip: text;
76
+ -webkit-text-fill-color: transparent;
77
+ background-clip: text;
78
+ }
79
+ .logo span {
80
+ display: block;
81
+ font-size: 0.75rem;
82
+ color: var(--text-dim);
83
+ -webkit-text-fill-color: var(--text-dim);
84
+ }
85
+ .header-badges { display: flex; gap: 8px; flex-wrap: wrap; }
86
+ .badge {
87
+ padding: 6px 14px;
88
+ border-radius: 999px;
89
+ font-size: 0.7rem;
90
+ font-weight: 600;
91
+ letter-spacing: 0.5px;
92
+ text-transform: uppercase;
93
+ }
94
+ .badge-accent { background: var(--accent-glow); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.3); }
95
+ .badge-green { background: var(--green-glow); color: #86efac; border: 1px solid rgba(34,197,94,0.3); display: flex; align-items: center; gap: 6px; }
96
+ .pulse { width: 8px; height: 8px; background: var(--green); border-radius: 50%; animation: pulse 2s ease-in-out infinite; }
97
+ @keyframes pulse { 0%,100%{box-shadow:0 0 0 0 var(--green-glow)} 50%{box-shadow:0 0 0 8px transparent} }
98
+ .stats-row {
99
+ display: grid;
100
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
101
+ gap: 14px;
102
+ margin-bottom: 22px;
103
+ }
104
+ .stat-card {
105
+ background: var(--surface);
106
+ backdrop-filter: blur(20px);
107
+ border: 1px solid var(--border);
108
+ border-radius: var(--radius);
109
+ padding: 18px 20px;
110
+ transition: transform 0.25s ease;
111
+ }
112
+ .stat-card:hover { transform: translateY(-2px); background: var(--surface-hover); }
113
+ .stat-label { font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); margin-bottom: 6px; }
114
+ .stat-value { font-size: 1.65rem; font-weight: 800; }
115
+ .stat-value.accent { color: var(--accent); }
116
+ .stat-value.green { color: var(--green); }
117
+ .stat-value.amber { color: var(--amber); }
118
+ .stat-value.cyan { color: var(--cyan); }
119
+ .stat-value.red { color: var(--red); }
120
+ .stat-value.pink { color: #f472b6; }
121
+ .cum-panel {
122
+ background: var(--surface);
123
+ border: 1px solid var(--border);
124
+ border-radius: var(--radius);
125
+ padding: 16px 20px;
126
+ margin-bottom: 22px;
127
+ }
128
+ .cum-panel h3 { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); margin-bottom: 10px; }
129
+ .cum-panel svg { width: 100%; max-width: 640px; height: 120px; display: block; }
130
+ .control-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
131
+ .btn {
132
+ display: inline-flex; align-items: center; gap: 8px;
133
+ padding: 12px 22px;
134
+ border: none;
135
+ border-radius: var(--radius-sm);
136
+ font-family: inherit;
137
+ font-size: 0.82rem;
138
+ font-weight: 600;
139
+ cursor: pointer;
140
+ transition: all 0.25s ease;
141
+ }
142
+ .btn-primary {
143
+ background: linear-gradient(135deg, var(--accent), #818cf8);
144
+ color: #fff;
145
+ box-shadow: 0 4px 20px var(--accent-glow);
146
+ }
147
+ .btn-success { background: linear-gradient(135deg, #059669, var(--green)); color: #fff; box-shadow: 0 4px 20px var(--green-glow); }
148
+ .btn-amber { background: linear-gradient(135deg, #d97706, var(--amber)); color: #fff; box-shadow: 0 4px 20px var(--amber-glow); }
149
+ .btn-ghost { background: var(--surface); color: var(--text); border: 1px solid var(--border); }
150
+ .btn:disabled { opacity: 0.45; cursor: not-allowed; }
151
+ .main-grid { display: grid; grid-template-columns: 1fr 400px; gap: 20px; }
152
+ @media (max-width: 1024px) { .main-grid { grid-template-columns: 1fr; } }
153
+ .panel {
154
+ background: var(--surface);
155
+ backdrop-filter: blur(20px);
156
+ border: 1px solid var(--border);
157
+ border-radius: var(--radius);
158
+ overflow: hidden;
159
+ margin-bottom: 18px;
160
+ }
161
+ .panel-header {
162
+ display: flex; align-items: center; justify-content: space-between;
163
+ padding: 16px 20px;
164
+ border-bottom: 1px solid var(--border);
165
+ }
166
+ .panel-title { font-size: 0.9rem; font-weight: 700; }
167
+ .panel-body { padding: 18px 20px; }
168
+ .ad-queue { display: flex; flex-wrap: wrap; gap: 10px; }
169
+ .ad-chip {
170
+ padding: 10px 16px;
171
+ border-radius: var(--radius-sm);
172
+ border: 1px solid var(--border);
173
+ font-size: 0.82rem;
174
+ font-weight: 600;
175
+ color: var(--text-dim);
176
+ display: inline-flex; align-items: center; gap: 8px;
177
+ }
178
+ .ad-chip.focus { border-color: var(--amber); color: var(--cyan); box-shadow: 0 0 0 1px var(--amber-glow); }
179
+ .ad-chip.approved { border-color: var(--green); color: var(--green); }
180
+ .ad-chip.rejected { border-color: var(--red); color: var(--red); }
181
+ .ad-chip.escalated { border-color: var(--cyan); color: var(--cyan); }
182
+ .dot { width: 8px; height: 8px; border-radius: 50%; }
183
+ .profile-meta { display: flex; gap: 24px; flex-wrap: wrap; margin-bottom: 14px; }
184
+ .pm-label { font-size: 0.65rem; text-transform: uppercase; color: var(--text-dim); letter-spacing: 0.8px; }
185
+ .pm-value { font-size: 0.95rem; font-weight: 600; margin-top: 4px; }
186
+ .ad-copy {
187
+ background: rgba(0,0,0,0.25);
188
+ border-left: 3px solid var(--cyan);
189
+ padding: 14px 18px;
190
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
191
+ font-style: italic;
192
+ color: var(--text-dim);
193
+ line-height: 1.55;
194
+ font-size: 0.88rem;
195
+ }
196
+ .inv-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
197
+ @media (max-width: 768px) { .inv-grid { grid-template-columns: repeat(2, 1fr); } }
198
+ .inv-card {
199
+ border: 1px solid var(--border);
200
+ border-radius: var(--radius-sm);
201
+ padding: 12px 14px;
202
+ min-height: 88px;
203
+ position: relative;
204
+ overflow: hidden;
205
+ background: rgba(0,0,0,0.15);
206
+ }
207
+ .inv-card.revealed { border-color: rgba(99,102,241,0.45); }
208
+ .inv-card.locked .inv-inner { filter: blur(4px); opacity: 0.2; }
209
+ .inv-card.locked::after {
210
+ content: '';
211
+ position: absolute;
212
+ inset: 0;
213
+ background: repeating-linear-gradient(-45deg, transparent, transparent 5px, rgba(255,255,255,0.04) 5px, rgba(255,255,255,0.04) 10px);
214
+ pointer-events: none;
215
+ }
216
+ .inv-label { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; color: var(--accent); margin-bottom: 6px; }
217
+ .inv-card.locked .inv-label { color: var(--text-dim); }
218
+ .inv-content { font-size: 0.75rem; line-height: 1.4; color: var(--text); }
219
+ .lock-icon { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 1.25rem; z-index: 2; }
220
+ .action-form { display: flex; flex-direction: column; gap: 14px; }
221
+ .form-group label {
222
+ display: block;
223
+ font-size: 0.68rem; font-weight: 600; text-transform: uppercase;
224
+ letter-spacing: 0.8px; color: var(--text-dim); margin-bottom: 6px;
225
+ }
226
+ .form-group select, .form-group input, .form-group textarea {
227
+ width: 100%;
228
+ padding: 11px 14px;
229
+ background: rgba(15, 23, 42, 0.95);
230
+ border: 1px solid var(--border);
231
+ border-radius: var(--radius-sm);
232
+ color: #f1f5f9;
233
+ font-family: inherit;
234
+ font-size: 0.85rem;
235
+ outline: none;
236
+ }
237
+ .control-bar select.control-select {
238
+ padding: 11px 14px;
239
+ border-radius: var(--radius-sm);
240
+ font-size: 0.85rem;
241
+ }
242
+ .control-bar select.control-select,
243
+ .form-group select {
244
+ background-color: #0f172a;
245
+ color: #f1f5f9;
246
+ border: 1px solid rgba(148, 163, 184, 0.45);
247
+ }
248
+ select.control-select option,
249
+ .form-group select option {
250
+ background-color: #0f172a;
251
+ color: #f1f5f9;
252
+ }
253
+ .form-group textarea { min-height: 120px; resize: vertical; line-height: 1.5; }
254
+ .form-group select:focus, .form-group input:focus, .form-group textarea:focus {
255
+ border-color: var(--accent);
256
+ box-shadow: 0 0 0 3px var(--accent-glow);
257
+ }
258
+ .log-area {
259
+ max-height: 220px;
260
+ overflow-y: auto;
261
+ font-family: ui-monospace, monospace;
262
+ font-size: 0.72rem;
263
+ }
264
+ .log-entry { padding: 8px 10px; border-radius: 6px; margin-bottom: 4px; background: rgba(0,0,0,0.2); color: var(--text-dim); }
265
+ .log-entry.ok { color: var(--green); }
266
+ .log-entry.bad { color: var(--red); }
267
+ .verdict-row {
268
+ display: flex; justify-content: space-between; align-items: center;
269
+ padding: 10px 12px;
270
+ border: 1px solid var(--border);
271
+ border-radius: var(--radius-sm);
272
+ margin-bottom: 6px;
273
+ font-size: 0.82rem;
274
+ }
275
+ .v-badge { padding: 3px 10px; border-radius: 999px; font-size: 0.62rem; font-weight: 700; text-transform: uppercase; }
276
+ .v-badge.approve { background: var(--green-glow); color: var(--green); }
277
+ .v-badge.reject { background: var(--red-glow); color: var(--red); }
278
+ .v-badge.escalate { background: var(--accent-glow); color: #a5b4fc; }
279
+ .toast-container { position: fixed; bottom: 24px; right: 24px; z-index: 1000; display: flex; flex-direction: column; gap: 8px; }
280
+ .toast {
281
+ padding: 14px 20px;
282
+ border-radius: var(--radius-sm);
283
+ font-size: 0.82rem;
284
+ max-width: 360px;
285
+ animation: slideIn 0.3s ease;
286
+ }
287
+ .toast.success { background: rgba(34,197,94,0.2); border: 1px solid rgba(34,197,94,0.35); color: #86efac; }
288
+ .toast.error { background: rgba(239,68,68,0.2); border: 1px solid rgba(239,68,68,0.35); color: #fca5a5; }
289
+ .toast.info { background: rgba(6,182,212,0.15); border: 1px solid rgba(6,182,212,0.35); color: #67e8f9; }
290
+ @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
291
+ footer { margin-top: 36px; text-align: center; padding: 20px; font-size: 0.72rem; color: var(--text-dim); border-top: 1px solid var(--border); }
292
+ footer a { color: var(--accent); text-decoration: none; }
293
+ .hidden { display: none !important; }
294
+ .feedback-strip { padding: 12px 16px; border-radius: var(--radius-sm); border: 1px solid var(--border); margin-bottom: 16px; font-size: 0.88rem; }
295
+ </style>
296
+ </head>
297
+ <body>
298
+ <div class="container">
299
+ <header>
300
+ <div class="logo">
301
+ <div class="logo-icon">&#128269;</div>
302
+ <div>
303
+ <h1>Ad Fraud Investigation</h1>
304
+ <span>OpenEnv interactive environment</span>
305
+ </div>
306
+ </div>
307
+ <div class="header-badges">
308
+ <span class="badge badge-accent">OpenEnv</span>
309
+ <span class="badge badge-green"><span class="pulse"></span> Live</span>
310
+ </div>
311
+ </header>
312
+
313
+ <div class="stats-row">
314
+ <div class="stat-card"><div class="stat-label">Total ads</div><div class="stat-value" id="st-total">-</div></div>
315
+ <div class="stat-card"><div class="stat-label">Reviewed</div><div class="stat-value green" id="st-reviewed">-</div></div>
316
+ <div class="stat-card"><div class="stat-label">Budget left</div><div class="stat-value pink" id="st-budget">-</div></div>
317
+ <div class="stat-card"><div class="stat-label">Step</div><div class="stat-value amber" id="st-step">-</div></div>
318
+ <div class="stat-card"><div class="stat-label">Env score</div><div class="stat-value cyan" id="st-score">-</div></div>
319
+ <div class="stat-card"><div class="stat-label">Cumulative reward</div><div class="stat-value" id="st-cum">-</div></div>
320
+ </div>
321
+
322
+ <div class="cum-panel">
323
+ <h3>Cumulative reward trajectory</h3>
324
+ <div id="cum-chart"></div>
325
+ </div>
326
+
327
+ <div class="control-bar">
328
+ <select id="task-select" class="control-select" aria-label="Task">
329
+ <option value="task_1">Task 1 — Basic triage</option>
330
+ <option value="task_2">Task 2 — Sophisticated fraud</option>
331
+ <option value="task_3">Task 3 — Fraud networks</option>
332
+ </select>
333
+ <button class="btn btn-primary" id="btn-reset">Reset environment</button>
334
+ <button class="btn btn-success" id="btn-step" disabled>Execute action</button>
335
+ <button class="btn btn-amber" id="btn-score">Get grader score</button>
336
+ <button class="btn btn-ghost" id="btn-baseline">Load baseline JSON</button>
337
+ <button class="btn btn-ghost" onclick="window.open('/docs','_blank')">API docs</button>
338
+ </div>
339
+
340
+ <div class="feedback-strip" id="feedback">Select a task and reset to begin.</div>
341
+
342
+ <div class="main-grid">
343
+ <div>
344
+ <div class="panel">
345
+ <div class="panel-header"><span class="panel-title">Ad queue</span></div>
346
+ <div class="panel-body"><div class="ad-queue" id="ad-queue"></div></div>
347
+ </div>
348
+ <div class="panel">
349
+ <div class="panel-header"><span class="panel-title">Subject profile</span></div>
350
+ <div class="panel-body" id="profile-body"></div>
351
+ </div>
352
+ <div class="panel">
353
+ <div class="panel-header"><span class="panel-title">Investigation findings</span></div>
354
+ <div class="panel-body"><div class="inv-grid" id="findings-grid"></div></div>
355
+ </div>
356
+ <div class="panel">
357
+ <div class="panel-header">
358
+ <span class="panel-title">RL intelligence log</span>
359
+ <button class="btn btn-ghost" style="padding:6px 12px;font-size:0.7rem;" id="btn-clear-log">Clear</button>
360
+ </div>
361
+ <div class="panel-body"><div class="log-area" id="log-area"></div></div>
362
+ </div>
363
+ </div>
364
+ <div>
365
+ <div class="panel">
366
+ <div class="panel-header"><span class="panel-title">Take action</span></div>
367
+ <div class="panel-body">
368
+ <div class="action-form">
369
+ <div class="form-group">
370
+ <label>Action type</label>
371
+ <select id="act-type">
372
+ <option value="investigate">Investigate</option>
373
+ <option value="verdict">Verdict</option>
374
+ <option value="link_accounts">Link accounts</option>
375
+ </select>
376
+ </div>
377
+ <div class="form-group">
378
+ <label>Ad ID</label>
379
+ <select id="act-ad"></select>
380
+ </div>
381
+ <div class="form-group" id="grp-target">
382
+ <label>Investigation target</label>
383
+ <select id="act-target">
384
+ <option value="advertiser_history">advertiser_history</option>
385
+ <option value="landing_page">landing_page</option>
386
+ <option value="payment_method">payment_method</option>
387
+ <option value="targeting_overlap">targeting_overlap</option>
388
+ <option value="creative_similarity">creative_similarity</option>
389
+ <option value="campaign_structure">campaign_structure</option>
390
+ </select>
391
+ </div>
392
+ <div class="form-group hidden" id="grp-verdict">
393
+ <label>Verdict</label>
394
+ <select id="act-verdict">
395
+ <option value="approve">approve</option>
396
+ <option value="reject">reject</option>
397
+ <option value="escalate">escalate</option>
398
+ </select>
399
+ </div>
400
+ <div class="form-group hidden" id="grp-conf">
401
+ <label>Confidence (0-1)</label>
402
+ <input type="number" id="act-conf" min="0" max="1" step="0.05" value="0.85" />
403
+ </div>
404
+ <div class="form-group hidden" id="grp-link">
405
+ <label>Linked ad ID</label>
406
+ <select id="act-linked"></select>
407
+ </div>
408
+ <div class="form-group hidden" id="grp-reason">
409
+ <label>Link reason</label>
410
+ <textarea id="act-reason" placeholder="Why are these ads connected? (e.g. shared payment ID, same template hash...)"></textarea>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ </div>
415
+ <div class="panel">
416
+ <div class="panel-header"><span class="panel-title">Verdict history</span></div>
417
+ <div class="panel-body" id="verdict-list"></div>
418
+ </div>
419
+ <div class="panel">
420
+ <div class="panel-header"><span class="panel-title">Benchmarks (cached)</span></div>
421
+ <div class="panel-body" id="bench-body" style="font-size:0.78rem;color:var(--text-dim);">Click &quot;Load baseline JSON&quot; to fetch /baseline.</div>
422
+ </div>
423
+ </div>
424
+ </div>
425
+
426
+ <footer>
427
+ Pure HTML UI at <code>/investigate</code> &mdash;
428
+ <a href="/schema">Schema</a> &middot; <a href="/tasks">Tasks</a> &middot; <a href="/grader">Grader</a>
429
+ </footer>
430
+ </div>
431
+ <div class="toast-container" id="toasts"></div>
432
+
433
+ <script>
434
+ const API = '';
435
+ const TARGETS = ['advertiser_history','landing_page','payment_method','targeting_overlap','creative_similarity','campaign_structure'];
436
+ const TARGET_LABELS = {
437
+ advertiser_history: 'ADVERTISER',
438
+ landing_page: 'LANDING PAGE',
439
+ payment_method: 'PAYMENT',
440
+ targeting_overlap: 'TARGETING',
441
+ creative_similarity: 'CREATIVE',
442
+ campaign_structure: 'CAMPAIGN'
443
+ };
444
+ const FINDING_RE = /^\[(ad_\d+)\s*\/\s*([a-z_]+)\]/;
445
+
446
+ let lastObs = null;
447
+ let verdicts = {};
448
+ let cumReward = 0;
449
+ let cumHistory = [];
450
+ let maxBudget = 0;
451
+ let uiStep = 0;
452
+ let episodeDone = false;
453
+
454
+ function toast(msg, type) {
455
+ const c = document.getElementById('toasts');
456
+ const t = document.createElement('div');
457
+ t.className = 'toast ' + (type || 'info');
458
+ t.textContent = msg;
459
+ c.appendChild(t);
460
+ setTimeout(() => t.remove(), 3200);
461
+ }
462
+
463
+ function logLine(msg, cls) {
464
+ const a = document.getElementById('log-area');
465
+ const d = document.createElement('div');
466
+ d.className = 'log-entry ' + (cls || '');
467
+ d.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
468
+ a.appendChild(d);
469
+ a.scrollTop = a.scrollHeight;
470
+ }
471
+
472
+ function parseFindings(raw) {
473
+ const out = {};
474
+ if (!raw) return out;
475
+ let curAd = null, curTgt = null, lines = [];
476
+ raw.split('\n').forEach(line => {
477
+ const m = line.trim().match(FINDING_RE);
478
+ if (m) {
479
+ if (curAd && curTgt) {
480
+ if (!out[curAd]) out[curAd] = {};
481
+ out[curAd][curTgt] = lines.join('\n').trim();
482
+ }
483
+ curAd = m[1]; curTgt = m[2]; lines = [];
484
+ } else lines.push(line);
485
+ });
486
+ if (curAd && curTgt) {
487
+ if (!out[curAd]) out[curAd] = {};
488
+ out[curAd][curTgt] = lines.join('\n').trim();
489
+ }
490
+ return out;
491
+ }
492
+
493
+ function focusedFromInfo(info) {
494
+ const m = info && info.match(/Ad in Focus:\s*(ad_\d+)/);
495
+ return m ? m[1] : null;
496
+ }
497
+
498
+ function renderStats(obs) {
499
+ const qs = obs.queue_status || {};
500
+ document.getElementById('st-total').textContent = qs.total_ads ?? '-';
501
+ document.getElementById('st-reviewed').textContent = qs.reviewed ?? '-';
502
+ document.getElementById('st-budget').textContent = qs.investigation_budget ?? qs.steps_remaining ?? '-';
503
+ document.getElementById('st-step').textContent = maxBudget ? (uiStep + ' / ' + maxBudget) : String(uiStep);
504
+ document.getElementById('st-score').textContent = '-';
505
+ const el = document.getElementById('st-cum');
506
+ el.textContent = (cumReward >= 0 ? '+' : '') + cumReward.toFixed(2);
507
+ el.className = 'stat-value ' + (cumReward >= 0 ? 'green' : 'red');
508
+ }
509
+
510
+ function renderCumChart() {
511
+ const host = document.getElementById('cum-chart');
512
+ if (!cumHistory.length) {
513
+ host.innerHTML = '<p style="color:var(--text-dim);font-size:0.85rem;">No steps yet.</p>';
514
+ return;
515
+ }
516
+ const w = 560, h = 110, pad = 10;
517
+ const vals = cumHistory.slice();
518
+ let mn = Math.min(...vals), mx = Math.max(...vals);
519
+ if (mn === mx) { mn -= 0.05; mx += 0.05; }
520
+ const n = vals.length;
521
+ const pts = vals.map((v, i) => {
522
+ const x = pad + (n <= 1 ? 0 : i / (n - 1)) * (w - 2 * pad);
523
+ const y = h - pad - ((v - mn) / (mx - mn)) * (h - 2 * pad);
524
+ return x + ',' + y;
525
+ }).join(' ');
526
+ const col = vals[vals.length - 1] >= 0 ? '#22c55e' : '#ef4444';
527
+ host.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="xMidYMid meet"><rect width="' + w + '" height="' + h + '" fill="rgba(0,0,0,0.25)" rx="8"/><polyline fill="none" stroke="' + col + '" stroke-width="2.5" points="' + pts + '"/></svg>';
528
+ }
529
+
530
+ function renderQueue(obs) {
531
+ const ads = obs.available_ads || [];
532
+ const focused = focusedFromInfo(obs.current_ad_info || '');
533
+ const ids = [...new Set([...ads, ...Object.keys(verdicts)])].sort();
534
+ const el = document.getElementById('ad-queue');
535
+ el.innerHTML = '';
536
+ ids.forEach(id => {
537
+ const d = document.createElement('div');
538
+ let cls = 'ad-chip';
539
+ if (id === focused) cls += ' focus';
540
+ else if (verdicts[id]) cls += ' ' + (verdicts[id].verdict || '');
541
+ d.className = cls;
542
+ d.innerHTML = id + ' <span class="dot" style="background:' + (id === focused ? 'var(--amber)' : verdicts[id] ? 'var(--green)' : 'var(--text-dim)') + '"></span>';
543
+ el.appendChild(d);
544
+ });
545
+ if (!ids.length) el.innerHTML = '<span style="color:var(--text-dim)">Reset to load queue.</span>';
546
+ }
547
+
548
+ function renderProfile(obs) {
549
+ const info = obs.current_ad_info || '';
550
+ const body = document.getElementById('profile-body');
551
+ if (!info) {
552
+ body.innerHTML = '<p style="color:var(--text-dim)">No ad in focus.</p>';
553
+ return;
554
+ }
555
+ const fid = focusedFromInfo(info);
556
+ const cat = (info.match(/Category:\s*(.+)/) || [])[1] || '';
557
+ const risk = (info.match(/Risk signals:\s*(.+)/) || [])[1] || '';
558
+ const copy = (info.match(/Ad copy:\s*(.+)/) || [])[1] || '';
559
+ body.innerHTML =
560
+ '<div style="font-size:1.4rem;font-weight:800;margin-bottom:12px">' + (fid || '') + '</div>' +
561
+ '<div class="profile-meta">' +
562
+ '<div><div class="pm-label">Category</div><div class="pm-value">' + esc(cat) + '</div></div>' +
563
+ '<div><div class="pm-label">Risk</div><div class="pm-value">' + esc(risk || '—') + '</div></div></div>' +
564
+ (copy ? '<div class="ad-copy">' + esc(copy) + '</div>' : '');
565
+ }
566
+
567
+ function esc(s) {
568
+ const d = document.createElement('div');
569
+ d.textContent = s;
570
+ return d.innerHTML;
571
+ }
572
+
573
+ function renderFindings(obs) {
574
+ const raw = obs.investigation_findings || '';
575
+ const inv = parseFindings(raw);
576
+ const focused = focusedFromInfo(obs.current_ad_info || '');
577
+ const adInv = (focused && inv[focused]) || {};
578
+ const grid = document.getElementById('findings-grid');
579
+ grid.innerHTML = '';
580
+ TARGETS.forEach(t => {
581
+ const card = document.createElement('div');
582
+ card.className = 'inv-card' + (adInv[t] ? ' revealed' : ' locked');
583
+ const label = TARGET_LABELS[t] || t;
584
+ const inner = adInv[t]
585
+ ? '<div class="inv-inner"><div class="inv-label">' + esc(label) + '</div><div class="inv-content">' + esc(adInv[t].slice(0, 220)) + (adInv[t].length > 220 ? '...' : '') + '</div></div>'
586
+ : '<div class="inv-inner"><div class="inv-label">' + esc(label) + '</div><div class="inv-content">Classified</div></div><div class="lock-icon">&#128274;</div>';
587
+ card.innerHTML = inner;
588
+ grid.appendChild(card);
589
+ });
590
+ }
591
+
592
+ function fillAdSelects(obs) {
593
+ const ads = obs.available_ads || [];
594
+ const sel = document.getElementById('act-ad');
595
+ const lk = document.getElementById('act-linked');
596
+ sel.innerHTML = '';
597
+ lk.innerHTML = '';
598
+ ads.forEach(a => {
599
+ const o = document.createElement('option');
600
+ o.value = a; o.textContent = a;
601
+ sel.appendChild(o);
602
+ const o2 = document.createElement('option');
603
+ o2.value = a; o2.textContent = a;
604
+ lk.appendChild(o2);
605
+ });
606
+ }
607
+
608
+ function renderVerdicts() {
609
+ const el = document.getElementById('verdict-list');
610
+ el.innerHTML = '';
611
+ const keys = Object.keys(verdicts);
612
+ if (!keys.length) {
613
+ el.innerHTML = '<p style="color:var(--text-dim);font-size:0.85rem;">None yet.</p>';
614
+ return;
615
+ }
616
+ keys.forEach(aid => {
617
+ const v = verdicts[aid];
618
+ const row = document.createElement('div');
619
+ row.className = 'verdict-row';
620
+ row.innerHTML = '<span>' + esc(aid) + '</span><span style="color:var(--text-dim)">' + ((v.confidence * 100) | 0) + '%</span><span class="v-badge ' + esc(v.verdict) + '">' + esc(v.verdict) + '</span>';
621
+ el.appendChild(row);
622
+ });
623
+ }
624
+
625
+ function applyObs(data) {
626
+ const obs = data.observation || {};
627
+ lastObs = obs;
628
+ renderStats(obs);
629
+ renderQueue(obs);
630
+ renderProfile(obs);
631
+ renderFindings(obs);
632
+ fillAdSelects(obs);
633
+ renderCumChart();
634
+ renderVerdicts();
635
+ }
636
+
637
+ function toggleActionFields() {
638
+ const t = document.getElementById('act-type').value;
639
+ document.getElementById('grp-target').classList.toggle('hidden', t !== 'investigate');
640
+ document.getElementById('grp-verdict').classList.toggle('hidden', t !== 'verdict');
641
+ document.getElementById('grp-conf').classList.toggle('hidden', t !== 'verdict');
642
+ document.getElementById('grp-link').classList.toggle('hidden', t !== 'link_accounts');
643
+ document.getElementById('grp-reason').classList.toggle('hidden', t !== 'link_accounts');
644
+ }
645
+
646
+ document.getElementById('act-type').addEventListener('change', toggleActionFields);
647
+
648
+ document.getElementById('btn-reset').onclick = async () => {
649
+ try {
650
+ const task = document.getElementById('task-select').value;
651
+ const res = await fetch(API + '/investigate/api/reset', {
652
+ method: 'POST',
653
+ headers: { 'Content-Type': 'application/json' },
654
+ body: JSON.stringify({ task_id: task, seed: 42 })
655
+ });
656
+ const data = await res.json();
657
+ if (!res.ok) throw new Error(data.detail || res.statusText);
658
+ verdicts = {};
659
+ cumReward = 0;
660
+ cumHistory = [];
661
+ uiStep = 0;
662
+ episodeDone = false;
663
+ maxBudget = (data.observation && data.observation.queue_status && data.observation.queue_status.investigation_budget) || 25;
664
+ applyObs(data);
665
+ document.getElementById('btn-step').disabled = false;
666
+ document.getElementById('feedback').textContent = 'Episode started. Budget: ' + maxBudget + ' actions.';
667
+ logLine('Reset OK (' + task + ')', 'ok');
668
+ toast('Environment reset', 'success');
669
+ } catch (e) {
670
+ toast(String(e.message), 'error');
671
+ logLine('Reset failed: ' + e.message, 'bad');
672
+ }
673
+ };
674
+
675
+ document.getElementById('btn-step').onclick = async () => {
676
+ if (episodeDone) { toast('Episode finished — reset first', 'error'); return; }
677
+ const t = document.getElementById('act-type').value;
678
+ const ad = document.getElementById('act-ad').value;
679
+ const body = { action_type: t, ad_id: ad };
680
+ if (t === 'investigate') body.investigation_target = document.getElementById('act-target').value;
681
+ else if (t === 'verdict') {
682
+ body.verdict = document.getElementById('act-verdict').value;
683
+ body.confidence = parseFloat(document.getElementById('act-conf').value) || 0.5;
684
+ verdicts[ad] = { verdict: body.verdict, confidence: body.confidence };
685
+ } else if (t === 'link_accounts') {
686
+ body.linked_ad_id = document.getElementById('act-linked').value;
687
+ body.link_reason = document.getElementById('act-reason').value.trim() || '—';
688
+ }
689
+ try {
690
+ const res = await fetch(API + '/investigate/api/step', {
691
+ method: 'POST',
692
+ headers: { 'Content-Type': 'application/json' },
693
+ body: JSON.stringify(body)
694
+ });
695
+ const data = await res.json();
696
+ if (!res.ok) throw new Error(typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail));
697
+ const r = data.reward != null ? data.reward : 0;
698
+ cumReward += r;
699
+ cumHistory.push(cumReward);
700
+ uiStep += 1;
701
+ episodeDone = !!data.done;
702
+ applyObs(data);
703
+ document.getElementById('feedback').textContent = (data.observation && data.observation.feedback) || ('Reward ' + r);
704
+ logLine('Step ' + uiStep + ' reward ' + r + ' cum ' + cumReward.toFixed(2), r < 0 ? 'bad' : 'ok');
705
+ if (data.done) {
706
+ document.getElementById('btn-step').disabled = true;
707
+ toast('Episode complete', 'success');
708
+ }
709
+ } catch (e) {
710
+ toast(String(e.message), 'error');
711
+ logLine('Step error: ' + e.message, 'bad');
712
+ }
713
+ };
714
+
715
+ document.getElementById('btn-score').onclick = async () => {
716
+ try {
717
+ const res = await fetch(API + '/grader');
718
+ const g = await res.json();
719
+ if (g.grader_score != null) {
720
+ document.getElementById('st-score').textContent = Number(g.grader_score).toFixed(3);
721
+ toast('Grader score: ' + g.grader_score.toFixed(3), 'success');
722
+ } else toast(g.error || 'No grader yet', 'info');
723
+ } catch (e) { toast(String(e.message), 'error'); }
724
+ };
725
+
726
+ document.getElementById('btn-baseline').onclick = async () => {
727
+ try {
728
+ const res = await fetch(API + '/baseline');
729
+ const j = await res.json();
730
+ const el = document.getElementById('bench-body');
731
+ el.innerHTML = '<pre style="white-space:pre-wrap;word-break:break-all;max-height:200px;overflow:auto">' + esc(JSON.stringify(j, null, 2)) + '</pre>';
732
+ toast('Loaded /baseline', 'success');
733
+ } catch (e) { toast(String(e.message), 'error'); }
734
+ };
735
+
736
+ document.getElementById('btn-clear-log').onclick = () => { document.getElementById('log-area').innerHTML = ''; };
737
+
738
+ toggleActionFields();
739
+ </script>
740
+ </body>
741
+ </html>