Gleb Tsyganov commited on
Commit
cd8bc6c
·
1 Parent(s): c945352

add dashboard

Browse files
Files changed (2) hide show
  1. server/app.py +166 -187
  2. web/index.html +409 -0
server/app.py CHANGED
@@ -5,28 +5,18 @@
5
  # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
- FastAPI application for the Clothing Brand Ctr Env Environment.
9
 
10
- This module creates an HTTP server that exposes the ClothingBrandCtrEnvironment
11
- over HTTP and WebSocket endpoints, compatible with EnvClient.
12
-
13
- Endpoints:
14
- - POST /reset: Reset the environment
15
- - POST /step: Execute an action
16
- - GET /state: Get current environment state
17
- - GET /schema: Get action/observation schemas
18
- - WS /ws: WebSocket endpoint for persistent sessions
19
-
20
- Usage:
21
- # Development (with auto-reload):
22
- uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
23
 
24
- # Production:
25
- uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
26
 
27
- # Or run directly:
28
- python -m server.app
29
- """
 
30
 
31
  try:
32
  from openenv.core.env_server.http_server import create_app
@@ -34,7 +24,8 @@ except Exception as e: # pragma: no cover
34
  raise ImportError(
35
  "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
36
  ) from e
37
- from fastapi.responses import HTMLResponse
 
38
 
39
  try:
40
  from ..models import ClothingBrandCtrAction, ClothingBrandCtrObservation
@@ -43,195 +34,183 @@ except ImportError: # pragma: no cover - supports direct server.app imports
43
  from .clothing_brand_ctr_env_environment import ClothingBrandCtrEnvironment
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  # Create the app with web interface and README integration
47
  app = create_app(
48
  ClothingBrandCtrEnvironment,
49
  ClothingBrandCtrAction,
50
  ClothingBrandCtrObservation,
51
  env_name="clothing_brand_ctr_env",
52
- max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions
53
  )
54
 
55
- LANDING_HTML = """
56
- <!doctype html>
57
- <html lang="en">
58
- <head>
59
- <meta charset="utf-8" />
60
- <meta name="viewport" content="width=device-width,initial-scale=1" />
61
- <title>Email Campaign Simulation</title>
62
- <style>
63
- :root {
64
- --bg: #f6f4ef;
65
- --card: #ffffff;
66
- --ink: #222222;
67
- --muted: #6e675e;
68
- --accent: #d5632f;
69
- --accent-soft: #ffe5d8;
70
- }
71
- body {
72
- margin: 0;
73
- font-family: "Avenir Next", "Segoe UI", sans-serif;
74
- background: radial-gradient(circle at 15% 10%, #fff7f1 0%, var(--bg) 45%, #efe9df 100%);
75
- color: var(--ink);
76
- }
77
- .wrap {
78
- max-width: 980px;
79
- margin: 0 auto;
80
- padding: 42px 24px 56px;
81
- }
82
- .hero {
83
- background: var(--card);
84
- border: 1px solid #ece5dc;
85
- border-radius: 18px;
86
- padding: 28px;
87
- box-shadow: 0 16px 40px rgba(36, 26, 15, 0.08);
88
- }
89
- h1 {
90
- margin: 0 0 10px 0;
91
- font-size: clamp(28px, 4vw, 44px);
92
- line-height: 1.1;
93
- }
94
- .sub {
95
- margin: 0;
96
- color: var(--muted);
97
- font-size: 16px;
98
- }
99
- .badge-row {
100
- display: flex;
101
- flex-wrap: wrap;
102
- gap: 10px;
103
- margin: 18px 0 0 0;
104
- }
105
- .badge {
106
- background: var(--accent-soft);
107
- color: #7a3518;
108
- border: 1px solid #ffd2bc;
109
- border-radius: 999px;
110
- padding: 7px 12px;
111
- font-size: 13px;
112
- font-weight: 600;
113
- }
114
- .grid {
115
- margin-top: 18px;
116
- display: grid;
117
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
118
- gap: 12px;
119
- }
120
- .card {
121
- background: var(--card);
122
- border: 1px solid #ece5dc;
123
- border-radius: 14px;
124
- padding: 14px;
125
- }
126
- .card h3 {
127
- margin: 0 0 8px 0;
128
- font-size: 15px;
129
- }
130
- .card p {
131
- margin: 0;
132
- color: var(--muted);
133
- font-size: 14px;
134
- }
135
- .links {
136
- display: flex;
137
- flex-wrap: wrap;
138
- gap: 10px;
139
- margin-top: 18px;
140
- }
141
- .links a {
142
- text-decoration: none;
143
- color: #fff;
144
- background: var(--accent);
145
- border-radius: 10px;
146
- padding: 10px 14px;
147
- font-weight: 700;
148
- font-size: 14px;
149
- }
150
- .links a.secondary {
151
- background: #2f6ea7;
152
- }
153
- pre {
154
- margin: 16px 0 0 0;
155
- background: #1a1f24;
156
- color: #d8e6f3;
157
- border-radius: 12px;
158
- padding: 14px;
159
- overflow-x: auto;
160
- font-size: 12px;
161
- }
162
- </style>
163
- </head>
164
- <body>
165
- <main class="wrap">
166
- <section class="hero">
167
- <h1>Email Marketing Campaign Simulator</h1>
168
- <p class="sub">
169
- Simulates multi-step brand email campaigns and optimizes opens, click-through rate, and purchases.
170
- Uses Hugging Face personas and DeepSeek for generation + marketer judging.
171
- </p>
172
- <div class="badge-row">
173
- <span class="badge">5-email schedule optimization</span>
174
- <span class="badge">Day + time simulation</span>
175
- <span class="badge">DeepSeek marketer judge</span>
176
- <span class="badge">Persona-based outcomes</span>
177
- </div>
178
- <div class="grid">
179
- <article class="card">
180
- <h3>Primary metrics</h3>
181
- <p>Open rate, CTR, click-to-open rate, purchases, and composite campaign score.</p>
182
- </article>
183
- <article class="card">
184
- <h3>Audience model</h3>
185
- <p>Supports Hugging Face Nemotron personas and synthetic fallback for experimentation.</p>
186
- </article>
187
- <article class="card">
188
- <h3>Judging layer</h3>
189
- <p>10x marketer judge combines deterministic checks with LLM scoring for subject/body quality.</p>
190
- </article>
191
- <article class="card">
192
- <h3>Optimization output</h3>
193
- <p>Top schedule ranking, per-step performance, and top purchasing personas.</p>
194
- </article>
195
- </div>
196
- <div class="links">
197
- <a href="/docs">Open API Docs</a>
198
- <a class="secondary" href="/health">Health Check</a>
199
- <a class="secondary" href="/schema">Schema</a>
200
- </div>
201
- <pre>python simulate_5_email_campaign.py --persona-source hf --send-days mon,tue,wed,thu,fri --send-hours 8,10,12,15,18</pre>
202
- </section>
203
- </main>
204
- </body>
205
- </html>
206
- """
207
-
208
 
209
  @app.get("/", include_in_schema=False)
210
  def landing_page() -> HTMLResponse:
211
- return HTMLResponse(LANDING_HTML)
 
212
 
213
 
214
  @app.get("/web", include_in_schema=False)
215
  def landing_page_web() -> HTMLResponse:
216
- return HTMLResponse(LANDING_HTML)
 
 
 
 
 
 
 
217
 
218
 
219
  def main(host: str = "0.0.0.0", port: int = 8000):
220
  """
221
  Entry point for direct execution via uv run or python -m.
222
 
223
- This function enables running the server without Docker:
224
- uv run --project . server
225
- uv run --project . server --port 8001
226
- python -m clothing_brand_ctr_env.server.app
227
-
228
  Args:
229
- host: Host address to bind to (default: "0.0.0.0")
230
- port: Port number to listen on (default: 8000)
231
-
232
- For production deployments, consider using uvicorn directly with
233
- multiple workers:
234
- uvicorn clothing_brand_ctr_env.server.app:app --workers 4
235
  """
236
  import uvicorn
237
 
 
5
  # LICENSE file in the root directory of this source tree.
6
 
7
  """
8
+ FastAPI application for the Email Campaign Simulation environment.
9
 
10
+ This server exposes OpenEnv-compatible endpoints and serves a lightweight
11
+ campaign analytics dashboard at "/" and "/web".
12
+ """
 
 
 
 
 
 
 
 
 
 
13
 
14
+ from __future__ import annotations
 
15
 
16
+ import csv
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List
20
 
21
  try:
22
  from openenv.core.env_server.http_server import create_app
 
24
  raise ImportError(
25
  "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
26
  ) from e
27
+
28
+ from fastapi.responses import HTMLResponse, JSONResponse
29
 
30
  try:
31
  from ..models import ClothingBrandCtrAction, ClothingBrandCtrObservation
 
34
  from .clothing_brand_ctr_env_environment import ClothingBrandCtrEnvironment
35
 
36
 
37
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
38
+ WEB_INDEX_PATH = PROJECT_ROOT / "web" / "index.html"
39
+ EVALS_DIR = PROJECT_ROOT / "outputs" / "evals"
40
+
41
+
42
+ def _safe_float(value: object, default: float = 0.0) -> float:
43
+ """Best-effort float parser."""
44
+ try:
45
+ return float(value) # type: ignore[arg-type]
46
+ except (TypeError, ValueError):
47
+ return default
48
+
49
+
50
+ def _safe_int(value: object, default: int = 0) -> int:
51
+ """Best-effort integer parser."""
52
+ try:
53
+ return int(float(value)) # type: ignore[arg-type]
54
+ except (TypeError, ValueError):
55
+ return default
56
+
57
+
58
+ def _read_csv_rows(path: Path, limit: int | None = None) -> List[Dict[str, str]]:
59
+ """Read CSV rows if file exists."""
60
+ if not path.exists():
61
+ return []
62
+
63
+ rows: List[Dict[str, str]] = []
64
+ with path.open("r", newline="", encoding="utf-8") as handle:
65
+ reader = csv.DictReader(handle)
66
+ for idx, row in enumerate(reader):
67
+ rows.append(dict(row))
68
+ if limit is not None and idx + 1 >= limit:
69
+ break
70
+ return rows
71
+
72
+
73
+ def _format_file_meta(path: Path) -> Dict[str, object]:
74
+ """Return file metadata used by dashboard."""
75
+ if not path.exists():
76
+ return {"path": str(path.relative_to(PROJECT_ROOT)), "exists": False}
77
+ stat = path.stat()
78
+ updated = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
79
+ return {
80
+ "path": str(path.relative_to(PROJECT_ROOT)),
81
+ "exists": True,
82
+ "size_bytes": stat.st_size,
83
+ "updated_at_utc": updated,
84
+ }
85
+
86
+
87
+ def load_campaign_stats() -> Dict[str, object]:
88
+ """Aggregate latest simulation output files for dashboard display."""
89
+ schedule_path = EVALS_DIR / "five_email_schedule_results.csv"
90
+ step_path = EVALS_DIR / "five_email_best_schedule_steps.csv"
91
+ arm_path = EVALS_DIR / "brand_campaign_arm_results.csv"
92
+
93
+ schedule_rows = _read_csv_rows(schedule_path, limit=25)
94
+ step_rows = _read_csv_rows(step_path, limit=25)
95
+ arm_rows = _read_csv_rows(arm_path, limit=25)
96
+
97
+ top_schedules = [
98
+ {
99
+ "rank": _safe_int(row.get("rank")),
100
+ "schedule": row.get("schedule", ""),
101
+ "open_rate": _safe_float(row.get("open_rate")),
102
+ "ctr": _safe_float(row.get("ctr")),
103
+ "purchase_rate": _safe_float(row.get("purchase_rate")),
104
+ "composite_score": _safe_float(row.get("composite_score")),
105
+ "generation_source": row.get("generation_source", ""),
106
+ "marketer_score": _safe_float(row.get("marketer_score")),
107
+ "opens": _safe_int(row.get("opens")),
108
+ "clicks": _safe_int(row.get("clicks")),
109
+ "purchases": _safe_int(row.get("purchases")),
110
+ }
111
+ for row in schedule_rows[:10]
112
+ ]
113
+ best_schedule = top_schedules[0] if top_schedules else {}
114
+
115
+ step_breakdown = [
116
+ {
117
+ "step_idx": _safe_int(row.get("step_idx")),
118
+ "step_name": row.get("step_name", ""),
119
+ "send_day": row.get("send_day", ""),
120
+ "send_hour": _safe_int(row.get("send_hour")),
121
+ "open_rate": _safe_float(row.get("open_rate")),
122
+ "ctr": _safe_float(row.get("ctr")),
123
+ "purchase_rate": _safe_float(row.get("purchase_rate")),
124
+ "subject_line": row.get("subject_line", ""),
125
+ }
126
+ for row in step_rows
127
+ ]
128
+
129
+ top_arms = [
130
+ {
131
+ "rank": _safe_int(row.get("rank")),
132
+ "arm_id": row.get("arm_id", ""),
133
+ "variant_name": row.get("variant_name", ""),
134
+ "brand_voice": row.get("brand_voice", ""),
135
+ "send_hour": _safe_int(row.get("send_hour")),
136
+ "open_rate": _safe_float(row.get("open_rate")),
137
+ "ctr": _safe_float(row.get("ctr")),
138
+ "purchase_rate": _safe_float(row.get("purchase_rate")),
139
+ "composite_score": _safe_float(row.get("composite_score")),
140
+ "marketer_score": _safe_float(row.get("marketer_score")),
141
+ "subject_line": row.get("subject_line", ""),
142
+ }
143
+ for row in arm_rows[:10]
144
+ ]
145
+ best_arm = top_arms[0] if top_arms else {}
146
+
147
+ return {
148
+ "status": "ok",
149
+ "generated_at_utc": datetime.now(tz=UTC).isoformat(),
150
+ "summary": {
151
+ "best_schedule": best_schedule,
152
+ "best_arm": best_arm,
153
+ "top_schedule_count": len(top_schedules),
154
+ "step_count": len(step_breakdown),
155
+ "top_arm_count": len(top_arms),
156
+ },
157
+ "top_schedules": top_schedules,
158
+ "step_breakdown": step_breakdown,
159
+ "top_arms": top_arms,
160
+ "files": {
161
+ "five_email_schedule_results": _format_file_meta(schedule_path),
162
+ "five_email_best_schedule_steps": _format_file_meta(step_path),
163
+ "brand_campaign_arm_results": _format_file_meta(arm_path),
164
+ },
165
+ }
166
+
167
+
168
+ def _load_dashboard_html() -> str:
169
+ """Load dashboard HTML from disk with safe fallback."""
170
+ if WEB_INDEX_PATH.exists():
171
+ return WEB_INDEX_PATH.read_text(encoding="utf-8")
172
+
173
+ return (
174
+ "<!doctype html><html><body><h1>Email Campaign Dashboard Missing</h1>"
175
+ "<p>Create web/index.html in the project root.</p></body></html>"
176
+ )
177
+
178
+
179
  # Create the app with web interface and README integration
180
  app = create_app(
181
  ClothingBrandCtrEnvironment,
182
  ClothingBrandCtrAction,
183
  ClothingBrandCtrObservation,
184
  env_name="clothing_brand_ctr_env",
185
+ max_concurrent_envs=1, # increase for more concurrent WebSocket sessions
186
  )
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
  @app.get("/", include_in_schema=False)
190
  def landing_page() -> HTMLResponse:
191
+ """Dashboard landing page."""
192
+ return HTMLResponse(_load_dashboard_html())
193
 
194
 
195
  @app.get("/web", include_in_schema=False)
196
  def landing_page_web() -> HTMLResponse:
197
+ """Compatibility route for Space base path."""
198
+ return HTMLResponse(_load_dashboard_html())
199
+
200
+
201
+ @app.get("/campaign/stats")
202
+ def campaign_stats() -> JSONResponse:
203
+ """Return latest aggregated campaign stats from simulation CSV outputs."""
204
+ return JSONResponse(load_campaign_stats())
205
 
206
 
207
  def main(host: str = "0.0.0.0", port: int = 8000):
208
  """
209
  Entry point for direct execution via uv run or python -m.
210
 
 
 
 
 
 
211
  Args:
212
+ host: Host address to bind to.
213
+ port: Port number to listen on.
 
 
 
 
214
  """
215
  import uvicorn
216
 
web/index.html ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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" />
6
+ <title>Email Campaign Stats</title>
7
+ <style>
8
+ :root {
9
+ --bg-0: #f1ede6;
10
+ --bg-1: #f8f5ef;
11
+ --ink: #1e1d1a;
12
+ --muted: #6f685f;
13
+ --accent: #cf5a2e;
14
+ --accent-2: #1b6f8a;
15
+ --card: #ffffff;
16
+ --line: #e7ddd2;
17
+ --ok: #1f7a4f;
18
+ }
19
+ * {
20
+ box-sizing: border-box;
21
+ }
22
+ body {
23
+ margin: 0;
24
+ color: var(--ink);
25
+ background: radial-gradient(circle at 10% 8%, #fff8ef 0%, var(--bg-1) 42%, var(--bg-0) 100%);
26
+ font-family: "Avenir Next", "Segoe UI", Tahoma, sans-serif;
27
+ }
28
+ .page {
29
+ max-width: 1180px;
30
+ margin: 0 auto;
31
+ padding: 28px 20px 40px;
32
+ }
33
+ .hero {
34
+ border: 1px solid var(--line);
35
+ background: linear-gradient(145deg, #fff 0%, #fffaf5 100%);
36
+ border-radius: 20px;
37
+ padding: 22px;
38
+ box-shadow: 0 14px 35px rgba(36, 21, 9, 0.08);
39
+ }
40
+ .eyebrow {
41
+ font-size: 12px;
42
+ letter-spacing: 0.13em;
43
+ text-transform: uppercase;
44
+ color: var(--accent);
45
+ font-weight: 700;
46
+ }
47
+ h1 {
48
+ margin: 6px 0 8px;
49
+ font-size: clamp(30px, 5vw, 56px);
50
+ line-height: 1.05;
51
+ }
52
+ .subtitle {
53
+ margin: 0;
54
+ max-width: 900px;
55
+ color: var(--muted);
56
+ font-size: 15px;
57
+ }
58
+ .controls {
59
+ margin-top: 16px;
60
+ display: flex;
61
+ gap: 10px;
62
+ flex-wrap: wrap;
63
+ }
64
+ .btn {
65
+ border: 0;
66
+ border-radius: 10px;
67
+ padding: 10px 14px;
68
+ font-weight: 700;
69
+ font-size: 13px;
70
+ cursor: pointer;
71
+ transition: transform 120ms ease;
72
+ }
73
+ .btn:active {
74
+ transform: translateY(1px);
75
+ }
76
+ .btn-primary {
77
+ background: var(--accent);
78
+ color: #fff;
79
+ }
80
+ .btn-secondary {
81
+ background: var(--accent-2);
82
+ color: #fff;
83
+ }
84
+ .meta {
85
+ margin-top: 8px;
86
+ color: var(--muted);
87
+ font-size: 12px;
88
+ }
89
+ .kpis {
90
+ display: grid;
91
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
92
+ gap: 12px;
93
+ margin-top: 16px;
94
+ }
95
+ .kpi {
96
+ background: var(--card);
97
+ border: 1px solid var(--line);
98
+ border-radius: 14px;
99
+ padding: 12px;
100
+ }
101
+ .kpi .label {
102
+ color: var(--muted);
103
+ font-size: 12px;
104
+ text-transform: uppercase;
105
+ letter-spacing: 0.08em;
106
+ }
107
+ .kpi .value {
108
+ margin-top: 6px;
109
+ font-size: 24px;
110
+ line-height: 1.15;
111
+ font-weight: 800;
112
+ }
113
+ .kpi .sub {
114
+ margin-top: 5px;
115
+ font-size: 12px;
116
+ color: var(--muted);
117
+ }
118
+ .section {
119
+ margin-top: 18px;
120
+ background: var(--card);
121
+ border: 1px solid var(--line);
122
+ border-radius: 14px;
123
+ overflow: hidden;
124
+ }
125
+ .section-head {
126
+ padding: 12px 14px;
127
+ display: flex;
128
+ justify-content: space-between;
129
+ align-items: center;
130
+ background: linear-gradient(180deg, #fff 0%, #faf6f0 100%);
131
+ border-bottom: 1px solid var(--line);
132
+ }
133
+ .section-head h2 {
134
+ margin: 0;
135
+ font-size: 16px;
136
+ }
137
+ .section-head span {
138
+ font-size: 12px;
139
+ color: var(--muted);
140
+ }
141
+ .table-wrap {
142
+ overflow-x: auto;
143
+ }
144
+ table {
145
+ width: 100%;
146
+ border-collapse: collapse;
147
+ min-width: 760px;
148
+ }
149
+ th,
150
+ td {
151
+ padding: 9px 11px;
152
+ border-bottom: 1px solid #f1ebe4;
153
+ text-align: left;
154
+ font-size: 13px;
155
+ vertical-align: top;
156
+ }
157
+ th {
158
+ position: sticky;
159
+ top: 0;
160
+ background: #fff;
161
+ z-index: 1;
162
+ color: #4c453e;
163
+ font-size: 12px;
164
+ text-transform: uppercase;
165
+ letter-spacing: 0.06em;
166
+ }
167
+ tr:hover td {
168
+ background: #fffaf3;
169
+ }
170
+ .status-ok {
171
+ color: var(--ok);
172
+ font-weight: 700;
173
+ }
174
+ .error {
175
+ margin-top: 12px;
176
+ color: #a22a2a;
177
+ font-size: 13px;
178
+ font-weight: 600;
179
+ }
180
+ .foot {
181
+ margin-top: 14px;
182
+ font-size: 12px;
183
+ color: var(--muted);
184
+ }
185
+ @media (max-width: 720px) {
186
+ .page {
187
+ padding: 16px 12px 26px;
188
+ }
189
+ .hero {
190
+ padding: 16px;
191
+ }
192
+ }
193
+ </style>
194
+ </head>
195
+ <body>
196
+ <main class="page">
197
+ <section class="hero">
198
+ <div class="eyebrow">Email Campaign Intelligence</div>
199
+ <h1>Campaign Stats Dashboard</h1>
200
+ <p class="subtitle">
201
+ Live view of simulation outputs from the 5-email optimizer and arm-level campaign experiments.
202
+ Data is read from the latest CSV files in <code>outputs/evals/</code>.
203
+ </p>
204
+ <div class="controls">
205
+ <button class="btn btn-primary" id="refreshBtn">Refresh Stats</button>
206
+ <a class="btn btn-secondary" href="/docs" style="text-decoration:none;">API Docs</a>
207
+ </div>
208
+ <div class="meta" id="metaText">Loading latest results...</div>
209
+ <div class="error" id="errorBox" hidden></div>
210
+ </section>
211
+
212
+ <section class="kpis">
213
+ <article class="kpi">
214
+ <div class="label">Best Schedule</div>
215
+ <div class="value" id="kpiSchedule">-</div>
216
+ <div class="sub">Winning 5-email send plan</div>
217
+ </article>
218
+ <article class="kpi">
219
+ <div class="label">Open Rate</div>
220
+ <div class="value" id="kpiOpen">-</div>
221
+ <div class="sub">Top schedule open rate</div>
222
+ </article>
223
+ <article class="kpi">
224
+ <div class="label">Click-Through Rate</div>
225
+ <div class="value" id="kpiCtr">-</div>
226
+ <div class="sub">Top schedule CTR</div>
227
+ </article>
228
+ <article class="kpi">
229
+ <div class="label">Purchase Rate</div>
230
+ <div class="value" id="kpiPurchase">-</div>
231
+ <div class="sub">Top schedule conversion</div>
232
+ </article>
233
+ <article class="kpi">
234
+ <div class="label">Top Arm</div>
235
+ <div class="value" id="kpiArm">-</div>
236
+ <div class="sub">Best variant/time from arm simulation</div>
237
+ </article>
238
+ </section>
239
+
240
+ <section class="section">
241
+ <div class="section-head">
242
+ <h2>Top Schedules</h2>
243
+ <span id="scheduleCount">0 rows</span>
244
+ </div>
245
+ <div class="table-wrap">
246
+ <table>
247
+ <thead>
248
+ <tr>
249
+ <th>Rank</th>
250
+ <th>Schedule</th>
251
+ <th>Open</th>
252
+ <th>CTR</th>
253
+ <th>Purchase</th>
254
+ <th>Composite</th>
255
+ <th>Opens</th>
256
+ <th>Clicks</th>
257
+ <th>Buys</th>
258
+ </tr>
259
+ </thead>
260
+ <tbody id="scheduleTable"></tbody>
261
+ </table>
262
+ </div>
263
+ </section>
264
+
265
+ <section class="section">
266
+ <div class="section-head">
267
+ <h2>Best Schedule Step Breakdown</h2>
268
+ <span id="stepCount">0 rows</span>
269
+ </div>
270
+ <div class="table-wrap">
271
+ <table>
272
+ <thead>
273
+ <tr>
274
+ <th>Step</th>
275
+ <th>Timing</th>
276
+ <th>Open</th>
277
+ <th>CTR</th>
278
+ <th>Purchase</th>
279
+ <th>Subject</th>
280
+ </tr>
281
+ </thead>
282
+ <tbody id="stepTable"></tbody>
283
+ </table>
284
+ </div>
285
+ </section>
286
+
287
+ <section class="section">
288
+ <div class="section-head">
289
+ <h2>Top Campaign Arms</h2>
290
+ <span id="armCount">0 rows</span>
291
+ </div>
292
+ <div class="table-wrap">
293
+ <table>
294
+ <thead>
295
+ <tr>
296
+ <th>Rank</th>
297
+ <th>Arm</th>
298
+ <th>Voice</th>
299
+ <th>Open</th>
300
+ <th>CTR</th>
301
+ <th>Purchase</th>
302
+ <th>Marketer</th>
303
+ </tr>
304
+ </thead>
305
+ <tbody id="armTable"></tbody>
306
+ </table>
307
+ </div>
308
+ </section>
309
+
310
+ <p class="foot" id="fileMeta"></p>
311
+ </main>
312
+
313
+ <script>
314
+ const percent = (v) => `${(Number(v || 0) * 100).toFixed(1)}%`;
315
+ const fixed = (v) => Number(v || 0).toFixed(3);
316
+ const byId = (id) => document.getElementById(id);
317
+
318
+ function renderRows(targetId, rows, htmlFn) {
319
+ const target = byId(targetId);
320
+ target.innerHTML = rows.map(htmlFn).join("");
321
+ }
322
+
323
+ function maybe(text) {
324
+ if (text === null || text === undefined || text === "") return "-";
325
+ return String(text);
326
+ }
327
+
328
+ async function loadStats() {
329
+ byId("errorBox").hidden = true;
330
+ byId("metaText").textContent = "Loading latest results...";
331
+ try {
332
+ const resp = await fetch("/campaign/stats", { cache: "no-store" });
333
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
334
+ const data = await resp.json();
335
+
336
+ const bestSchedule = (data.summary && data.summary.best_schedule) || {};
337
+ const bestArm = (data.summary && data.summary.best_arm) || {};
338
+
339
+ byId("kpiSchedule").textContent = maybe(bestSchedule.schedule);
340
+ byId("kpiOpen").textContent = percent(bestSchedule.open_rate);
341
+ byId("kpiCtr").textContent = percent(bestSchedule.ctr);
342
+ byId("kpiPurchase").textContent = percent(bestSchedule.purchase_rate);
343
+ byId("kpiArm").textContent = maybe(bestArm.arm_id || bestArm.variant_name);
344
+
345
+ const topSchedules = data.top_schedules || [];
346
+ byId("scheduleCount").textContent = `${topSchedules.length} rows`;
347
+ renderRows("scheduleTable", topSchedules, (row) => `
348
+ <tr>
349
+ <td>${row.rank}</td>
350
+ <td>${maybe(row.schedule)}</td>
351
+ <td>${percent(row.open_rate)}</td>
352
+ <td>${percent(row.ctr)}</td>
353
+ <td>${percent(row.purchase_rate)}</td>
354
+ <td>${fixed(row.composite_score)}</td>
355
+ <td>${row.opens}</td>
356
+ <td>${row.clicks}</td>
357
+ <td>${row.purchases}</td>
358
+ </tr>
359
+ `);
360
+
361
+ const steps = data.step_breakdown || [];
362
+ byId("stepCount").textContent = `${steps.length} rows`;
363
+ renderRows("stepTable", steps, (row) => `
364
+ <tr>
365
+ <td>${row.step_idx}. ${maybe(row.step_name)}</td>
366
+ <td>${maybe(row.send_day)} ${String(row.send_hour).padStart(2, "0")}:00</td>
367
+ <td>${percent(row.open_rate)}</td>
368
+ <td>${percent(row.ctr)}</td>
369
+ <td>${percent(row.purchase_rate)}</td>
370
+ <td>${maybe(row.subject_line)}</td>
371
+ </tr>
372
+ `);
373
+
374
+ const arms = data.top_arms || [];
375
+ byId("armCount").textContent = `${arms.length} rows`;
376
+ renderRows("armTable", arms, (row) => `
377
+ <tr>
378
+ <td>${row.rank}</td>
379
+ <td>${maybe(row.arm_id)}</td>
380
+ <td>${maybe(row.brand_voice)}</td>
381
+ <td>${percent(row.open_rate)}</td>
382
+ <td>${percent(row.ctr)}</td>
383
+ <td>${percent(row.purchase_rate)}</td>
384
+ <td>${Number(row.marketer_score || 0).toFixed(1)}</td>
385
+ </tr>
386
+ `);
387
+
388
+ const files = data.files || {};
389
+ const fileSummary = Object.values(files)
390
+ .map((f) => `${f.path}: ${f.exists ? "present" : "missing"}`)
391
+ .join(" | ");
392
+ byId("fileMeta").textContent = fileSummary;
393
+
394
+ byId("metaText").innerHTML =
395
+ `<span class="status-ok">Loaded</span> &middot; ` +
396
+ `generated at ${maybe(data.generated_at_utc)}`;
397
+ } catch (err) {
398
+ byId("metaText").textContent = "Failed to load campaign stats.";
399
+ const box = byId("errorBox");
400
+ box.hidden = false;
401
+ box.textContent = `Error: ${err.message}`;
402
+ }
403
+ }
404
+
405
+ byId("refreshBtn").addEventListener("click", loadStats);
406
+ loadStats();
407
+ </script>
408
+ </body>
409
+ </html>