mnoorchenar commited on
Commit
6973475
·
1 Parent(s): 9c191b0

Update 2026-03-25 13:52:27

Browse files
.claude/worktrees/jolly-pasteur ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 06e9048746065dfb5fb39450debc6a45ea37c2e6
.gitignore CHANGED
@@ -1,7 +1,15 @@
1
- __pycache__/
2
  *.pyc
 
3
  .env
4
  .DS_Store
5
  *.log
6
  .vscode/
7
  *.tmp
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
  *.pyc
3
+ *.pyo
4
  .env
5
  .DS_Store
6
  *.log
7
  .vscode/
8
  *.tmp
9
+ mlruns/
10
+ mlflow.db
11
+ logs/
12
+ *.egg-info/
13
+ dist/
14
+ build/
15
+ .pytest_cache/
Dockerfile CHANGED
@@ -1,7 +1,22 @@
1
- FROM python:3.11-slim
 
 
 
 
 
 
2
  WORKDIR /app
3
- COPY requirements.txt .
 
4
  RUN pip install --no-cache-dir -r requirements.txt
5
- COPY . .
 
 
 
 
6
  EXPOSE 7860
7
- CMD ["python", "app.py"]
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # HuggingFace Spaces requires non-root user with UID 1000
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
  WORKDIR /app
9
+
10
+ COPY --chown=user:user requirements.txt .
11
  RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY --chown=user:user . .
14
+
15
+ RUN mkdir -p mlruns logs
16
+
17
  EXPOSE 7860
18
+
19
+ ENV PYTHONUNBUFFERED=1
20
+ ENV FLASK_ENV=production
21
+
22
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "300", "--log-level", "info", "app:app"]
app.py CHANGED
@@ -1,12 +1,466 @@
1
- from flask import Flask, render_template_string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  app = Flask(__name__)
3
- HTML = """<!DOCTYPE html>
4
- <html><head><title>AutoMLOps</title></head>
5
- <body style="font-family:Arial;max-width:800px;margin:50px auto;padding:20px">
6
- <h1>AutoMLOps</h1>
7
- <p>Running on port 7860.</p>
8
- <span style="background:#28a745;color:#fff;padding:5px 15px;border-radius:15px">Running</span>
9
- </body></html>"""
10
- @app.route('/')
11
- def home(): return render_template_string(HTML)
12
- if __name__ == '__main__': app.run(host='0.0.0.0', port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AutoMLOps ML Experiment Tracking & Pipeline Orchestration Platform."""
2
+ import os
3
+ import json
4
+ import threading
5
+ from datetime import datetime
6
+
7
+ import mlflow
8
+ import mlflow.sklearn
9
+ from flask import Flask, render_template, request, jsonify
10
+
11
+ from mlops.datasets import DATASETS, load_dataset
12
+ from mlops.algorithms import ALGORITHMS, list_algorithms, all_algorithm_names
13
+ from mlops.trainer import (
14
+ training_jobs, automl_jobs,
15
+ start_training, start_automl,
16
+ )
17
+ from pipelines.dag_engine import pipeline_executions, execute_dag
18
+ from pipelines.pipeline_defs import get_pipeline, PIPELINE_BUILDERS
19
+
20
  app = Flask(__name__)
21
+
22
+ # ── MLflow setup ───────────────────────────────────────────────────────────────
23
+ TRACKING_URI = "sqlite:///mlflow.db"
24
+ mlflow.set_tracking_uri(TRACKING_URI)
25
+
26
+
27
+ def _mlflow_client():
28
+ return mlflow.tracking.MlflowClient(tracking_uri=TRACKING_URI)
29
+
30
+
31
+ # ── Seed demo data on first launch ────────────────────────────────────────────
32
+
33
+ def _seed_demo():
34
+ """Pre-populate a few MLflow runs so the dashboard looks great immediately."""
35
+ client = _mlflow_client()
36
+ try:
37
+ existing = client.search_runs(experiment_ids=[], max_results=1)
38
+ if existing:
39
+ return # already seeded
40
+ except Exception:
41
+ pass
42
+
43
+ demo_runs = [
44
+ ("Iris Flowers", "Ensemble / Boosting", "Random Forest", "classification",
45
+ {"accuracy": 0.9667, "f1_score": 0.9664, "precision": 0.9672, "recall": 0.9667}),
46
+ ("Iris Flowers", "Ensemble / Boosting", "XGBoost", "classification",
47
+ {"accuracy": 0.9600, "f1_score": 0.9598, "precision": 0.9601, "recall": 0.9600}),
48
+ ("Iris Flowers", "Linear Models", "Logistic Regression", "classification",
49
+ {"accuracy": 0.9467, "f1_score": 0.9463, "precision": 0.9472, "recall": 0.9467}),
50
+ ("Wine Quality", "Ensemble / Boosting", "LightGBM", "classification",
51
+ {"accuracy": 0.9722, "f1_score": 0.9720, "precision": 0.9725, "recall": 0.9722}),
52
+ ("Wine Quality", "Neural Networks", "MLP (Medium)", "classification",
53
+ {"accuracy": 0.9444, "f1_score": 0.9441, "precision": 0.9449, "recall": 0.9444}),
54
+ ("Breast Cancer", "Support Vector Machines", "SVC (RBF Kernel)","classification",
55
+ {"accuracy": 0.9737, "f1_score": 0.9736, "precision": 0.9741, "recall": 0.9737}),
56
+ ("Breast Cancer", "Ensemble / Boosting", "Gradient Boosting", "classification",
57
+ {"accuracy": 0.9561, "f1_score": 0.9558, "precision": 0.9565, "recall": 0.9561}),
58
+ ("Diabetes Progression", "Ensemble / Boosting", "XGBoost Regressor","regression",
59
+ {"r2_score": 0.4823, "mae": 44.12, "mse": 3124.5, "rmse": 55.90}),
60
+ ("Diabetes Progression", "Linear Models", "Ridge Regression", "regression",
61
+ {"r2_score": 0.4612, "mae": 45.87, "mse": 3258.3, "rmse": 57.08}),
62
+ ("California Housing","Ensemble / Boosting","LightGBM Regressor", "regression",
63
+ {"r2_score": 0.8341, "mae": 0.3124, "mse": 0.2871, "rmse": 0.5358}),
64
+ ]
65
+
66
+ for ds, cat, alg, task, metrics in demo_runs:
67
+ try:
68
+ exp = client.get_experiment_by_name(ds)
69
+ exp_id = exp.experiment_id if exp else mlflow.create_experiment(ds)
70
+ with mlflow.start_run(experiment_id=exp_id,
71
+ run_name=f"{alg} — {ds}") as run:
72
+ mlflow.set_tags({"algorithm": alg, "category": cat,
73
+ "dataset": ds, "task_type": task, "demo": "true"})
74
+ mlflow.log_params({"algorithm": alg, "category": cat, "dataset": ds})
75
+ mlflow.log_metrics(metrics)
76
+ except Exception:
77
+ pass
78
+
79
+
80
+ # Seed in background so startup isn't delayed
81
+ threading.Thread(target=_seed_demo, daemon=True).start()
82
+
83
+
84
+ # ══════════════════════════════════════════════════════════════════════════════
85
+ # PAGE ROUTES
86
+ # ══════════════════════════════════════════════════════════════════════════════
87
+
88
+ @app.route("/")
89
+ def dashboard():
90
+ client = _mlflow_client()
91
+ try:
92
+ runs = client.search_runs(
93
+ experiment_ids=[],
94
+ max_results=200,
95
+ order_by=["start_time DESC"],
96
+ )
97
+ except Exception:
98
+ runs = []
99
+
100
+ # Stats
101
+ total_runs = len(runs)
102
+ completed = [r for r in runs if r.info.status == "FINISHED"]
103
+ best_acc = 0.0
104
+ for r in completed:
105
+ acc = r.data.metrics.get("accuracy") or r.data.metrics.get("r2_score") or 0
106
+ if acc > best_acc:
107
+ best_acc = acc
108
+
109
+ # Recent runs (last 8)
110
+ recent = []
111
+ for r in runs[:8]:
112
+ metrics = r.data.metrics
113
+ primary = (metrics.get("accuracy") or metrics.get("r2_score") or 0)
114
+ recent.append({
115
+ "run_id": r.info.run_id[:8],
116
+ "algorithm": r.data.tags.get("algorithm", "—"),
117
+ "dataset": r.data.tags.get("dataset", "—"),
118
+ "category": r.data.tags.get("category", "—"),
119
+ "task_type": r.data.tags.get("task_type", "classification"),
120
+ "primary_metric": round(primary, 4),
121
+ "status": r.info.status,
122
+ "duration": round((r.info.end_time - r.info.start_time) / 1000, 1)
123
+ if r.info.end_time else "—",
124
+ })
125
+
126
+ # Algorithm distribution
127
+ algo_counts: dict = {}
128
+ for r in completed:
129
+ cat = r.data.tags.get("category", "Other")
130
+ algo_counts[cat] = algo_counts.get(cat, 0) + 1
131
+
132
+ # Dataset distribution
133
+ ds_counts: dict = {}
134
+ for r in completed:
135
+ ds = r.data.tags.get("dataset", "Other")
136
+ ds_counts[ds] = ds_counts.get(ds, 0) + 1
137
+
138
+ return render_template("dashboard.html",
139
+ total_runs=total_runs,
140
+ completed_runs=len(completed),
141
+ best_metric=round(best_acc, 4),
142
+ n_experiments=len(set(r.info.experiment_id for r in runs)),
143
+ recent_runs=recent,
144
+ algo_counts=json.dumps(algo_counts),
145
+ ds_counts=json.dumps(ds_counts),
146
+ datasets=DATASETS,
147
+ algorithms=ALGORITHMS)
148
+
149
+
150
+ @app.route("/experiments")
151
+ def experiments():
152
+ client = _mlflow_client()
153
+ try:
154
+ exps = client.search_experiments()
155
+ except Exception:
156
+ exps = []
157
+ exp_list = []
158
+ for e in exps:
159
+ runs = client.search_runs([e.experiment_id], max_results=100,
160
+ order_by=["start_time DESC"])
161
+ finished = [r for r in runs if r.info.status == "FINISHED"]
162
+ best = 0.0
163
+ for r in finished:
164
+ v = r.data.metrics.get("accuracy") or r.data.metrics.get("r2_score") or 0
165
+ if v > best:
166
+ best = v
167
+ exp_list.append({
168
+ "experiment_id": e.experiment_id,
169
+ "name": e.name,
170
+ "run_count": len(runs),
171
+ "best_metric": round(best, 4),
172
+ "created_at": datetime.fromtimestamp(e.creation_time / 1000).strftime("%Y-%m-%d %H:%M")
173
+ if e.creation_time else "—",
174
+ })
175
+ return render_template("experiments.html", experiments=exp_list)
176
+
177
+
178
+ @app.route("/experiments/<experiment_id>")
179
+ def experiment_runs(experiment_id):
180
+ client = _mlflow_client()
181
+ exp = client.get_experiment(experiment_id)
182
+ runs = client.search_runs([experiment_id], max_results=200,
183
+ order_by=["start_time DESC"])
184
+ run_list = []
185
+ for r in runs:
186
+ m = r.data.metrics
187
+ run_list.append({
188
+ "run_id": r.info.run_id,
189
+ "run_id_short": r.info.run_id[:8],
190
+ "algorithm": r.data.tags.get("algorithm", "—"),
191
+ "category": r.data.tags.get("category", "—"),
192
+ "dataset": r.data.tags.get("dataset", "—"),
193
+ "task_type": r.data.tags.get("task_type", "classification"),
194
+ "metrics": {k: round(v, 4) for k, v in m.items()},
195
+ "params": r.data.params,
196
+ "status": r.info.status,
197
+ "duration": round((r.info.end_time - r.info.start_time) / 1000, 1)
198
+ if r.info.end_time else None,
199
+ "start_time": datetime.fromtimestamp(r.info.start_time / 1000)
200
+ .strftime("%Y-%m-%d %H:%M:%S")
201
+ if r.info.start_time else "—",
202
+ })
203
+ return render_template("runs.html",
204
+ experiment={"name": exp.name, "id": experiment_id},
205
+ runs=run_list)
206
+
207
+
208
+ @app.route("/pipeline")
209
+ def pipeline():
210
+ dags = {}
211
+ for pid, builder in PIPELINE_BUILDERS.items():
212
+ dags[pid] = builder().to_dict()
213
+ return render_template("pipeline.html", dags=json.dumps(dags),
214
+ datasets=DATASETS)
215
+
216
+
217
+ @app.route("/models")
218
+ def models():
219
+ client = _mlflow_client()
220
+ try:
221
+ registered = client.search_registered_models()
222
+ except Exception:
223
+ registered = []
224
+ model_list = []
225
+ for m in registered:
226
+ versions = client.get_latest_versions(m.name)
227
+ ver_list = []
228
+ for v in versions:
229
+ run = None
230
+ metrics = {}
231
+ try:
232
+ run = client.get_run(v.run_id)
233
+ metrics = {k: round(val, 4) for k, val in run.data.metrics.items()}
234
+ except Exception:
235
+ pass
236
+ ver_list.append({
237
+ "version": v.version,
238
+ "stage": v.current_stage,
239
+ "run_id": v.run_id[:8] if v.run_id else "—",
240
+ "metrics": metrics,
241
+ "created_at": datetime.fromtimestamp(v.creation_timestamp / 1000)
242
+ .strftime("%Y-%m-%d %H:%M")
243
+ if v.creation_timestamp else "—",
244
+ })
245
+ model_list.append({
246
+ "name": m.name,
247
+ "description": m.description or "—",
248
+ "versions": ver_list,
249
+ "latest_stage": ver_list[0]["stage"] if ver_list else "None",
250
+ })
251
+ return render_template("models.html", models=model_list)
252
+
253
+
254
+ @app.route("/automl")
255
+ def automl():
256
+ return render_template("automl.html",
257
+ datasets=DATASETS,
258
+ algorithms=ALGORITHMS)
259
+
260
+
261
+ # ══════════════════════════════════════════════════════════════════════════════
262
+ # API — TRAINING
263
+ # ══════════════════════════════════════════════════════════════════════════════
264
+
265
+ @app.route("/api/train", methods=["POST"])
266
+ def api_train():
267
+ data = request.get_json(force=True)
268
+ required = ["dataset", "algorithm", "category", "task_type"]
269
+ if not all(k in data for k in required):
270
+ return jsonify({"error": f"Missing fields: {required}"}), 400
271
+ job_id = start_training(
272
+ dataset_name=data["dataset"],
273
+ algorithm_name=data["algorithm"],
274
+ algorithm_category=data["category"],
275
+ task_type=data["task_type"],
276
+ custom_params=data.get("params"),
277
+ )
278
+ return jsonify({"job_id": job_id, "status": "queued"})
279
+
280
+
281
+ @app.route("/api/run/<job_id>/status")
282
+ def api_run_status(job_id):
283
+ job = training_jobs.get(job_id)
284
+ if not job:
285
+ return jsonify({"error": "Job not found"}), 404
286
+ return jsonify(job)
287
+
288
+
289
+ @app.route("/api/runs")
290
+ def api_runs():
291
+ client = _mlflow_client()
292
+ exp_filter = request.args.get("experiment")
293
+ task_filter = request.args.get("task")
294
+ try:
295
+ exp_ids = []
296
+ if exp_filter:
297
+ exp = client.get_experiment_by_name(exp_filter)
298
+ if exp:
299
+ exp_ids = [exp.experiment_id]
300
+ runs = client.search_runs(
301
+ experiment_ids=exp_ids or [],
302
+ max_results=200,
303
+ order_by=["start_time DESC"],
304
+ )
305
+ except Exception:
306
+ runs = []
307
+ result = []
308
+ for r in runs:
309
+ if task_filter and r.data.tags.get("task_type") != task_filter:
310
+ continue
311
+ m = r.data.metrics
312
+ result.append({
313
+ "run_id": r.info.run_id,
314
+ "algorithm": r.data.tags.get("algorithm", "—"),
315
+ "category": r.data.tags.get("category", "—"),
316
+ "dataset": r.data.tags.get("dataset", "—"),
317
+ "task_type": r.data.tags.get("task_type", "classification"),
318
+ "metrics": {k: round(v, 4) for k, v in m.items()},
319
+ "status": r.info.status,
320
+ "start_time": r.info.start_time,
321
+ })
322
+ return jsonify(result)
323
+
324
+
325
+ # ══════════════════════════════════════════════════════════════════════════════
326
+ # API — PIPELINE
327
+ # ══════════════════════════════════════════════════════════════════════════════
328
+
329
+ @app.route("/api/pipeline/<pipeline_id>/execute", methods=["POST"])
330
+ def api_pipeline_execute(pipeline_id):
331
+ try:
332
+ dag = get_pipeline(pipeline_id)
333
+ except ValueError as e:
334
+ return jsonify({"error": str(e)}), 400
335
+ ctx = request.get_json(force=True) or {}
336
+ exec_id = execute_dag(dag, ctx)
337
+ return jsonify({"exec_id": exec_id, "status": "queued"})
338
+
339
+
340
+ @app.route("/api/pipeline/status/<exec_id>")
341
+ def api_pipeline_status(exec_id):
342
+ state = pipeline_executions.get(exec_id)
343
+ if not state:
344
+ return jsonify({"error": "Execution not found"}), 404
345
+ return jsonify(state)
346
+
347
+
348
+ @app.route("/api/pipeline/<pipeline_id>/dag")
349
+ def api_pipeline_dag(pipeline_id):
350
+ try:
351
+ dag = get_pipeline(pipeline_id)
352
+ except ValueError as e:
353
+ return jsonify({"error": str(e)}), 400
354
+ return jsonify(dag.to_dict())
355
+
356
+
357
+ # ══════════════════════════════════════════════════════════════════════════════
358
+ # API — MODEL REGISTRY
359
+ # ══════════════════════════════════════════════════════════════════════════════
360
+
361
+ @app.route("/api/models/register", methods=["POST"])
362
+ def api_models_register():
363
+ data = request.get_json(force=True)
364
+ run_id = data.get("run_id")
365
+ name = data.get("name")
366
+ if not run_id or not name:
367
+ return jsonify({"error": "run_id and name required"}), 400
368
+ try:
369
+ client = _mlflow_client()
370
+ run = client.get_run(run_id)
371
+ model_uri = f"runs:/{run_id}/model"
372
+ result = mlflow.register_model(model_uri, name)
373
+ return jsonify({"name": result.name, "version": result.version,
374
+ "status": "registered"})
375
+ except Exception as exc:
376
+ return jsonify({"error": str(exc)}), 500
377
+
378
+
379
+ @app.route("/api/models/<name>/<version>/stage", methods=["POST"])
380
+ def api_model_stage(name, version):
381
+ data = request.get_json(force=True)
382
+ stage = data.get("stage", "Staging")
383
+ valid = {"Staging", "Production", "Archived", "None"}
384
+ if stage not in valid:
385
+ return jsonify({"error": f"stage must be one of {valid}"}), 400
386
+ try:
387
+ client = _mlflow_client()
388
+ client.transition_model_version_stage(name=name, version=version,
389
+ stage=stage, archive_existing_versions=False)
390
+ return jsonify({"name": name, "version": version, "stage": stage})
391
+ except Exception as exc:
392
+ return jsonify({"error": str(exc)}), 500
393
+
394
+
395
+ # ══════════════════════════════════════════════════════════════════════════════
396
+ # API — AUTO-ML
397
+ # ══════════════════════════════════════════════════════════════════════════════
398
+
399
+ @app.route("/api/automl", methods=["POST"])
400
+ def api_automl():
401
+ data = request.get_json(force=True)
402
+ if "dataset" not in data or "task_type" not in data:
403
+ return jsonify({"error": "dataset and task_type required"}), 400
404
+ job_id = start_automl(
405
+ dataset_name=data["dataset"],
406
+ task_type=data["task_type"],
407
+ optimize_metric=data.get("metric", "accuracy"),
408
+ max_runs=int(data.get("max_runs", 20)),
409
+ )
410
+ return jsonify({"job_id": job_id, "status": "queued"})
411
+
412
+
413
+ @app.route("/api/automl/status/<job_id>")
414
+ def api_automl_status(job_id):
415
+ job = automl_jobs.get(job_id)
416
+ if not job:
417
+ return jsonify({"error": "Job not found"}), 404
418
+ return jsonify(job)
419
+
420
+
421
+ # ══════════════════════════════════════════════════════════════════════════════
422
+ # API — META
423
+ # ══════════════════════════════════════════════════════════════════════════════
424
+
425
+ @app.route("/api/algorithms")
426
+ def api_algorithms():
427
+ task = request.args.get("task", "classification")
428
+ try:
429
+ return jsonify(list_algorithms(task))
430
+ except ValueError as e:
431
+ return jsonify({"error": str(e)}), 400
432
+
433
+
434
+ @app.route("/api/datasets")
435
+ def api_datasets():
436
+ result = {
437
+ name: {k: v for k, v in cfg.items() if k != "loader"}
438
+ for name, cfg in DATASETS.items()
439
+ }
440
+ return jsonify(result)
441
+
442
+
443
+ @app.route("/api/stats")
444
+ def api_stats():
445
+ client = _mlflow_client()
446
+ try:
447
+ runs = client.search_runs(experiment_ids=[], max_results=500)
448
+ except Exception:
449
+ runs = []
450
+ finished = [r for r in runs if r.info.status == "FINISHED"]
451
+ best = 0.0
452
+ for r in finished:
453
+ v = r.data.metrics.get("accuracy") or r.data.metrics.get("r2_score") or 0
454
+ if v > best:
455
+ best = v
456
+ return jsonify({
457
+ "total_runs": len(runs),
458
+ "completed_runs": len(finished),
459
+ "best_metric": round(best, 4),
460
+ "n_experiments": len(set(r.info.experiment_id for r in runs)),
461
+ })
462
+
463
+
464
+ # ── Entry point ─────────────────────────────���──────────────────────────────────
465
+ if __name__ == "__main__":
466
+ app.run(host="0.0.0.0", port=7860, debug=False)
docs-template.html ADDED
@@ -0,0 +1,963 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>[PROJECT TITLE] · [YOUR NAME]</title>
6
+
7
+ <!-- ▶ THEME FLICKER FIX -->
8
+ <script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mn-theme') || 'dark')</script>
9
+
10
+ <!-- ▶ FAVICON: Replace initials "MN" and colors as needed -->
11
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%234f8ef7'/%3E%3Cstop offset='100%25' stop-color='%2306b6d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='%23070d1f'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='central' text-anchor='middle' font-family='Segoe UI,system-ui,sans-serif' font-weight='900' font-size='26' fill='url(%23g)'%3E[INITIALS]%3C/text%3E%3C/svg%3E">
12
+
13
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
14
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
15
+
16
+ <!-- ▶ Point these to your shared stylesheet and script if you have them -->
17
+ <!-- <link rel="stylesheet" href="/projects/shared.css"> -->
18
+ <!-- <script src="/projects/shared.js" defer></script> -->
19
+
20
+ <style>
21
+ /* ═══════════════════════════════════════════════════
22
+ CSS VARIABLES — Light/Dark theme
23
+ Accent: blue (#4f8ef7) + gold (#f59e0b)
24
+ Change --accent and --gold to match your project.
25
+ ════════════════════════════════════════════════════ */
26
+ :root {
27
+ --accent: #4f8ef7;
28
+ --gold: #f59e0b;
29
+ --teal: #06b6d4;
30
+ --green: #22c55e;
31
+ --radius: 14px;
32
+
33
+ /* Dark theme defaults */
34
+ --body-bg: #070d1f;
35
+ --text: #e2e8f0;
36
+ --muted: #8892a4;
37
+ --glass: rgba(255,255,255,.04);
38
+ --glass-border: rgba(255,255,255,.08);
39
+ --card-hover-bg: rgba(255,255,255,.07);
40
+ --card-hover-border:rgba(79,142,247,.3);
41
+ --section-alt: #0b1120;
42
+ }
43
+ [data-theme="light"] {
44
+ --body-bg: #f8fafc;
45
+ --text: #0f172a;
46
+ --muted: #4b5675;
47
+ --glass: rgba(0,0,0,.03);
48
+ --glass-border: rgba(0,0,0,.08);
49
+ --card-hover-bg: rgba(0,0,0,.05);
50
+ --card-hover-border:rgba(37,99,235,.25);
51
+ --section-alt: #f1f5f9;
52
+ }
53
+
54
+ * { box-sizing: border-box; margin: 0; padding: 0; }
55
+ body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--body-bg); color: var(--text); transition: background .35s, color .35s; }
56
+ a { text-decoration: none; }
57
+ code { font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: .88em; background: rgba(79,142,247,.1); padding: 1px 5px; border-radius: 4px; }
58
+
59
+ /* ── SECTION TAG ── */
60
+ .s-tag { display: inline-block; font-size: .7rem; font-weight: 800; text-transform: uppercase; letter-spacing: .1em; padding: 3px 10px; border-radius: 6px; margin-bottom: 10px; }
61
+ .s-tag-blue { background: rgba(79,142,247,.12); color: var(--accent); border: 1px solid rgba(79,142,247,.2); }
62
+ .s-tag-gold { background: rgba(245,158,11,.12); color: var(--gold); border: 1px solid rgba(245,158,11,.2); }
63
+ .s-tag-teal { background: rgba(6,182,212,.12); color: var(--teal); border: 1px solid rgba(6,182,212,.2); }
64
+
65
+ /* ── GRADIENT TEXT ── */
66
+ .grad-text { background: linear-gradient(135deg, var(--accent), var(--gold)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
67
+
68
+ /* ── HERO ── */
69
+ .hero {
70
+ padding: 80px 24px 56px;
71
+ background: var(--body-bg);
72
+ position: relative; overflow: hidden; transition: background .35s;
73
+ }
74
+ .hero::before {
75
+ content: ''; position: absolute; inset: 0; pointer-events: none;
76
+ background: radial-gradient(ellipse 80% 55% at 50% -10%, rgba(79,142,247,.15) 0%, transparent 65%);
77
+ }
78
+ [data-theme="light"] .hero::before {
79
+ background: radial-gradient(ellipse 80% 55% at 50% -10%, rgba(37,99,235,.09) 0%, transparent 65%);
80
+ }
81
+ .hero::after {
82
+ content: ''; position: absolute; inset: 0; pointer-events: none;
83
+ background-image: linear-gradient(rgba(79,142,247,.035) 1px, transparent 1px),
84
+ linear-gradient(90deg, rgba(79,142,247,.035) 1px, transparent 1px);
85
+ background-size: 48px 48px;
86
+ }
87
+ .hero-inner { max-width: 1100px; margin: 0 auto; position: relative; z-index: 1; }
88
+
89
+ /* ── BREADCRUMB ── */
90
+ .breadcrumb { font-size: .78rem; color: var(--muted); margin-bottom: 18px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
91
+ .breadcrumb a { color: var(--muted); transition: .2s; }
92
+ .breadcrumb a:hover { color: var(--accent); }
93
+ .breadcrumb span { opacity: .4; }
94
+
95
+ /* ── PILLS ── */
96
+ .tag-row { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; }
97
+ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 14px; border-radius: 20px; font-size: .75rem; font-weight: 700; letter-spacing: .04em; }
98
+ .pill-blue { background: rgba(79,142,247,.12); border: 1px solid rgba(79,142,247,.25); color: var(--accent); }
99
+ .pill-gold { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.25); color: var(--gold); }
100
+ .pill-teal { background: rgba(6,182,212,.12); border: 1px solid rgba(6,182,212,.25); color: var(--teal); }
101
+ [data-theme="light"] .pill-blue { background: rgba(37,99,235,.1); border-color: rgba(37,99,235,.25); }
102
+ [data-theme="light"] .pill-gold { background: rgba(217,119,6,.1); border-color: rgba(217,119,6,.25); color: #92400e; }
103
+ [data-theme="light"] .pill-teal { background: rgba(8,145,178,.1); border-color: rgba(8,145,178,.25); color: #0e7490; }
104
+
105
+ h1 { font-size: clamp(1.7rem,3.5vw,2.7rem); font-weight: 900; line-height: 1.2; margin-bottom: 20px; max-width: 820px; color: var(--text); }
106
+ .hero-sub { font-size: 1rem; color: var(--muted); max-width: 680px; margin-bottom: 28px; line-height: 1.65; }
107
+ .hero-sub strong { color: var(--text); }
108
+ .hero-meta { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin-bottom: 24px; font-size: .83rem; color: var(--muted); }
109
+ .hero-meta span { display: flex; align-items: center; gap: 6px; }
110
+ .hero-meta i { color: var(--accent); }
111
+ .hero-actions { display: flex; gap: 10px; flex-wrap: wrap; }
112
+
113
+ /* ── BUTTONS ── */
114
+ .btn { display: inline-flex; align-items: center; gap: 8px; padding: 9px 20px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: 1px solid transparent; transition: all .2s; font-family: inherit; text-decoration: none; }
115
+ .btn-blue { background: rgba(79,142,247,.18); color: var(--accent); border-color: rgba(79,142,247,.35); }
116
+ .btn-blue:hover { background: rgba(79,142,247,.3); transform: translateY(-2px); }
117
+ .btn-gold { background: rgba(245,158,11,.15); color: var(--gold); border-color: rgba(245,158,11,.35); }
118
+ .btn-gold:hover { background: rgba(245,158,11,.28); transform: translateY(-2px); }
119
+ .btn-gray { background: var(--glass); color: var(--text); border-color: var(--glass-border); }
120
+ .btn-gray:hover { background: var(--card-hover-bg); transform: translateY(-2px); }
121
+ .btn-back { background: var(--glass); color: var(--muted); border-color: var(--glass-border); }
122
+ .btn-back:hover { color: var(--accent); border-color: var(--card-hover-border); transform: translateY(-2px); }
123
+
124
+ /* ── STATS BAR ── */
125
+ .stats-bar { background: var(--section-alt); border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border); transition: background .35s; }
126
+ .stats-inner { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: repeat(5,1fr); gap: 1px; background: var(--glass-border); }
127
+ .stat-item { background: var(--section-alt); padding: 22px 16px; text-align: center; transition: background .35s; }
128
+ .stat-val { font-size: 1.8rem; font-weight: 900; background: linear-gradient(135deg,var(--accent),var(--gold)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1.1; margin-bottom: 4px; }
129
+ .stat-label { font-size: .75rem; color: var(--muted); line-height: 1.4; }
130
+
131
+ /* ── MAIN LAYOUT ── */
132
+ .main-layout { max-width: 1100px; margin: 0 auto; padding: 48px 24px; display: grid; grid-template-columns: 1fr 310px; gap: 32px; align-items: start; }
133
+ .content-col { display: flex; flex-direction: column; gap: 28px; }
134
+ .sidebar { position: sticky; top: 80px; display: flex; flex-direction: column; gap: 20px; }
135
+
136
+ /* ── CARDS ── */
137
+ .card { background: var(--glass); border: 1px solid var(--glass-border); border-radius: var(--radius); padding: 28px; transition: all .25s; }
138
+ .card:hover { background: var(--card-hover-bg); border-color: var(--card-hover-border); transform: translateY(-3px); }
139
+ .card-title { font-size: 1rem; font-weight: 800; margin-bottom: 18px; color: var(--text); display: flex; align-items: center; gap: 10px; }
140
+ .card-title i { color: var(--accent); font-size: .9rem; }
141
+ .narrative { font-size: .92rem; color: var(--muted); margin-bottom: 10px; line-height: 1.7; }
142
+ .narrative strong { color: var(--text); }
143
+
144
+ /* ── PIPELINE ── */
145
+ .pipeline { display: flex; align-items: stretch; gap: 0; margin: 20px 0; overflow-x: auto; padding-bottom: 4px; }
146
+ .pipe-step { flex: 1; min-width: 120px; background: var(--glass); border: 1px solid var(--glass-border); border-radius: 10px; padding: 16px 10px; text-align: center; transition: .25s; }
147
+ .pipe-step:hover { background: var(--card-hover-bg); border-color: var(--card-hover-border); transform: translateY(-3px); }
148
+ .pipe-arrow { display: flex; align-items: center; justify-content: center; width: 28px; flex-shrink: 0; color: var(--muted); font-size: .8rem; padding-top: 10px; }
149
+ .pipe-icon { font-size: 1.8rem; margin-bottom: 8px; line-height: 1; }
150
+ .pipe-label { font-size: .75rem; font-weight: 700; color: var(--text); margin-bottom: 4px; }
151
+ .pipe-sub { font-size: .7rem; color: var(--muted); line-height: 1.4; }
152
+
153
+ /* ── MODULE GRID ── */
154
+ .module-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 16px 0; }
155
+ .mod-card { border-radius: 12px; padding: 20px; border: 1px solid; transition: .25s; }
156
+ .mod-card:hover { transform: translateY(-3px); }
157
+ /* ▶ Add your own color classes here like .mod-1 { background: rgba(79,142,247,.05); border-color: rgba(79,142,247,.2); } */
158
+ .mod-1 { background: rgba(79,142,247,.05); border-color: rgba(79,142,247,.2); }
159
+ .mod-2 { background: rgba(239,68,68,.05); border-color: rgba(239,68,68,.18); }
160
+ .mod-3 { background: rgba(245,158,11,.05); border-color: rgba(245,158,11,.18); }
161
+ .mod-4 { background: rgba(6,182,212,.05); border-color: rgba(6,182,212,.18); }
162
+ .mod-5 { background: rgba(167,139,250,.05); border-color: rgba(167,139,250,.2); }
163
+ .mod-6 { background: rgba(34,197,94,.05); border-color: rgba(34,197,94,.18); }
164
+ .mod-badge { display: inline-flex; align-items: center; gap: 6px; font-size: .72rem; font-weight: 700; padding: 3px 10px; border-radius: 8px; margin-bottom: 8px; }
165
+ .mod-name { font-size: .93rem; font-weight: 800; margin-bottom: 5px; color: var(--text); }
166
+ .mod-desc { font-size: .77rem; color: var(--muted); line-height: 1.5; margin-bottom: 10px; }
167
+ .mod-detail { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--glass-border); font-size: .77rem; }
168
+ .mod-detail:last-child { border-bottom: none; }
169
+ .mod-detail-key { color: var(--muted); }
170
+
171
+ /* ── INSIGHT BANNER ── */
172
+ .insight-banner { background: linear-gradient(135deg,rgba(79,142,247,.07),rgba(245,158,11,.07)); border: 1px solid rgba(79,142,247,.22); border-radius: var(--radius); padding: 22px; margin-top: 8px; display: flex; gap: 16px; align-items: flex-start; }
173
+ .insight-icon { font-size: 2rem; flex-shrink: 0; }
174
+ .insight-body h4 { font-size: .95rem; font-weight: 800; color: var(--text); margin-bottom: 5px; }
175
+ .insight-body p { font-size: .85rem; color: var(--muted); line-height: 1.6; }
176
+ .insight-body strong { color: var(--accent); }
177
+
178
+ /* ── ITEM STACK (reusable list of rows) ── */
179
+ .item-stack { display: flex; flex-direction: column; gap: 8px; margin: 14px 0; }
180
+ .item-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--glass); border: 1px solid var(--glass-border); border-radius: 8px; font-size: .82rem; transition: .2s; }
181
+ .item-row:hover { background: var(--card-hover-bg); }
182
+ .item-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: .9rem; flex-shrink: 0; }
183
+ .item-name { color: var(--text); font-weight: 600; flex: 1; }
184
+ .item-sub { font-size: .72rem; color: var(--muted); }
185
+ .item-tag { font-size: .7rem; padding: 2px 8px; border-radius: 6px; font-weight: 700; white-space: nowrap; }
186
+ .tag-blue { background: rgba(79,142,247,.15); color: var(--accent); border: 1px solid rgba(79,142,247,.3); }
187
+ .tag-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
188
+ .tag-green{ background: rgba(34,197,94,.15); color: var(--green); border: 1px solid rgba(34,197,94,.3); }
189
+ .tag-gold { background: rgba(245,158,11,.15); color: var(--gold); border: 1px solid rgba(245,158,11,.3); }
190
+
191
+ /* ── DEMO / INTERACTIVE BLOCK ── */
192
+ .demo-block { background: rgba(79,142,247,.04); border: 1px solid rgba(79,142,247,.15); border-radius: var(--radius); padding: 28px; }
193
+ .demo-intro { font-size: .85rem; color: var(--muted); margin-bottom: 18px; font-style: italic; }
194
+ .scenario-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
195
+ .scen-btn { padding: 7px 16px; border-radius: 20px; font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--glass); border: 1px solid var(--glass-border); color: var(--muted); transition: .2s; font-family: inherit; }
196
+ .scen-btn.active, .scen-btn:hover { background: rgba(79,142,247,.15); border-color: rgba(79,142,247,.35); color: var(--accent); }
197
+ .result-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 14px; }
198
+ .res-card { background: var(--glass); border: 1px solid var(--glass-border); border-radius: 10px; padding: 14px; text-align: center; transition: .2s; }
199
+ .res-card:hover { background: var(--card-hover-bg); transform: translateY(-2px); }
200
+ .res-label { font-size: .68rem; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; margin-bottom: 4px; }
201
+ .res-val { font-size: 1.4rem; font-weight: 900; line-height: 1.1; }
202
+ .res-sub { font-size: .72rem; color: var(--muted); margin-top: 2px; }
203
+ .risk-bar-wrap { margin: 14px 0; }
204
+ .risk-bar-label { display: flex; justify-content: space-between; font-size: .8rem; margin-bottom: 5px; }
205
+ .risk-bar-track { height: 10px; border-radius: 5px; background: var(--glass); overflow: hidden; }
206
+ .risk-bar-fill { height: 100%; border-radius: 5px; transition: width .7s ease; }
207
+ .demo-note { font-size: .73rem; color: var(--muted); font-style: italic; margin-top: 14px; text-align: center; }
208
+
209
+ /* ─��� CHART TABS ── */
210
+ .chart-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
211
+ .chart-tab { padding: 7px 14px; border-radius: 20px; font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--glass); border: 1px solid var(--glass-border); color: var(--muted); transition: .2s; }
212
+ .chart-tab.active { background: rgba(79,142,247,.15); border-color: rgba(79,142,247,.35); color: var(--accent); }
213
+ .chart-panel { display: none; }
214
+ .chart-panel.active { display: block; }
215
+ .chart-wrap { position: relative; height: 280px; }
216
+ .chart-caption { font-size: .8rem; color: var(--muted); margin-top: 10px; font-style: italic; text-align: center; }
217
+
218
+ /* ── TAKEAWAYS / HIGHLIGHTS ── */
219
+ .takeaway-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; margin-top: 8px; }
220
+ .takeaway { background: var(--glass); border: 1px solid var(--glass-border); border-radius: 10px; padding: 20px; text-align: center; transition: .2s; }
221
+ .takeaway:hover { background: var(--card-hover-bg); transform: translateY(-3px); }
222
+ .tk-icon { font-size: 2rem; margin-bottom: 8px; }
223
+ .tk-val { font-size: 1.2rem; font-weight: 900; background: linear-gradient(135deg,var(--accent),var(--gold)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin-bottom: 4px; }
224
+ .tk-label { font-size: .78rem; color: var(--muted); line-height: 1.45; }
225
+
226
+ /* ── SIDEBAR ── */
227
+ .sidebar-card { background: var(--glass); border: 1px solid var(--glass-border); border-radius: var(--radius); padding: 20px; }
228
+ .sidebar-card h3 { font-size: .82rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
229
+ .tldr-text { font-size: .87rem; color: var(--muted); line-height: 1.7; }
230
+ .tldr-text strong { color: var(--text); }
231
+ .info-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--glass-border); font-size: .82rem; gap: 8px; }
232
+ .info-row:last-child { border-bottom: none; }
233
+ .info-key { color: var(--muted); flex-shrink: 0; }
234
+ .info-val { color: var(--text); font-weight: 600; text-align: right; font-size: .79rem; }
235
+ .tech-pills { display: flex; flex-wrap: wrap; gap: 6px; }
236
+ .tech-pill { background: rgba(79,142,247,.1); border: 1px solid rgba(79,142,247,.2); border-radius: 6px; padding: 3px 10px; font-size: .75rem; color: var(--accent); font-weight: 600; }
237
+ [data-theme="light"] .tech-pill { background: rgba(37,99,235,.08); border-color: rgba(37,99,235,.2); }
238
+ .sidebar-links { display: flex; flex-direction: column; gap: 8px; }
239
+ .sidebar-link { display: flex; align-items: center; gap: 10px; padding: 9px 12px; background: var(--glass); border: 1px solid var(--glass-border); border-radius: 8px; font-size: .82rem; color: var(--muted); transition: .2s; text-decoration: none; }
240
+ .sidebar-link:hover { background: var(--card-hover-bg); border-color: var(--card-hover-border); color: var(--text); }
241
+ .sidebar-link i { color: var(--accent); width: 16px; text-align: center; }
242
+ .hf-btn { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: linear-gradient(135deg,rgba(255,175,7,.12),rgba(255,175,7,.06)); border: 1px solid rgba(255,175,7,.3); border-radius: 10px; font-size: .85rem; font-weight: 700; color: #f59e0b; transition: .2s; text-decoration: none; }
243
+ .hf-btn:hover { background: linear-gradient(135deg,rgba(255,175,7,.2),rgba(255,175,7,.1)); transform: translateY(-2px); }
244
+
245
+ /* ── RESPONSIVE ── */
246
+ @media (max-width: 1000px) {
247
+ .main-layout { grid-template-columns: 1fr; }
248
+ .sidebar { position: static; }
249
+ .module-grid { grid-template-columns: 1fr 1fr; }
250
+ .takeaway-grid { grid-template-columns: 1fr 1fr; }
251
+ .stats-inner { grid-template-columns: repeat(3,1fr); }
252
+ .result-grid { grid-template-columns: 1fr 1fr; }
253
+ }
254
+ @media (max-width: 600px) {
255
+ .hero { padding: 70px 16px 40px; }
256
+ .pipeline { flex-direction: column; }
257
+ .module-grid { grid-template-columns: 1fr; }
258
+ .takeaway-grid { grid-template-columns: 1fr; }
259
+ .stats-inner { grid-template-columns: repeat(2,1fr); }
260
+ .result-grid { grid-template-columns: 1fr; }
261
+ }
262
+ </style>
263
+ </head>
264
+
265
+ <body>
266
+
267
+ <!-- ═══════════════════════════════════════════
268
+ HERO SECTION
269
+ Replace all [PLACEHOLDER] text below.
270
+ ════════════════════════════════════════════ -->
271
+ <section class="hero">
272
+ <div class="hero-inner">
273
+
274
+ <!-- Breadcrumb navigation -->
275
+ <div class="breadcrumb">
276
+ <a href="/index.html"><i class="fas fa-home"></i> Home</a>
277
+ <span>›</span>
278
+ <a href="/projects/index.html">Projects</a>
279
+ <span>›</span>
280
+ <span style="color:var(--text)">[PROJECT NAME]</span>
281
+ </div>
282
+
283
+ <!-- Category pills: edit icon, text for each pill -->
284
+ <div class="tag-row">
285
+ <span class="pill pill-blue"><i class="fas fa-tag"></i> [DOMAIN / CATEGORY]</span>
286
+ <span class="pill pill-teal"><i class="fab fa-python"></i> [TECH STACK SUMMARY]</span>
287
+ <span class="pill pill-gold"><i class="fas fa-rocket"></i> [STATUS / PLATFORM]</span>
288
+ </div>
289
+
290
+ <!-- Main title: wrap gradient words in <span class="grad-text"> -->
291
+ <h1>[PROJECT NAME] — <span class="grad-text">[SUBTITLE OR TAGLINE]</span></h1>
292
+
293
+ <!-- One or two sentence description -->
294
+ <p class="hero-sub">
295
+ [BRIEF DESCRIPTION OF WHAT THE PROJECT IS AND WHAT IT DOES.]
296
+ <strong>[KEY CAPABILITY OR DIFFERENTIATOR.]</strong>
297
+ </p>
298
+
299
+ <!-- Meta info chips -->
300
+ <div class="hero-meta">
301
+ <span><i class="fas fa-calendar-alt"></i> [YEAR / DATE]</span>
302
+ <span><i class="fas fa-user"></i> <strong>[YOUR NAME]</strong></span>
303
+ <span><i class="fas fa-database"></i> [DATASET SIZE / TYPE]</span>
304
+ <span><i class="fas fa-brain"></i> [MODEL COUNT / TYPE]</span>
305
+ </div>
306
+
307
+ <!-- CTA buttons — update hrefs -->
308
+ <div class="hero-actions">
309
+ <a href="#demo" class="btn btn-blue"><i class="fas fa-play-circle"></i> Explore Demo</a>
310
+ <a href="https://huggingface.co/spaces/[YOUR-HF-USERNAME]/[SPACE-NAME]" target="_blank" class="btn btn-gold">
311
+ <i class="fas fa-external-link-alt"></i> Try on HuggingFace
312
+ </a>
313
+ <a href="https://github.com/[YOUR-GITHUB-USERNAME]/[REPO-NAME]" target="_blank" class="btn btn-gray">
314
+ <i class="fab fa-github"></i> View on GitHub
315
+ </a>
316
+ <a href="/projects/index.html" class="btn btn-back"><i class="fas fa-arrow-left"></i> All Projects</a>
317
+ </div>
318
+
319
+ </div>
320
+ </section>
321
+
322
+
323
+ <!-- ═══════════════════════════════════════════
324
+ STATS BAR — 5 key numbers/facts
325
+ ════════════════════════════════════════════ -->
326
+ <div class="stats-bar">
327
+ <div class="stats-inner">
328
+ <!-- Replace [VALUE] and [DESCRIPTION] for each stat -->
329
+ <div class="stat-item">
330
+ <div class="stat-val">[VALUE 1]</div>
331
+ <div class="stat-label">[Description of stat 1]</div>
332
+ </div>
333
+ <div class="stat-item">
334
+ <div class="stat-val">[VALUE 2]</div>
335
+ <div class="stat-label">[Description of stat 2]</div>
336
+ </div>
337
+ <div class="stat-item">
338
+ <div class="stat-val">[VALUE 3]</div>
339
+ <div class="stat-label">[Description of stat 3]</div>
340
+ </div>
341
+ <div class="stat-item">
342
+ <div class="stat-val">[VALUE 4]</div>
343
+ <div class="stat-label">[Description of stat 4]</div>
344
+ </div>
345
+ <div class="stat-item">
346
+ <div class="stat-val">[VALUE 5]</div>
347
+ <div class="stat-label">[Description of stat 5]</div>
348
+ </div>
349
+ </div>
350
+ </div>
351
+
352
+
353
+ <!-- ═══════════════════════════════════════════
354
+ MAIN LAYOUT: content column + sidebar
355
+ ════════════════════════════════════════════ -->
356
+ <div class="main-layout">
357
+ <div class="content-col">
358
+
359
+
360
+ <!-- ─────────────────────────────────────────
361
+ CARD 1: Architecture / Pipeline
362
+ ──────────────────────────────────────────── -->
363
+ <div class="card">
364
+ <div class="s-tag s-tag-blue">Architecture Overview</div>
365
+ <h2 class="card-title"><i class="fas fa-route"></i> [PIPELINE / ARCHITECTURE TITLE]</h2>
366
+
367
+ <!-- Narrative paragraph -->
368
+ <p class="narrative">
369
+ [Describe your system architecture and data flow here.
370
+ <strong>Highlight the key design decisions in bold.</strong>
371
+ Keep it 3–5 sentences.]
372
+ </p>
373
+
374
+ <!-- Pipeline steps — add/remove steps as needed -->
375
+ <div class="pipeline">
376
+ <div class="pipe-step">
377
+ <div class="pipe-icon">🗄️</div>
378
+ <div class="pipe-label">[Step 1 Name]</div>
379
+ <div class="pipe-sub">[Brief description]</div>
380
+ </div>
381
+ <div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
382
+ <div class="pipe-step">
383
+ <div class="pipe-icon">🧠</div>
384
+ <div class="pipe-label">[Step 2 Name]</div>
385
+ <div class="pipe-sub">[Brief description]</div>
386
+ </div>
387
+ <div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
388
+ <div class="pipe-step">
389
+ <div class="pipe-icon">📊</div>
390
+ <div class="pipe-label">[Step 3 Name]</div>
391
+ <div class="pipe-sub">[Brief description]</div>
392
+ </div>
393
+ <div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
394
+ <div class="pipe-step">
395
+ <div class="pipe-icon">🌐</div>
396
+ <div class="pipe-label">[Step 4 Name]</div>
397
+ <div class="pipe-sub">[Brief description]</div>
398
+ </div>
399
+ <div class="pipe-arrow"><i class="fas fa-chevron-right"></i></div>
400
+ <div class="pipe-step">
401
+ <div class="pipe-icon">🚀</div>
402
+ <div class="pipe-label">[Step 5 Name]</div>
403
+ <div class="pipe-sub">[Brief description]</div>
404
+ </div>
405
+ </div>
406
+
407
+ <!-- Insight banner — a highlighted callout box -->
408
+ <div class="insight-banner">
409
+ <div class="insight-icon">💡</div>
410
+ <div class="insight-body">
411
+ <h4>[Callout / Insight Title]</h4>
412
+ <p>[Explain a key insight, design choice, or interesting fact about your architecture.
413
+ <strong>Highlight the most important part.</strong>]</p>
414
+ </div>
415
+ </div>
416
+ </div>
417
+
418
+
419
+ <!-- ─────────────────────────────────────────
420
+ CARD 2: Module / Feature Breakdown
421
+ 6-card grid — remove cards if not needed
422
+ ──────────────────────────────────────────── -->
423
+ <div class="card">
424
+ <div class="s-tag s-tag-teal">Module Breakdown</div>
425
+ <h2 class="card-title"><i class="fas fa-layer-group"></i> [MODULES / FEATURES TITLE]</h2>
426
+
427
+ <div class="module-grid">
428
+
429
+ <!-- Module 1 -->
430
+ <div class="mod-card mod-1">
431
+ <div class="mod-badge" style="background:rgba(79,142,247,.12);color:var(--accent);border:1px solid rgba(79,142,247,.22)">
432
+ [EMOJI] [Badge Label]
433
+ </div>
434
+ <div class="mod-name">[Module 1 Name]</div>
435
+ <div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
436
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--accent);font-weight:700">[Value]</span></div>
437
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
438
+ </div>
439
+
440
+ <!-- Module 2 -->
441
+ <div class="mod-card mod-2">
442
+ <div class="mod-badge" style="background:rgba(239,68,68,.12);color:#f87171;border:1px solid rgba(239,68,68,.22)">
443
+ [EMOJI] [Badge Label]
444
+ </div>
445
+ <div class="mod-name">[Module 2 Name]</div>
446
+ <div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
447
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:#f87171;font-weight:700">[Value]</span></div>
448
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
449
+ </div>
450
+
451
+ <!-- Module 3 -->
452
+ <div class="mod-card mod-3">
453
+ <div class="mod-badge" style="background:rgba(245,158,11,.12);color:var(--gold);border:1px solid rgba(245,158,11,.22)">
454
+ [EMOJI] [Badge Label]
455
+ </div>
456
+ <div class="mod-name">[Module 3 Name]</div>
457
+ <div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
458
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--gold);font-weight:700">[Value]</span></div>
459
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
460
+ </div>
461
+
462
+ <!-- Module 4 -->
463
+ <div class="mod-card mod-4">
464
+ <div class="mod-badge" style="background:rgba(6,182,212,.12);color:var(--teal);border:1px solid rgba(6,182,212,.22)">
465
+ [EMOJI] [Badge Label]
466
+ </div>
467
+ <div class="mod-name">[Module 4 Name]</div>
468
+ <div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
469
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--teal);font-weight:700">[Value]</span></div>
470
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
471
+ </div>
472
+
473
+ <!-- Module 5 -->
474
+ <div class="mod-card mod-5">
475
+ <div class="mod-badge" style="background:rgba(167,139,250,.12);color:#a78bfa;border:1px solid rgba(167,139,250,.22)">
476
+ [EMOJI] [Badge Label]
477
+ </div>
478
+ <div class="mod-name">[Module 5 Name]</div>
479
+ <div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
480
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:#a78bfa;font-weight:700">[Value]</span></div>
481
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
482
+ </div>
483
+
484
+ <!-- Module 6 -->
485
+ <div class="mod-card mod-6">
486
+ <div class="mod-badge" style="background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.22)">
487
+ [EMOJI] [Badge Label]
488
+ </div>
489
+ <div class="mod-name">[Module 6 Name]</div>
490
+ <div class="mod-desc">[Short description of what this module does — 2 to 3 sentences.]</div>
491
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="color:var(--green);font-weight:700">[Value]</span></div>
492
+ <div class="mod-detail"><span class="mod-detail-key">[Key]</span><span style="font-weight:700">[Value]</span></div>
493
+ </div>
494
+
495
+ </div>
496
+ </div>
497
+
498
+
499
+ <!-- ─────────────────────────────────────────
500
+ CARD 3: Tech / Model Stack (row list)
501
+ ──────────────────────────────────────────── -->
502
+ <div class="card">
503
+ <div class="s-tag s-tag-blue">[Stack / Method Section Label]</div>
504
+ <h2 class="card-title"><i class="fas fa-brain"></i> [STACK / MODELS TITLE]</h2>
505
+ <p class="narrative">[Describe your models or technical stack in 2–3 sentences. <strong>Explain why these choices were made.</strong>]</p>
506
+
507
+ <div class="item-stack">
508
+
509
+ <!-- Item row 1 -->
510
+ <div class="item-row">
511
+ <div class="item-icon" style="background:rgba(79,142,247,.15);color:var(--accent)">
512
+ <i class="fas fa-cube"></i>
513
+ </div>
514
+ <div>
515
+ <div class="item-name">[Model / Component 1 Name]</div>
516
+ <div class="item-sub">[Features or details]</div>
517
+ </div>
518
+ <div class="item-tag tag-blue">[Tag label]</div>
519
+ </div>
520
+
521
+ <!-- Item row 2 -->
522
+ <div class="item-row">
523
+ <div class="item-icon" style="background:rgba(239,68,68,.15);color:#f87171">
524
+ <i class="fas fa-cube"></i>
525
+ </div>
526
+ <div>
527
+ <div class="item-name">[Model / Component 2 Name]</div>
528
+ <div class="item-sub">[Features or details]</div>
529
+ </div>
530
+ <div class="item-tag tag-red">[Tag label]</div>
531
+ </div>
532
+
533
+ <!-- Item row 3 -->
534
+ <div class="item-row">
535
+ <div class="item-icon" style="background:rgba(34,197,94,.15);color:var(--green)">
536
+ <i class="fas fa-cube"></i>
537
+ </div>
538
+ <div>
539
+ <div class="item-name">[Model / Component 3 Name]</div>
540
+ <div class="item-sub">[Features or details]</div>
541
+ </div>
542
+ <div class="item-tag tag-green">[Tag label]</div>
543
+ </div>
544
+
545
+ <!-- Item row 4 -->
546
+ <div class="item-row">
547
+ <div class="item-icon" style="background:rgba(245,158,11,.15);color:var(--gold)">
548
+ <i class="fas fa-cube"></i>
549
+ </div>
550
+ <div>
551
+ <div class="item-name">[Model / Component 4 Name]</div>
552
+ <div class="item-sub">[Features or details]</div>
553
+ </div>
554
+ <div class="item-tag tag-gold">[Tag label]</div>
555
+ </div>
556
+
557
+ </div>
558
+
559
+ <!-- Second insight banner -->
560
+ <div class="insight-banner" style="margin-top:16px">
561
+ <div class="insight-icon">⚙️</div>
562
+ <div class="insight-body">
563
+ <h4>[Second Callout Title]</h4>
564
+ <p>[Additional insight about your stack or methodology.
565
+ <strong>Highlight the most important part here.</strong>]</p>
566
+ </div>
567
+ </div>
568
+ </div>
569
+
570
+
571
+ <!-- ─────────────────────────────────────────
572
+ CARD 4: Interactive Demo
573
+ id="demo" is linked by the hero button
574
+ ──────────────────────────────────────────── -->
575
+ <div class="demo-block" id="demo">
576
+ <div class="s-tag s-tag-blue">Interactive Explorer</div>
577
+ <h2 class="card-title" style="margin-bottom:4px"><i class="fas fa-flask"></i> [DEMO SECTION TITLE]</h2>
578
+ <p class="demo-intro">[Brief intro sentence about what these tabs show — e.g. "Select a scenario to see representative outputs from the live model."]</p>
579
+
580
+ <!-- Tab buttons — update labels and onclick indices to match SCENARIOS array in JS -->
581
+ <div class="scenario-tabs" id="scenTabs">
582
+ <button class="scen-btn active" onclick="selectScen(0,this)">[Tab 1 Label]</button>
583
+ <button class="scen-btn" onclick="selectScen(1,this)">[Tab 2 Label]</button>
584
+ <button class="scen-btn" onclick="selectScen(2,this)">[Tab 3 Label]</button>
585
+ <button class="scen-btn" onclick="selectScen(3,this)">[Tab 4 Label]</button>
586
+ </div>
587
+
588
+ <div id="scenOutput"></div>
589
+ <p class="demo-note">[Disclaimer note, e.g. "Illustrative outputs based on synthetic data. Live app scores in real time."]</p>
590
+ </div>
591
+
592
+
593
+ <!-- ─────────────────────────────────────────
594
+ CARD 5: Charts (tabbed)
595
+ ──────────────────────────────────────────── -->
596
+ <div class="card">
597
+ <div class="s-tag s-tag-blue">Performance Snapshot</div>
598
+ <h2 class="card-title"><i class="fas fa-chart-bar"></i> [CHARTS SECTION TITLE]</h2>
599
+
600
+ <!-- Tab labels — update to match your charts -->
601
+ <div class="chart-tabs">
602
+ <div class="chart-tab active" onclick="switchTab(0,this)">[Chart 1 Tab Label]</div>
603
+ <div class="chart-tab" onclick="switchTab(1,this)">[Chart 2 Tab Label]</div>
604
+ <div class="chart-tab" onclick="switchTab(2,this)">[Chart 3 Tab Label]</div>
605
+ </div>
606
+
607
+ <div class="chart-panel active" id="cp0">
608
+ <div class="chart-wrap"><canvas id="chart0"></canvas></div>
609
+ <p class="chart-caption">[Caption for chart 1 — explain what the data shows and what insight to take away.]</p>
610
+ </div>
611
+ <div class="chart-panel" id="cp1">
612
+ <div class="chart-wrap"><canvas id="chart1"></canvas></div>
613
+ <p class="chart-caption">[Caption for chart 2.]</p>
614
+ </div>
615
+ <div class="chart-panel" id="cp2">
616
+ <div class="chart-wrap"><canvas id="chart2"></canvas></div>
617
+ <p class="chart-caption">[Caption for chart 3.]</p>
618
+ </div>
619
+ </div>
620
+
621
+
622
+ <!-- ─────────────────────────────────────────
623
+ CARD 6: Key Design Decisions / Takeaways
624
+ 3-column grid of highlight boxes
625
+ ──────────────────────────────────────────── -->
626
+ <div class="card">
627
+ <div class="s-tag s-tag-gold">Design Decisions</div>
628
+ <h2 class="card-title"><i class="fas fa-lightbulb"></i> [DECISIONS / HIGHLIGHTS TITLE]</h2>
629
+ <div class="takeaway-grid">
630
+
631
+ <div class="takeaway">
632
+ <div class="tk-icon">[EMOJI]</div>
633
+ <div class="tk-val">[Short headline]</div>
634
+ <div class="tk-label">[Explanation of this design choice — 2 to 3 sentences.]</div>
635
+ </div>
636
+
637
+ <div class="takeaway">
638
+ <div class="tk-icon">[EMOJI]</div>
639
+ <div class="tk-val">[Short headline]</div>
640
+ <div class="tk-label">[Explanation of this design choice — 2 to 3 sentences.]</div>
641
+ </div>
642
+
643
+ <div class="takeaway">
644
+ <div class="tk-icon">[EMOJI]</div>
645
+ <div class="tk-val">[Short headline]</div>
646
+ <div class="tk-label">[Explanation of this design choice — 2 to 3 sentences.]</div>
647
+ </div>
648
+
649
+ </div>
650
+ </div>
651
+
652
+
653
+ </div><!-- /content-col -->
654
+
655
+
656
+ <!-- ═══════════════════════════════════════════
657
+ SIDEBAR
658
+ ════════════════════════════════════════════ -->
659
+ <div class="sidebar">
660
+
661
+ <!-- At-a-Glance summary -->
662
+ <div class="sidebar-card">
663
+ <h3>At a Glance</h3>
664
+ <p class="tldr-text">
665
+ <strong>What it is:</strong> [One sentence summary.]
666
+ <strong>Tech:</strong> [Key tools/frameworks.]
667
+ <strong>Deploy:</strong> [Where it runs.]
668
+ <strong>Scope:</strong> [What domains/features it covers.]
669
+ </p>
670
+ </div>
671
+
672
+ <!-- Live link button — update href -->
673
+ <div class="sidebar-card">
674
+ <h3>Try It Live</h3>
675
+ <a href="https://huggingface.co/spaces/[YOUR-HF-USERNAME]/[SPACE-NAME]" target="_blank" class="hf-btn">
676
+ <i class="fas fa-rocket"></i> Open on HuggingFace Spaces
677
+ </a>
678
+ </div>
679
+
680
+ <!-- Project metadata -->
681
+ <div class="sidebar-card">
682
+ <h3>Project Info</h3>
683
+ <div class="info-row"><span class="info-key">Status</span> <span class="info-val" style="color:var(--accent)">[🔵 Live / 🟡 In Progress]</span></div>
684
+ <div class="info-row"><span class="info-key">Type</span> <span class="info-val">[Academic / Industry / Personal]</span></div>
685
+ <div class="info-row"><span class="info-key">Domain</span> <span class="info-val">[Field / Sector]</span></div>
686
+ <div class="info-row"><span class="info-key">Backend</span> <span class="info-val">[Language · Framework]</span></div>
687
+ <div class="info-row"><span class="info-key">ML Models</span> <span class="info-val">[Model names]</span></div>
688
+ <div class="info-row"><span class="info-key">Visualization</span><span class="info-val">[Library used]</span></div>
689
+ <div class="info-row"><span class="info-key">Records</span> <span class="info-val">[Dataset size]</span></div>
690
+ <div class="info-row"><span class="info-key">Deploy target</span><span class="info-val">[Platform · Container · Port]</span></div>
691
+ <div class="info-row"><span class="info-key">Year</span> <span class="info-val">[YEAR]</span></div>
692
+ </div>
693
+
694
+ <!-- Tech stack pills -->
695
+ <div class="sidebar-card">
696
+ <h3>Tech Stack</h3>
697
+ <div class="tech-pills">
698
+ <!-- Add or remove pills as needed -->
699
+ <span class="tech-pill">[Tech 1]</span>
700
+ <span class="tech-pill">[Tech 2]</span>
701
+ <span class="tech-pill">[Tech 3]</span>
702
+ <span class="tech-pill">[Tech 4]</span>
703
+ <span class="tech-pill">[Tech 5]</span>
704
+ <span class="tech-pill">[Tech 6]</span>
705
+ </div>
706
+ </div>
707
+
708
+ <!-- Module / Feature links — update hrefs and labels -->
709
+ <div class="sidebar-card">
710
+ <h3>[Modules / Features]</h3>
711
+ <div class="sidebar-links">
712
+ <a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 1]</a>
713
+ <a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 2]</a>
714
+ <a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 3]</a>
715
+ <a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 4]</a>
716
+ <a href="#" class="sidebar-link"><i class="fas fa-link"></i> [Feature / Page 5]</a>
717
+ </div>
718
+ </div>
719
+
720
+ <!-- Related links -->
721
+ <div class="sidebar-card">
722
+ <h3>Related Work</h3>
723
+ <div class="sidebar-links">
724
+ <a href="https://github.com/[YOUR-GITHUB-USERNAME]/[REPO-NAME]" target="_blank" class="sidebar-link">
725
+ <i class="fab fa-github"></i> GitHub Repository
726
+ </a>
727
+ <a href="/projects/[OTHER-PROJECT].html" class="sidebar-link"><i class="fas fa-link"></i> [Related Project 1]</a>
728
+ <a href="/projects/[OTHER-PROJECT].html" class="sidebar-link"><i class="fas fa-link"></i> [Related Project 2]</a>
729
+ <a href="/index.html#publications" class="sidebar-link"><i class="fas fa-book"></i> All Publications</a>
730
+ <a href="/projects/index.html" class="sidebar-link"><i class="fas fa-th-large"></i> Back to Projects</a>
731
+ </div>
732
+ </div>
733
+
734
+ </div><!-- /sidebar -->
735
+ </div><!-- /main-layout -->
736
+
737
+
738
+ <!-- ═══════════════════════════════════════════
739
+ PAGE-SPECIFIC JAVASCRIPT
740
+ Edit SCENARIOS and chart data below.
741
+ ════════════════════════════════════════════ -->
742
+ <script>
743
+
744
+ /* ─── THEME HELPERS (don't need to change these) ─── */
745
+ const html = document.documentElement;
746
+ function isDark(){ return html.getAttribute('data-theme') !== 'light'; }
747
+ function gc(){ return isDark() ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.06)'; }
748
+ function tc(){ return isDark() ? '#8892a4' : '#4b5675'; }
749
+ function tt(){
750
+ return {
751
+ backgroundColor: isDark() ? 'rgba(7,13,31,.95)' : 'rgba(255,255,255,.97)',
752
+ titleColor: isDark() ? '#e2e8f0' : '#0f172a',
753
+ bodyColor: isDark() ? '#8892a4' : '#4b5675',
754
+ borderColor: isDark() ? 'rgba(79,142,247,.3)' : 'rgba(37,99,235,.2)',
755
+ borderWidth: 1
756
+ };
757
+ }
758
+
759
+
760
+ /* ─── SCENARIO DEMO DATA ───────────────────────────
761
+ Each object = one tab. Fill in your own values.
762
+ - title: shown above the metric cards
763
+ - metrics: array of 3 cards (label, value, sub-label, color)
764
+ - bar: a single progress bar (label, pct 0–100, color)
765
+ - insight: the text box below the bar
766
+ ─────────────────────────────────────────────────── */
767
+ const SCENARIOS = [
768
+ {
769
+ title: '[EMOJI] [Scenario 1 Title]',
770
+ metrics: [
771
+ { label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
772
+ { label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' },
773
+ { label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#4f8ef7' }
774
+ ],
775
+ bar: { label: '[Bar label]', pct: 50, color: '#ef4444' },
776
+ insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
777
+ },
778
+ {
779
+ title: '[EMOJI] [Scenario 2 Title]',
780
+ metrics: [
781
+ { label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
782
+ { label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' },
783
+ { label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#4f8ef7' }
784
+ ],
785
+ bar: { label: '[Bar label]', pct: 30, color: '#f59e0b' },
786
+ insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
787
+ },
788
+ {
789
+ title: '[EMOJI] [Scenario 3 Title]',
790
+ metrics: [
791
+ { label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#4f8ef7' },
792
+ { label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' },
793
+ { label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#22c55e' }
794
+ ],
795
+ bar: { label: '[Bar label]', pct: 70, color: '#4f8ef7' },
796
+ insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
797
+ },
798
+ {
799
+ title: '[EMOJI] [Scenario 4 Title]',
800
+ metrics: [
801
+ { label: '[Metric Label 1]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
802
+ { label: '[Metric Label 2]', val: '[VALUE]', sub: '[Sub-label]', color: '#ef4444' },
803
+ { label: '[Metric Label 3]', val: '[VALUE]', sub: '[Sub-label]', color: '#f59e0b' }
804
+ ],
805
+ bar: { label: '[Bar label]', pct: 45, color: '#ef4444' },
806
+ insight: '[Explanation of what the numbers mean for this scenario — 2 to 3 sentences.]'
807
+ }
808
+ ];
809
+
810
+ /* ─── SCENARIO RENDERER (no need to change) ─── */
811
+ function renderScen(idx){
812
+ const s = SCENARIOS[idx];
813
+ const metrics = s.metrics.map(m => `
814
+ <div class="res-card">
815
+ <div class="res-label">${m.label}</div>
816
+ <div class="res-val" style="color:${m.color}">${m.val}</div>
817
+ <div class="res-sub">${m.sub}</div>
818
+ </div>`).join('');
819
+ document.getElementById('scenOutput').innerHTML = `
820
+ <div style="font-size:.82rem;font-weight:700;color:var(--text);margin-bottom:12px">${s.title}</div>
821
+ <div class="result-grid">${metrics}</div>
822
+ <div class="risk-bar-wrap">
823
+ <div class="risk-bar-label">
824
+ <span style="color:var(--muted);font-size:.78rem">${s.bar.label}</span>
825
+ <span style="color:${s.bar.color};font-weight:700;font-size:.82rem">${s.bar.pct}%</span>
826
+ </div>
827
+ <div class="risk-bar-track">
828
+ <div class="risk-bar-fill" style="width:${s.bar.pct}%;background:${s.bar.color}"></div>
829
+ </div>
830
+ </div>
831
+ <div style="background:rgba(79,142,247,.06);border:1px solid rgba(79,142,247,.15);border-radius:8px;padding:12px 16px;font-size:.82rem;color:var(--muted);line-height:1.65;margin-top:4px">${s.insight}</div>`;
832
+ }
833
+ function selectScen(idx, btn){
834
+ document.querySelectorAll('.scen-btn').forEach(b => b.classList.remove('active'));
835
+ btn.classList.add('active');
836
+ renderScen(idx);
837
+ }
838
+ renderScen(0);
839
+
840
+
841
+ /* ─── CHART DATA ───────────────────────────────────
842
+ Edit the three buildChart(i) blocks below.
843
+ i=0 → Chart Tab 1, i=1 → Tab 2, i=2 → Tab 3
844
+ Chart.js docs: https://www.chartjs.org/docs/
845
+ ─────────────────────────────────────────────────── */
846
+ const charts = {};
847
+
848
+ function buildChart(i){
849
+ if(charts[i]) charts[i].destroy();
850
+ const ctx = document.getElementById('chart' + i);
851
+ if(!ctx) return;
852
+ const g = gc(), t = tc(), tip = tt();
853
+
854
+ if(i === 0){
855
+ /* ── CHART 1: Replace labels and data ── */
856
+ charts[0] = new Chart(ctx, {
857
+ type: 'bar',
858
+ data: {
859
+ labels: ['[Label A]', '[Label B]', '[Label C]', '[Label D]', '[Label E]'],
860
+ datasets:[{
861
+ label: '[Dataset Name]',
862
+ data: [10, 25, 40, 60, 80], /* ← replace with your values */
863
+ backgroundColor: [
864
+ 'rgba(248,81,73,.8)',
865
+ 'rgba(210,153,34,.75)',
866
+ 'rgba(88,166,255,.7)',
867
+ 'rgba(63,185,80,.7)',
868
+ 'rgba(0,176,255,.75)'
869
+ ],
870
+ borderRadius: 6
871
+ }]
872
+ },
873
+ options:{
874
+ responsive:true, maintainAspectRatio:false,
875
+ plugins:{ legend:{labels:{color:t}}, tooltip:tip },
876
+ scales:{
877
+ x:{ ticks:{color:t}, grid:{color:g} },
878
+ y:{ ticks:{color:t}, grid:{color:g}, title:{display:true, text:'[Y-Axis Label]', color:t, font:{size:11}} }
879
+ }
880
+ }
881
+ });
882
+
883
+ } else if(i === 1){
884
+ /* ── CHART 2: Replace labels and data ── */
885
+ charts[1] = new Chart(ctx, {
886
+ type: 'bar',
887
+ data: {
888
+ labels: ['[Label A]', '[Label B]', '[Label C]', '[Label D]'],
889
+ datasets:[
890
+ {
891
+ label: '[Dataset 1]',
892
+ data: [20, 35, 50, 65], /* ← replace */
893
+ backgroundColor: isDark() ? 'rgba(248,81,73,.75)' : 'rgba(220,38,38,.7)',
894
+ borderRadius: 6
895
+ },
896
+ {
897
+ label: '[Dataset 2]',
898
+ data: [40, 30, 20, 15], /* ← replace */
899
+ backgroundColor: isDark() ? 'rgba(210,153,34,.6)' : 'rgba(217,119,6,.6)',
900
+ borderRadius: 6
901
+ }
902
+ ]
903
+ },
904
+ options:{
905
+ responsive:true, maintainAspectRatio:false,
906
+ plugins:{ legend:{labels:{color:t}}, tooltip:tip },
907
+ scales:{
908
+ x:{ ticks:{color:t}, grid:{color:g} },
909
+ y:{ ticks:{color:t}, grid:{color:g}, title:{display:true, text:'[Y-Axis Label]', color:t, font:{size:11}} }
910
+ }
911
+ }
912
+ });
913
+
914
+ } else if(i === 2){
915
+ /* ── CHART 3: Replace labels and data ── */
916
+ charts[2] = new Chart(ctx, {
917
+ type: 'bar',
918
+ data: {
919
+ labels: ['[Label A]', '[Label B]', '[Label C]'],
920
+ datasets:[
921
+ {
922
+ label: '[Dataset 1]',
923
+ data: [0.8, 1.1, 1.3], /* ← replace */
924
+ backgroundColor: [
925
+ isDark()?'rgba(0,176,255,.7)':'rgba(37,99,235,.65)',
926
+ 'rgba(210,153,34,.7)',
927
+ 'rgba(248,81,73,.7)'
928
+ ],
929
+ borderRadius: 6
930
+ },
931
+ {
932
+ label: '[Dataset 2]',
933
+ data: [1.1, 1.4, 1.6], /* ← replace */
934
+ backgroundColor: ['rgba(136,146,164,.4)','rgba(136,146,164,.4)','rgba(136,146,164,.4)'],
935
+ borderRadius: 6
936
+ }
937
+ ]
938
+ },
939
+ options:{
940
+ responsive:true, maintainAspectRatio:false,
941
+ plugins:{ legend:{labels:{color:t}}, tooltip:tip },
942
+ scales:{
943
+ x:{ ticks:{color:t}, grid:{color:g} },
944
+ y:{ ticks:{color:t}, grid:{color:g}, title:{display:true, text:'[Y-Axis Label]', color:t, font:{size:11}} }
945
+ }
946
+ }
947
+ });
948
+ }
949
+ }
950
+
951
+ function switchTab(i, el){
952
+ document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
953
+ document.querySelectorAll('.chart-panel').forEach(p => p.classList.remove('active'));
954
+ el.classList.add('active');
955
+ document.getElementById('cp' + i).classList.add('active');
956
+ buildChart(i);
957
+ }
958
+
959
+ buildChart(0); /* Build the first chart on page load */
960
+ </script>
961
+
962
+ </body>
963
+ </html>
mlops/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # MLOps core package
mlops/algorithms.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Algorithm registry for AutoMLOps — multiple categories for classification & regression."""
2
+ from sklearn.linear_model import (
3
+ LogisticRegression, RidgeClassifier, SGDClassifier,
4
+ PassiveAggressiveClassifier, LinearRegression, Ridge, Lasso,
5
+ ElasticNet, BayesianRidge, HuberRegressor, SGDRegressor,
6
+ )
7
+ from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
8
+ from sklearn.ensemble import (
9
+ RandomForestClassifier, ExtraTreesClassifier,
10
+ GradientBoostingClassifier, AdaBoostClassifier, BaggingClassifier,
11
+ RandomForestRegressor, ExtraTreesRegressor,
12
+ GradientBoostingRegressor, AdaBoostRegressor, BaggingRegressor,
13
+ )
14
+ from sklearn.svm import SVC, SVR, LinearSVC
15
+ from sklearn.naive_bayes import GaussianNB, BernoulliNB, ComplementNB
16
+ from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
17
+ from sklearn.neural_network import MLPClassifier, MLPRegressor
18
+ from sklearn.discriminant_analysis import (
19
+ LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis,
20
+ )
21
+ from xgboost import XGBClassifier, XGBRegressor
22
+ from lightgbm import LGBMClassifier, LGBMRegressor
23
+
24
+
25
+ # ── Shared verbosity helper ────────────────────────────────────────────────────
26
+ _SILENT = {"verbosity": 0} # XGBoost
27
+ _LGBM_SILENT = {"verbose": -1} # LightGBM
28
+
29
+
30
+ ALGORITHMS = {
31
+ # ══════════════════════════════════════════════════════════════════════
32
+ # CLASSIFICATION
33
+ # ══════════════════════════════════════════════════════════════════════
34
+ "classification": {
35
+
36
+ "Linear Models": {
37
+ "Logistic Regression": {
38
+ "class": LogisticRegression,
39
+ "params": {"max_iter": 1000, "random_state": 42},
40
+ "description": "L2-regularised linear classifier, interpretable baseline.",
41
+ "color": "#3b82f6",
42
+ },
43
+ "Logistic Regression (L1)": {
44
+ "class": LogisticRegression,
45
+ "params": {"penalty": "l1", "solver": "saga", "max_iter": 1000, "random_state": 42},
46
+ "description": "Sparse logistic regression via L1 regularisation.",
47
+ "color": "#60a5fa",
48
+ },
49
+ "Ridge Classifier": {
50
+ "class": RidgeClassifier,
51
+ "params": {"alpha": 1.0},
52
+ "description": "Ridge-regression-based classifier; fast on high-dim data.",
53
+ "color": "#93c5fd",
54
+ },
55
+ "SGD Classifier": {
56
+ "class": SGDClassifier,
57
+ "params": {"max_iter": 1000, "random_state": 42},
58
+ "description": "Stochastic Gradient Descent for large-scale linear classification.",
59
+ "color": "#bfdbfe",
60
+ },
61
+ "Passive Aggressive": {
62
+ "class": PassiveAggressiveClassifier,
63
+ "params": {"max_iter": 1000, "random_state": 42},
64
+ "description": "Online learning algorithm suited to text/streaming data.",
65
+ "color": "#dbeafe",
66
+ },
67
+ "Linear Discriminant Analysis": {
68
+ "class": LinearDiscriminantAnalysis,
69
+ "params": {},
70
+ "description": "Finds linear combinations that maximise class separation.",
71
+ "color": "#eff6ff",
72
+ },
73
+ },
74
+
75
+ "Tree-Based": {
76
+ "Decision Tree": {
77
+ "class": DecisionTreeClassifier,
78
+ "params": {"max_depth": 10, "random_state": 42},
79
+ "description": "Interpretable tree of if-else rules.",
80
+ "color": "#22c55e",
81
+ },
82
+ "Random Forest": {
83
+ "class": RandomForestClassifier,
84
+ "params": {"n_estimators": 100, "random_state": 42},
85
+ "description": "Bagging of decision trees; robust, low variance.",
86
+ "color": "#4ade80",
87
+ },
88
+ "Extra Trees": {
89
+ "class": ExtraTreesClassifier,
90
+ "params": {"n_estimators": 100, "random_state": 42},
91
+ "description": "Extremely randomised trees; faster than Random Forest.",
92
+ "color": "#86efac",
93
+ },
94
+ "Quadratic Discriminant Analysis": {
95
+ "class": QuadraticDiscriminantAnalysis,
96
+ "params": {},
97
+ "description": "Non-linear discriminant analysis with quadratic boundary.",
98
+ "color": "#bbf7d0",
99
+ },
100
+ },
101
+
102
+ "Ensemble / Boosting": {
103
+ "Gradient Boosting": {
104
+ "class": GradientBoostingClassifier,
105
+ "params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42},
106
+ "description": "Sequential boosting of shallow trees; high accuracy.",
107
+ "color": "#f59e0b",
108
+ },
109
+ "AdaBoost": {
110
+ "class": AdaBoostClassifier,
111
+ "params": {"n_estimators": 100, "random_state": 42},
112
+ "description": "Adaptive boosting; up-weights misclassified samples.",
113
+ "color": "#fbbf24",
114
+ },
115
+ "Bagging Classifier": {
116
+ "class": BaggingClassifier,
117
+ "params": {"n_estimators": 50, "random_state": 42},
118
+ "description": "Bootstrap aggregating of any base estimator.",
119
+ "color": "#fcd34d",
120
+ },
121
+ "XGBoost": {
122
+ "class": XGBClassifier,
123
+ "params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_SILENT},
124
+ "description": "Optimised gradient boosting with regularisation; competition favourite.",
125
+ "color": "#d97706",
126
+ },
127
+ "LightGBM": {
128
+ "class": LGBMClassifier,
129
+ "params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_LGBM_SILENT},
130
+ "description": "Leaf-wise boosting; extremely fast on large datasets.",
131
+ "color": "#b45309",
132
+ },
133
+ },
134
+
135
+ "Support Vector Machines": {
136
+ "SVC (RBF Kernel)": {
137
+ "class": SVC,
138
+ "params": {"kernel": "rbf", "probability": True, "random_state": 42},
139
+ "description": "Non-linear SVM with radial basis function kernel.",
140
+ "color": "#a855f7",
141
+ },
142
+ "SVC (Polynomial)": {
143
+ "class": SVC,
144
+ "params": {"kernel": "poly", "degree": 3, "probability": True, "random_state": 42},
145
+ "description": "SVM with polynomial kernel; captures feature interactions.",
146
+ "color": "#c084fc",
147
+ },
148
+ "SVC (Linear)": {
149
+ "class": SVC,
150
+ "params": {"kernel": "linear", "probability": True, "random_state": 42},
151
+ "description": "Linear SVM; interpretable weights, good on text features.",
152
+ "color": "#d8b4fe",
153
+ },
154
+ "LinearSVC": {
155
+ "class": LinearSVC,
156
+ "params": {"max_iter": 2000, "random_state": 42},
157
+ "description": "Faster linear SVM implementation via liblinear.",
158
+ "color": "#ede9fe",
159
+ },
160
+ },
161
+
162
+ "Probabilistic": {
163
+ "Gaussian Naive Bayes": {
164
+ "class": GaussianNB,
165
+ "params": {},
166
+ "description": "Assumes Gaussian feature distribution; very fast baseline.",
167
+ "color": "#ec4899",
168
+ },
169
+ "Bernoulli Naive Bayes": {
170
+ "class": BernoulliNB,
171
+ "params": {},
172
+ "description": "NB for binary/boolean features; popular in text classification.",
173
+ "color": "#f472b6",
174
+ },
175
+ "Complement Naive Bayes": {
176
+ "class": ComplementNB,
177
+ "params": {},
178
+ "description": "Improved NB variant, particularly strong on imbalanced text data.",
179
+ "color": "#fbcfe8",
180
+ },
181
+ },
182
+
183
+ "Instance-Based (KNN)": {
184
+ "KNN (k=3)": {
185
+ "class": KNeighborsClassifier,
186
+ "params": {"n_neighbors": 3},
187
+ "description": "Majority vote from 3 nearest neighbours.",
188
+ "color": "#06b6d4",
189
+ },
190
+ "KNN (k=5)": {
191
+ "class": KNeighborsClassifier,
192
+ "params": {"n_neighbors": 5},
193
+ "description": "Majority vote from 5 nearest neighbours.",
194
+ "color": "#22d3ee",
195
+ },
196
+ "KNN (k=9)": {
197
+ "class": KNeighborsClassifier,
198
+ "params": {"n_neighbors": 9},
199
+ "description": "Majority vote from 9 nearest neighbours; smoother boundary.",
200
+ "color": "#67e8f9",
201
+ },
202
+ },
203
+
204
+ "Neural Networks": {
205
+ "MLP (Small)": {
206
+ "class": MLPClassifier,
207
+ "params": {"hidden_layer_sizes": (64,), "max_iter": 500, "random_state": 42},
208
+ "description": "Single hidden-layer neural network.",
209
+ "color": "#f43f5e",
210
+ },
211
+ "MLP (Medium)": {
212
+ "class": MLPClassifier,
213
+ "params": {"hidden_layer_sizes": (128, 64), "max_iter": 500, "random_state": 42},
214
+ "description": "Two hidden-layer neural network.",
215
+ "color": "#fb7185",
216
+ },
217
+ "MLP (Deep)": {
218
+ "class": MLPClassifier,
219
+ "params": {"hidden_layer_sizes": (256, 128, 64), "max_iter": 500, "random_state": 42},
220
+ "description": "Three hidden-layer neural network with ReLU activations.",
221
+ "color": "#fda4af",
222
+ },
223
+ },
224
+ },
225
+
226
+ # ══════════════════════════════════════════════════════════════════════
227
+ # REGRESSION
228
+ # ══════════════════════════════════════════════════════════════════════
229
+ "regression": {
230
+
231
+ "Linear Models": {
232
+ "Linear Regression": {
233
+ "class": LinearRegression,
234
+ "params": {},
235
+ "description": "Ordinary least-squares; interpretable baseline.",
236
+ "color": "#3b82f6",
237
+ },
238
+ "Ridge Regression": {
239
+ "class": Ridge,
240
+ "params": {"alpha": 1.0},
241
+ "description": "L2-regularised linear regression; handles multicollinearity.",
242
+ "color": "#60a5fa",
243
+ },
244
+ "Lasso": {
245
+ "class": Lasso,
246
+ "params": {"alpha": 0.1, "max_iter": 2000},
247
+ "description": "L1 regularisation produces sparse feature weights.",
248
+ "color": "#93c5fd",
249
+ },
250
+ "ElasticNet": {
251
+ "class": ElasticNet,
252
+ "params": {"alpha": 0.1, "l1_ratio": 0.5, "max_iter": 2000},
253
+ "description": "Combines L1 and L2 regularisation.",
254
+ "color": "#bfdbfe",
255
+ },
256
+ "Bayesian Ridge": {
257
+ "class": BayesianRidge,
258
+ "params": {},
259
+ "description": "Probabilistic Bayesian linear regression with automatic regularisation.",
260
+ "color": "#dbeafe",
261
+ },
262
+ "Huber Regressor": {
263
+ "class": HuberRegressor,
264
+ "params": {"max_iter": 200},
265
+ "description": "Robust to outliers via Huber loss function.",
266
+ "color": "#eff6ff",
267
+ },
268
+ },
269
+
270
+ "Tree-Based": {
271
+ "Decision Tree Regressor": {
272
+ "class": DecisionTreeRegressor,
273
+ "params": {"max_depth": 10, "random_state": 42},
274
+ "description": "Recursive partitioning for regression.",
275
+ "color": "#22c55e",
276
+ },
277
+ "Random Forest Regressor": {
278
+ "class": RandomForestRegressor,
279
+ "params": {"n_estimators": 100, "random_state": 42},
280
+ "description": "Averaged predictions of many trees; low variance.",
281
+ "color": "#4ade80",
282
+ },
283
+ "Extra Trees Regressor": {
284
+ "class": ExtraTreesRegressor,
285
+ "params": {"n_estimators": 100, "random_state": 42},
286
+ "description": "Extremely randomised regression trees; fast.",
287
+ "color": "#86efac",
288
+ },
289
+ },
290
+
291
+ "Ensemble / Boosting": {
292
+ "Gradient Boosting Regressor": {
293
+ "class": GradientBoostingRegressor,
294
+ "params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42},
295
+ "description": "Sequential boosting minimising regression loss.",
296
+ "color": "#f59e0b",
297
+ },
298
+ "AdaBoost Regressor": {
299
+ "class": AdaBoostRegressor,
300
+ "params": {"n_estimators": 100, "random_state": 42},
301
+ "description": "Adaptive boosting for regression.",
302
+ "color": "#fbbf24",
303
+ },
304
+ "Bagging Regressor": {
305
+ "class": BaggingRegressor,
306
+ "params": {"n_estimators": 50, "random_state": 42},
307
+ "description": "Bootstrap aggregating for regression.",
308
+ "color": "#fcd34d",
309
+ },
310
+ "XGBoost Regressor": {
311
+ "class": XGBRegressor,
312
+ "params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_SILENT},
313
+ "description": "Regularised gradient boosting; excellent out-of-the-box performance.",
314
+ "color": "#d97706",
315
+ },
316
+ "LightGBM Regressor": {
317
+ "class": LGBMRegressor,
318
+ "params": {"n_estimators": 100, "learning_rate": 0.1, "random_state": 42, **_LGBM_SILENT},
319
+ "description": "Leaf-wise boosting regressor; fast and memory-efficient.",
320
+ "color": "#b45309",
321
+ },
322
+ },
323
+
324
+ "Support Vector Machines": {
325
+ "SVR (RBF)": {
326
+ "class": SVR,
327
+ "params": {"kernel": "rbf"},
328
+ "description": "Non-linear support vector regression.",
329
+ "color": "#a855f7",
330
+ },
331
+ "SVR (Linear)": {
332
+ "class": SVR,
333
+ "params": {"kernel": "linear"},
334
+ "description": "Linear support vector regression.",
335
+ "color": "#c084fc",
336
+ },
337
+ },
338
+
339
+ "Instance-Based (KNN)": {
340
+ "KNN Regressor (k=3)": {
341
+ "class": KNeighborsRegressor,
342
+ "params": {"n_neighbors": 3},
343
+ "description": "Average of 3 nearest neighbours.",
344
+ "color": "#06b6d4",
345
+ },
346
+ "KNN Regressor (k=5)": {
347
+ "class": KNeighborsRegressor,
348
+ "params": {"n_neighbors": 5},
349
+ "description": "Average of 5 nearest neighbours.",
350
+ "color": "#22d3ee",
351
+ },
352
+ },
353
+
354
+ "Neural Networks": {
355
+ "MLP Regressor (Small)": {
356
+ "class": MLPRegressor,
357
+ "params": {"hidden_layer_sizes": (64,), "max_iter": 500, "random_state": 42},
358
+ "description": "Single hidden-layer neural network for regression.",
359
+ "color": "#f43f5e",
360
+ },
361
+ "MLP Regressor (Medium)": {
362
+ "class": MLPRegressor,
363
+ "params": {"hidden_layer_sizes": (128, 64), "max_iter": 500, "random_state": 42},
364
+ "description": "Two hidden-layer neural network for regression.",
365
+ "color": "#fb7185",
366
+ },
367
+ },
368
+ },
369
+ }
370
+
371
+
372
+ def get_algorithm(task: str, category: str, name: str) -> dict:
373
+ """Retrieve algorithm config by task / category / name."""
374
+ try:
375
+ return ALGORITHMS[task][category][name]
376
+ except KeyError:
377
+ raise ValueError(f"Algorithm not found: task={task}, category={category}, name={name}")
378
+
379
+
380
+ def list_algorithms(task: str) -> dict:
381
+ """Return the algorithm tree for the given task type."""
382
+ if task not in ALGORITHMS:
383
+ raise ValueError(f"Unknown task: {task}")
384
+ return ALGORITHMS[task]
385
+
386
+
387
+ def all_algorithm_names(task: str) -> list[str]:
388
+ """Flat list of all algorithm names for a given task."""
389
+ names = []
390
+ for cat in ALGORITHMS[task].values():
391
+ names.extend(cat.keys())
392
+ return names
mlops/datasets.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset loaders for AutoMLOps demo."""
2
+ import numpy as np
3
+ from sklearn.datasets import (
4
+ load_iris, load_wine, load_breast_cancer, load_digits,
5
+ load_diabetes, fetch_california_housing
6
+ )
7
+ from sklearn.model_selection import train_test_split
8
+ from sklearn.preprocessing import StandardScaler
9
+
10
+
11
+ DATASETS = {
12
+ # ── Classification ────────────────────────────────────────────────────
13
+ "Iris Flowers": {
14
+ "task": "classification",
15
+ "description": "Classic 3-class flower species classification (150 samples, 4 features)",
16
+ "loader": load_iris,
17
+ "icon": "🌸",
18
+ "difficulty": "Easy",
19
+ },
20
+ "Wine Quality": {
21
+ "task": "classification",
22
+ "description": "Wine cultivar identification from chemical analysis (178 samples, 13 features)",
23
+ "loader": load_wine,
24
+ "icon": "🍷",
25
+ "difficulty": "Easy",
26
+ },
27
+ "Breast Cancer": {
28
+ "task": "classification",
29
+ "description": "Tumour malignancy detection (569 samples, 30 features)",
30
+ "loader": load_breast_cancer,
31
+ "icon": "🔬",
32
+ "difficulty": "Medium",
33
+ },
34
+ "Handwritten Digits": {
35
+ "task": "classification",
36
+ "description": "Digit recognition 0-9 from pixel images (1797 samples, 64 features)",
37
+ "loader": load_digits,
38
+ "icon": "✍️",
39
+ "difficulty": "Medium",
40
+ },
41
+ # ── Regression ────────────────────────────────────────────────────────
42
+ "Diabetes Progression": {
43
+ "task": "regression",
44
+ "description": "Disease progression prediction from physiological measurements (442 samples, 10 features)",
45
+ "loader": load_diabetes,
46
+ "icon": "💉",
47
+ "difficulty": "Medium",
48
+ },
49
+ "California Housing": {
50
+ "task": "regression",
51
+ "description": "House price prediction from socio-economic data (20640 samples, 8 features)",
52
+ "loader": fetch_california_housing,
53
+ "icon": "🏠",
54
+ "difficulty": "Hard",
55
+ },
56
+ }
57
+
58
+
59
+ def load_dataset(name: str, test_size: float = 0.2, random_state: int = 42):
60
+ """Load a dataset and return train/test splits with metadata."""
61
+ if name not in DATASETS:
62
+ raise ValueError(f"Unknown dataset: {name}. Available: {list(DATASETS.keys())}")
63
+
64
+ cfg = DATASETS[name]
65
+ data = cfg["loader"]()
66
+
67
+ X, y = data.data, data.target
68
+
69
+ feature_names = (
70
+ list(data.feature_names) if hasattr(data, "feature_names") else
71
+ [f"feature_{i}" for i in range(X.shape[1])]
72
+ )
73
+ target_names = (
74
+ list(data.target_names) if hasattr(data, "target_names") else None
75
+ )
76
+
77
+ X_train, X_test, y_train, y_test = train_test_split(
78
+ X, y, test_size=test_size, random_state=random_state
79
+ )
80
+
81
+ metadata = {
82
+ "name": name,
83
+ "task": cfg["task"],
84
+ "description": cfg["description"],
85
+ "icon": cfg["icon"],
86
+ "difficulty": cfg["difficulty"],
87
+ "n_samples": X.shape[0],
88
+ "n_features": X.shape[1],
89
+ "n_train": X_train.shape[0],
90
+ "n_test": X_test.shape[0],
91
+ "feature_names": feature_names,
92
+ "target_names": target_names,
93
+ "n_classes": len(np.unique(y)) if cfg["task"] == "classification" else None,
94
+ }
95
+
96
+ return X_train, X_test, y_train, y_test, metadata
mlops/trainer.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background model trainer with MLflow tracking."""
2
+ import time
3
+ import uuid
4
+ import threading
5
+ import numpy as np
6
+ from datetime import datetime
7
+
8
+ import mlflow
9
+ import mlflow.sklearn
10
+ from sklearn.preprocessing import StandardScaler, LabelEncoder
11
+ from sklearn.metrics import (
12
+ accuracy_score, f1_score, precision_score, recall_score,
13
+ r2_score, mean_absolute_error, mean_squared_error,
14
+ confusion_matrix, classification_report,
15
+ )
16
+
17
+ from mlops.datasets import load_dataset
18
+ from mlops.algorithms import get_algorithm, ALGORITHMS
19
+
20
+ # ── Shared job state ──────────────────────────────────────────────────────────
21
+ training_jobs: dict = {}
22
+ automl_jobs: dict = {}
23
+ _lock = threading.Lock()
24
+
25
+
26
+ # ── Internal helpers ──────────────────────────────────────────────────────────
27
+
28
+ def _get_or_create_experiment(name: str) -> str:
29
+ mlflow.set_tracking_uri("sqlite:///mlflow.db")
30
+ exp = mlflow.get_experiment_by_name(name)
31
+ if exp is None:
32
+ return mlflow.create_experiment(name)
33
+ return exp.experiment_id
34
+
35
+
36
+ def _update_job(store: dict, job_id: str, **kwargs):
37
+ with _lock:
38
+ store[job_id].update(kwargs)
39
+
40
+
41
+ def _classification_metrics(y_test, y_pred) -> dict:
42
+ return {
43
+ "accuracy": round(float(accuracy_score(y_test, y_pred)), 4),
44
+ "f1_score": round(float(f1_score(y_test, y_pred, average="weighted", zero_division=0)), 4),
45
+ "precision": round(float(precision_score(y_test, y_pred, average="weighted", zero_division=0)), 4),
46
+ "recall": round(float(recall_score(y_test, y_pred, average="weighted", zero_division=0)), 4),
47
+ }
48
+
49
+
50
+ def _regression_metrics(y_test, y_pred) -> dict:
51
+ mse = float(mean_squared_error(y_test, y_pred))
52
+ return {
53
+ "r2_score": round(float(r2_score(y_test, y_pred)), 4),
54
+ "mae": round(float(mean_absolute_error(y_test, y_pred)), 4),
55
+ "mse": round(mse, 4),
56
+ "rmse": round(float(np.sqrt(mse)), 4),
57
+ }
58
+
59
+
60
+ # ── Single training run ───────────────────────────────────────────────────────
61
+
62
+ def _do_train(job_id: str, dataset_name: str, algorithm_name: str,
63
+ algorithm_category: str, task_type: str, custom_params: dict | None):
64
+ """Executed in a daemon thread; updates training_jobs[job_id] in place."""
65
+ start_time = time.time()
66
+ try:
67
+ _update_job(training_jobs, job_id, status="running", progress=5)
68
+ mlflow.set_tracking_uri("sqlite:///mlflow.db")
69
+
70
+ # 1. Load data
71
+ X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
72
+ _update_job(training_jobs, job_id, progress=20, dataset_meta=meta)
73
+
74
+ # 2. Algorithm config
75
+ algo_cfg = get_algorithm(task_type, algorithm_category, algorithm_name)
76
+ params = {**algo_cfg["params"], **(custom_params or {})}
77
+
78
+ # 3. Pre-process
79
+ scaler = StandardScaler()
80
+ X_train_s = scaler.fit_transform(X_train)
81
+ X_test_s = scaler.transform(X_test)
82
+
83
+ # Handle NB algorithms that can't take negative inputs
84
+ if "Naive Bayes" in algorithm_name or "Complement" in algorithm_name:
85
+ from sklearn.preprocessing import MinMaxScaler
86
+ mms = MinMaxScaler()
87
+ X_train_s = mms.fit_transform(X_train)
88
+ X_test_s = mms.transform(X_test)
89
+
90
+ _update_job(training_jobs, job_id, progress=35)
91
+
92
+ # 4. Train inside an MLflow run
93
+ exp_id = _get_or_create_experiment(dataset_name)
94
+ with mlflow.start_run(experiment_id=exp_id,
95
+ run_name=f"{algorithm_name} — {dataset_name}") as run:
96
+ run_id = run.info.run_id
97
+ _update_job(training_jobs, job_id, mlflow_run_id=run_id, progress=40)
98
+
99
+ mlflow.set_tags({
100
+ "algorithm": algorithm_name,
101
+ "category": algorithm_category,
102
+ "dataset": dataset_name,
103
+ "task_type": task_type,
104
+ "job_id": job_id,
105
+ })
106
+ mlflow.log_params({"algorithm": algorithm_name,
107
+ "category": algorithm_category,
108
+ "dataset": dataset_name,
109
+ **{k: str(v) for k, v in params.items()}})
110
+
111
+ _update_job(training_jobs, job_id, progress=50)
112
+
113
+ model = algo_cfg["class"](**params)
114
+ model.fit(X_train_s, y_train)
115
+ _update_job(training_jobs, job_id, progress=75)
116
+
117
+ y_pred = model.predict(X_test_s)
118
+
119
+ if task_type == "classification":
120
+ metrics = _classification_metrics(y_test, y_pred)
121
+ cm = confusion_matrix(y_test, y_pred).tolist()
122
+ extra = {"confusion_matrix": cm,
123
+ "report": classification_report(y_test, y_pred, output_dict=True,
124
+ zero_division=0)}
125
+ else:
126
+ metrics = _regression_metrics(y_test, y_pred)
127
+ extra = {"y_test_sample": y_test[:50].tolist(),
128
+ "y_pred_sample": y_pred[:50].tolist()}
129
+
130
+ mlflow.log_metrics(metrics)
131
+ mlflow.sklearn.log_model(model, "model")
132
+ _update_job(training_jobs, job_id, progress=90)
133
+
134
+ duration = round(time.time() - start_time, 2)
135
+ _update_job(training_jobs, job_id,
136
+ status="completed", progress=100,
137
+ metrics=metrics, extra=extra,
138
+ duration=duration,
139
+ completed_at=datetime.utcnow().isoformat())
140
+
141
+ except Exception as exc:
142
+ _update_job(training_jobs, job_id,
143
+ status="failed", error=str(exc), progress=0)
144
+
145
+
146
+ def start_training(dataset_name: str, algorithm_name: str,
147
+ algorithm_category: str, task_type: str,
148
+ custom_params: dict | None = None) -> str:
149
+ """Kick off a background training job and return its job_id."""
150
+ job_id = str(uuid.uuid4())[:8]
151
+ with _lock:
152
+ training_jobs[job_id] = {
153
+ "job_id": job_id,
154
+ "status": "queued",
155
+ "progress": 0,
156
+ "dataset": dataset_name,
157
+ "algorithm": algorithm_name,
158
+ "category": algorithm_category,
159
+ "task_type": task_type,
160
+ "created_at": datetime.utcnow().isoformat(),
161
+ }
162
+ t = threading.Thread(
163
+ target=_do_train,
164
+ args=(job_id, dataset_name, algorithm_name,
165
+ algorithm_category, task_type, custom_params),
166
+ daemon=True,
167
+ )
168
+ t.start()
169
+ return job_id
170
+
171
+
172
+ # ── AutoML: exhaustive search across all algorithms ───────────────────────────
173
+
174
+ def _do_automl(job_id: str, dataset_name: str, task_type: str,
175
+ optimize_metric: str, max_runs: int):
176
+ """Run every algorithm for the chosen task and log the best."""
177
+ try:
178
+ _update_job(automl_jobs, job_id, status="running", progress=2)
179
+ mlflow.set_tracking_uri("sqlite:///mlflow.db")
180
+
181
+ X_train, X_test, y_train, y_test, meta = load_dataset(dataset_name)
182
+ _update_job(automl_jobs, job_id, dataset_meta=meta, progress=5)
183
+
184
+ scaler = StandardScaler()
185
+ X_train_s = scaler.fit_transform(X_train)
186
+ X_test_s = scaler.transform(X_test)
187
+
188
+ exp_id = _get_or_create_experiment(f"AutoML — {dataset_name}")
189
+
190
+ # Collect all algorithms for this task
191
+ all_algos = []
192
+ for cat_name, cat in ALGORITHMS[task_type].items():
193
+ for alg_name, alg_cfg in cat.items():
194
+ all_algos.append((cat_name, alg_name, alg_cfg))
195
+
196
+ if max_runs < len(all_algos):
197
+ import random
198
+ random.seed(42)
199
+ all_algos = random.sample(all_algos, max_runs)
200
+
201
+ results = []
202
+ total = len(all_algos)
203
+
204
+ for idx, (cat_name, alg_name, alg_cfg) in enumerate(all_algos):
205
+ _update_job(automl_jobs, job_id,
206
+ progress=int(5 + 90 * idx / total),
207
+ current_algo=alg_name)
208
+ try:
209
+ with mlflow.start_run(experiment_id=exp_id,
210
+ run_name=f"AutoML: {alg_name}") as run:
211
+ mlflow.set_tags({"algorithm": alg_name, "category": cat_name,
212
+ "automl_job": job_id, "task_type": task_type})
213
+
214
+ # NB needs non-negative values
215
+ X_tr = X_train_s
216
+ X_te = X_test_s
217
+ if "Naive Bayes" in alg_name or "Complement" in alg_name:
218
+ from sklearn.preprocessing import MinMaxScaler
219
+ mms = MinMaxScaler()
220
+ X_tr = mms.fit_transform(X_train)
221
+ X_te = mms.transform(X_test)
222
+
223
+ model = alg_cfg["class"](**alg_cfg["params"])
224
+ t0 = time.time()
225
+ model.fit(X_tr, y_train)
226
+ dur = round(time.time() - t0, 2)
227
+
228
+ y_pred = model.predict(X_te)
229
+ if task_type == "classification":
230
+ metrics = _classification_metrics(y_test, y_pred)
231
+ else:
232
+ metrics = _regression_metrics(y_test, y_pred)
233
+
234
+ mlflow.log_params({"algorithm": alg_name, "category": cat_name})
235
+ mlflow.log_metrics(metrics)
236
+ mlflow.sklearn.log_model(model, "model")
237
+
238
+ results.append({
239
+ "rank": idx + 1,
240
+ "algorithm": alg_name,
241
+ "category": cat_name,
242
+ "metrics": metrics,
243
+ "duration": dur,
244
+ "run_id": run.info.run_id,
245
+ "color": alg_cfg.get("color", "#8b5cf6"),
246
+ })
247
+ except Exception:
248
+ pass # skip failed algorithms silently
249
+
250
+ # Sort by optimise metric
251
+ higher_is_better = optimize_metric in ("accuracy", "f1_score", "precision",
252
+ "recall", "r2_score")
253
+ results.sort(key=lambda r: r["metrics"].get(optimize_metric, 0),
254
+ reverse=higher_is_better)
255
+ for i, r in enumerate(results):
256
+ r["rank"] = i + 1
257
+
258
+ _update_job(automl_jobs, job_id,
259
+ status="completed", progress=100,
260
+ results=results,
261
+ best=results[0] if results else None,
262
+ completed_at=datetime.utcnow().isoformat())
263
+
264
+ except Exception as exc:
265
+ _update_job(automl_jobs, job_id, status="failed", error=str(exc))
266
+
267
+
268
+ def start_automl(dataset_name: str, task_type: str,
269
+ optimize_metric: str = "accuracy",
270
+ max_runs: int = 20) -> str:
271
+ """Kick off an AutoML sweep and return the job_id."""
272
+ job_id = str(uuid.uuid4())[:8]
273
+ with _lock:
274
+ automl_jobs[job_id] = {
275
+ "job_id": job_id,
276
+ "status": "queued",
277
+ "progress": 0,
278
+ "dataset": dataset_name,
279
+ "task_type": task_type,
280
+ "metric": optimize_metric,
281
+ "results": [],
282
+ "created_at": datetime.utcnow().isoformat(),
283
+ }
284
+ t = threading.Thread(
285
+ target=_do_automl,
286
+ args=(job_id, dataset_name, task_type, optimize_metric, max_runs),
287
+ daemon=True,
288
+ )
289
+ t.start()
290
+ return job_id
pipelines/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Pipelines package
pipelines/dag_engine.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight DAG execution engine — inspired by Apache Airflow concepts."""
2
+ from __future__ import annotations
3
+ import time
4
+ import uuid
5
+ import threading
6
+ from datetime import datetime
7
+ from typing import Callable
8
+
9
+ # ── Shared execution state ─────────────────────────────────────────────────────
10
+ pipeline_executions: dict = {}
11
+ _lock = threading.Lock()
12
+
13
+
14
+ class Task:
15
+ """A single unit of work in a DAG."""
16
+
17
+ def __init__(self, task_id: str, name: str, description: str,
18
+ func: Callable, upstream: list[str] | None = None,
19
+ icon: str = "⚙️", layer: int = 0):
20
+ self.task_id = task_id
21
+ self.name = name
22
+ self.description = description
23
+ self.func = func
24
+ self.upstream = upstream or [] # list of task_ids this depends on
25
+ self.icon = icon
26
+ self.layer = layer # visual column in the DAG
27
+
28
+
29
+ class DAG:
30
+ """A directed acyclic graph of Tasks."""
31
+
32
+ def __init__(self, dag_id: str, name: str, description: str):
33
+ self.dag_id = dag_id
34
+ self.name = name
35
+ self.description = description
36
+ self.tasks: dict[str, Task] = {}
37
+
38
+ def add_task(self, task: Task):
39
+ self.tasks[task.task_id] = task
40
+
41
+ def topological_order(self) -> list[str]:
42
+ """Kahn's algorithm — returns task_ids in execution order."""
43
+ in_degree = {tid: 0 for tid in self.tasks}
44
+ for task in self.tasks.values():
45
+ for up in task.upstream:
46
+ in_degree[task.task_id] += 1
47
+
48
+ queue = [tid for tid, deg in in_degree.items() if deg == 0]
49
+ order = []
50
+
51
+ while queue:
52
+ # Sort for determinism
53
+ queue.sort(key=lambda t: (self.tasks[t].layer, t))
54
+ tid = queue.pop(0)
55
+ order.append(tid)
56
+ for task in self.tasks.values():
57
+ if tid in task.upstream:
58
+ in_degree[task.task_id] -= 1
59
+ if in_degree[task.task_id] == 0:
60
+ queue.append(task.task_id)
61
+
62
+ return order
63
+
64
+ def to_dict(self) -> dict:
65
+ """Serialise DAG structure for the frontend."""
66
+ return {
67
+ "dag_id": self.dag_id,
68
+ "name": self.name,
69
+ "description": self.description,
70
+ "tasks": {
71
+ tid: {
72
+ "task_id": t.task_id,
73
+ "name": t.name,
74
+ "description": t.description,
75
+ "upstream": t.upstream,
76
+ "icon": t.icon,
77
+ "layer": t.layer,
78
+ }
79
+ for tid, t in self.tasks.items()
80
+ },
81
+ }
82
+
83
+
84
+ # ── Execution engine ──────────────────────────────────────────────────────────
85
+
86
+ def _run_dag(exec_id: str, dag: DAG, context: dict):
87
+ """Execute a DAG in a background thread."""
88
+ try:
89
+ order = dag.topological_order()
90
+ total = len(order)
91
+ task_results: dict = {}
92
+
93
+ def _upd(**kw):
94
+ with _lock:
95
+ pipeline_executions[exec_id].update(kw)
96
+
97
+ def _upd_task(tid: str, **kw):
98
+ with _lock:
99
+ pipeline_executions[exec_id]["task_states"][tid].update(kw)
100
+
101
+ _upd(status="running", progress=0)
102
+
103
+ for step_idx, tid in enumerate(order):
104
+ task = dag.tasks[tid]
105
+
106
+ _upd_task(tid, status="running",
107
+ started_at=datetime.utcnow().isoformat())
108
+
109
+ log_line = f"[{datetime.utcnow().strftime('%H:%M:%S')}] ▶ {task.name}"
110
+ with _lock:
111
+ pipeline_executions[exec_id]["logs"].append(log_line)
112
+
113
+ try:
114
+ result = task.func(context, task_results)
115
+ task_results[tid] = result
116
+
117
+ _upd_task(tid, status="success",
118
+ finished_at=datetime.utcnow().isoformat(),
119
+ result=str(result)[:200] if result is not None else "OK")
120
+
121
+ ok_line = f"[{datetime.utcnow().strftime('%H:%M:%S')}] ✔ {task.name} — OK"
122
+ with _lock:
123
+ pipeline_executions[exec_id]["logs"].append(ok_line)
124
+
125
+ except Exception as exc:
126
+ _upd_task(tid, status="failed",
127
+ finished_at=datetime.utcnow().isoformat(),
128
+ error=str(exc))
129
+ err_line = f"[{datetime.utcnow().strftime('%H:%M:%S')}] ✖ {task.name} — {exc}"
130
+ with _lock:
131
+ pipeline_executions[exec_id]["logs"].append(err_line)
132
+ # Continue with remaining tasks (soft failure)
133
+
134
+ progress = int(100 * (step_idx + 1) / total)
135
+ _upd(progress=progress)
136
+
137
+ time.sleep(0.4) # small delay so the UI can animate
138
+
139
+ _upd(status="completed", progress=100,
140
+ completed_at=datetime.utcnow().isoformat())
141
+
142
+ except Exception as exc:
143
+ with _lock:
144
+ pipeline_executions[exec_id]["status"] = "failed"
145
+ pipeline_executions[exec_id]["error"] = str(exc)
146
+
147
+
148
+ def execute_dag(dag: DAG, context: dict | None = None) -> str:
149
+ """Start DAG execution in a background thread; return exec_id."""
150
+ exec_id = str(uuid.uuid4())[:8]
151
+ task_states = {
152
+ tid: {"status": "pending", "started_at": None,
153
+ "finished_at": None, "result": None, "error": None}
154
+ for tid in dag.tasks
155
+ }
156
+ with _lock:
157
+ pipeline_executions[exec_id] = {
158
+ "exec_id": exec_id,
159
+ "dag_id": dag.dag_id,
160
+ "dag_name": dag.name,
161
+ "status": "queued",
162
+ "progress": 0,
163
+ "task_states": task_states,
164
+ "logs": [f"[{datetime.utcnow().strftime('%H:%M:%S')}] DAG '{dag.name}' queued"],
165
+ "created_at": datetime.utcnow().isoformat(),
166
+ }
167
+
168
+ t = threading.Thread(target=_run_dag, args=(exec_id, dag, context or {}), daemon=True)
169
+ t.start()
170
+ return exec_id
pipelines/pipeline_defs.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-built ML pipeline DAG definitions."""
2
+ import time
3
+ import numpy as np
4
+ from pipelines.dag_engine import DAG, Task
5
+
6
+
7
+ # ── Task functions ─────────────────────────────────────────────────────────────
8
+
9
+ def _load_data(ctx, _results):
10
+ from mlops.datasets import load_dataset
11
+ ds = ctx.get("dataset", "Iris Flowers")
12
+ _, _, _, _, meta = load_dataset(ds)
13
+ return f"{meta['n_samples']} samples, {meta['n_features']} features loaded"
14
+
15
+ def _validate_data(ctx, results):
16
+ time.sleep(0.2)
17
+ return "Schema OK · No nulls detected · Feature ranges valid"
18
+
19
+ def _preprocess(ctx, results):
20
+ time.sleep(0.3)
21
+ return "StandardScaler fitted · Train/test split 80/20"
22
+
23
+ def _feature_engineering(ctx, results):
24
+ time.sleep(0.2)
25
+ return "Polynomial features skipped · All features retained"
26
+
27
+ def _train_model(ctx, results):
28
+ from mlops.datasets import load_dataset
29
+ from mlops.algorithms import get_algorithm
30
+ from sklearn.preprocessing import StandardScaler
31
+ import mlflow, mlflow.sklearn
32
+
33
+ ds = ctx.get("dataset", "Iris Flowers")
34
+ cat = ctx.get("category", "Ensemble / Boosting")
35
+ alg = ctx.get("algorithm", "Random Forest")
36
+ task = ctx.get("task_type", "classification")
37
+
38
+ X_train, X_test, y_train, y_test, _ = load_dataset(ds)
39
+ scaler = StandardScaler()
40
+ X_tr = scaler.fit_transform(X_train)
41
+ X_te = scaler.transform(X_test)
42
+
43
+ cfg = get_algorithm(task, cat, alg)
44
+ model = cfg["class"](**cfg["params"])
45
+ model.fit(X_tr, y_train)
46
+ score = model.score(X_te, y_test)
47
+ return f"Model trained · score={score:.4f}"
48
+
49
+ def _evaluate_model(ctx, results):
50
+ time.sleep(0.2)
51
+ return "Accuracy / R² computed · Cross-val 5-fold done"
52
+
53
+ def _generate_report(ctx, results):
54
+ time.sleep(0.15)
55
+ return "HTML report generated · Metrics written to mlflow"
56
+
57
+ def _register_model(ctx, _results):
58
+ time.sleep(0.1)
59
+ return "Model artifact registered in MLflow Model Registry"
60
+
61
+ def _deploy_staging(ctx, _results):
62
+ time.sleep(0.2)
63
+ return "Model transitioned to Staging · REST endpoint ready"
64
+
65
+ # ── Retraining pipeline tasks ──────────────────────────────────────────────────
66
+
67
+ def _check_drift(ctx, _):
68
+ time.sleep(0.2)
69
+ drift = round(np.random.uniform(0.01, 0.08), 4)
70
+ return f"PSI={drift} · {'Drift detected — retraining triggered' if drift > 0.05 else 'No drift · pipeline skipped'}"
71
+
72
+ def _fetch_new_data(ctx, _):
73
+ time.sleep(0.3)
74
+ n = np.random.randint(200, 800)
75
+ return f"{n} new labelled samples fetched from data store"
76
+
77
+ def _merge_datasets(ctx, _):
78
+ time.sleep(0.2)
79
+ return "New data merged with historical · duplicates removed"
80
+
81
+ def _retrain_champion(ctx, _):
82
+ time.sleep(0.4)
83
+ acc = round(np.random.uniform(0.88, 0.97), 4)
84
+ return f"Champion model retrained · new accuracy={acc}"
85
+
86
+ def _ab_test(ctx, _):
87
+ time.sleep(0.2)
88
+ return "A/B test scheduled · 10% traffic split for 24 h"
89
+
90
+ def _promote_production(ctx, _):
91
+ time.sleep(0.15)
92
+ return "Champion model promoted to Production · old version archived"
93
+
94
+ # ── Data pipeline tasks ────────────────────────────────────────────────────────
95
+
96
+ def _ingest_raw(ctx, _):
97
+ time.sleep(0.2)
98
+ return "Raw data ingested from source"
99
+
100
+ def _clean_data(ctx, _):
101
+ time.sleep(0.3)
102
+ removed = np.random.randint(5, 40)
103
+ return f"{removed} anomalous rows removed · missing values imputed"
104
+
105
+ def _encode_features(ctx, _):
106
+ time.sleep(0.2)
107
+ return "Categorical features one-hot encoded · ordinals label-encoded"
108
+
109
+ def _scale_features(ctx, _):
110
+ time.sleep(0.2)
111
+ return "Numeric features scaled with StandardScaler"
112
+
113
+ def _save_processed(ctx, _):
114
+ time.sleep(0.1)
115
+ return "Processed dataset saved to feature store"
116
+
117
+
118
+ # ── DAG builders ──────────────────────────────────────────────────────────────
119
+
120
+ def build_training_pipeline() -> DAG:
121
+ dag = DAG("training_pipeline",
122
+ "Training Pipeline",
123
+ "End-to-end model training: ingest → preprocess → train → evaluate → register")
124
+
125
+ dag.add_task(Task("load_data", "Load Data", "Fetch dataset from registry",
126
+ _load_data, upstream=[], icon="📥", layer=0))
127
+ dag.add_task(Task("validate", "Validate Data", "Schema & quality checks",
128
+ _validate_data, upstream=["load_data"], icon="✅", layer=1))
129
+ dag.add_task(Task("preprocess", "Preprocess", "Scale & split features",
130
+ _preprocess, upstream=["validate"], icon="🔧", layer=2))
131
+ dag.add_task(Task("feat_eng", "Feature Engineering","Derive new features",
132
+ _feature_engineering, upstream=["preprocess"], icon="⚗️", layer=2))
133
+ dag.add_task(Task("train", "Train Model", "Fit model with MLflow tracking",
134
+ _train_model, upstream=["feat_eng"], icon="🧠", layer=3))
135
+ dag.add_task(Task("evaluate", "Evaluate", "Compute metrics on hold-out set",
136
+ _evaluate_model, upstream=["train"], icon="📊", layer=4))
137
+ dag.add_task(Task("report", "Generate Report", "Write evaluation artefacts",
138
+ _generate_report, upstream=["evaluate"], icon="📝", layer=4))
139
+ dag.add_task(Task("register", "Register Model", "Push model to MLflow Registry",
140
+ _register_model, upstream=["evaluate"], icon="📦", layer=5))
141
+ dag.add_task(Task("deploy_staging", "Deploy to Staging", "Transition registered model to Staging",
142
+ _deploy_staging, upstream=["register"], icon="🚀", layer=6))
143
+ return dag
144
+
145
+
146
+ def build_retraining_pipeline() -> DAG:
147
+ dag = DAG("retraining_pipeline",
148
+ "Retraining Pipeline",
149
+ "Automated retraining triggered by data drift detection")
150
+
151
+ dag.add_task(Task("drift_check", "Drift Detection", "PSI & KS tests on incoming data",
152
+ _check_drift, upstream=[], icon="📡", layer=0))
153
+ dag.add_task(Task("fetch_data", "Fetch New Data", "Pull latest labelled samples",
154
+ _fetch_new_data, upstream=["drift_check"], icon="🗄️", layer=1))
155
+ dag.add_task(Task("merge", "Merge Datasets", "Combine new + historical data",
156
+ _merge_datasets, upstream=["fetch_data"], icon="🔗", layer=2))
157
+ dag.add_task(Task("retrain", "Retrain Champion", "Retrain best model on merged data",
158
+ _retrain_champion, upstream=["merge"], icon="🔁", layer=3))
159
+ dag.add_task(Task("ab_test", "A/B Test", "Shadow-deploy challenger",
160
+ _ab_test, upstream=["retrain"], icon="🔀", layer=4))
161
+ dag.add_task(Task("promote", "Promote to Prod", "Archive old, promote new champion",
162
+ _promote_production, upstream=["ab_test"], icon="🏆", layer=5))
163
+ return dag
164
+
165
+
166
+ def build_data_pipeline() -> DAG:
167
+ dag = DAG("data_pipeline",
168
+ "Data Processing Pipeline",
169
+ "Automated feature engineering and data preparation pipeline")
170
+
171
+ dag.add_task(Task("ingest", "Ingest Raw Data", "Pull raw data from sources",
172
+ _ingest_raw, upstream=[], icon="📥", layer=0))
173
+ dag.add_task(Task("clean", "Clean Data", "Remove outliers & impute missing",
174
+ _clean_data, upstream=["ingest"], icon="🧹", layer=1))
175
+ dag.add_task(Task("encode", "Encode Features", "Categorical encoding",
176
+ _encode_features, upstream=["clean"], icon="🔢", layer=2))
177
+ dag.add_task(Task("scale", "Scale Features", "Normalise numeric columns",
178
+ _scale_features, upstream=["encode"], icon="⚖️", layer=3))
179
+ dag.add_task(Task("save", "Save to Feature Store","Persist processed dataset",
180
+ _save_processed, upstream=["scale"], icon="💾", layer=4))
181
+ return dag
182
+
183
+
184
+ # Singleton instances (rebuilt on each call so context can vary)
185
+ PIPELINE_BUILDERS = {
186
+ "training_pipeline": build_training_pipeline,
187
+ "retraining_pipeline": build_retraining_pipeline,
188
+ "data_pipeline": build_data_pipeline,
189
+ }
190
+
191
+
192
+ def get_pipeline(pipeline_id: str) -> DAG:
193
+ if pipeline_id not in PIPELINE_BUILDERS:
194
+ raise ValueError(f"Unknown pipeline: {pipeline_id}")
195
+ return PIPELINE_BUILDERS[pipeline_id]()
readme-template.md ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PROJECT_NAME
3
+ colorFrom: COLOR_FROM
4
+ colorTo: COLOR_TO
5
+ sdk: docker
6
+ ---
7
+
8
+ <div align="center">
9
+
10
+ <h1>PROJECT_EMOJI PROJECT_NAME</h1>
11
+ <img src="https://readme-typing-svg.demolab.com?font=Fira+Code&size=22&duration=3000&pause=1000&color=COLOR_HEX&center=true&vCenter=true&width=700&lines=TYPING_LINE_1;TYPING_LINE_2;TYPING_LINE_3" alt="Typing SVG"/>
12
+
13
+ <br/>
14
+
15
+ [![Python](https://img.shields.io/badge/Python-3.10+-3b82f6?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/)
16
+ [![Flask](https://img.shields.io/badge/Flask-2.x-4f46e5?style=for-the-badge&logo=flask&logoColor=white)](https://flask.palletsprojects.com/)
17
+ [![Docker](https://img.shields.io/badge/Docker-Ready-3b82f6?style=for-the-badge&logo=docker&logoColor=white)](https://www.docker.com/)
18
+ [![HuggingFace](https://img.shields.io/badge/HuggingFace-Spaces-ffcc00?style=for-the-badge&logo=huggingface&logoColor=black)](https://huggingface.co/mnoorchenar/spaces)
19
+ [![Status](https://img.shields.io/badge/Status-Active-22c55e?style=for-the-badge)](#)
20
+
21
+ <br/>
22
+
23
+ **PROJECT_EMOJI PROJECT_NAME** — PROJECT_DESCRIPTION
24
+
25
+ <br/>
26
+
27
+ ---
28
+
29
+ </div>
30
+
31
+ ## Table of Contents
32
+
33
+ - [Features](#-features)
34
+ - [Architecture](#️-architecture)
35
+ - [Getting Started](#-getting-started)
36
+ - [Docker Deployment](#-docker-deployment)
37
+ - [Dashboard Modules](#-dashboard-modules)
38
+ - [ML Models](#-ml-models)
39
+ - [Project Structure](#-project-structure)
40
+ - [Author](#-author)
41
+ - [Contributing](#-contributing)
42
+ - [Disclaimer](#disclaimer)
43
+ - [License](#-license)
44
+
45
+ ---
46
+
47
+ ## ✨ Features
48
+
49
+ <table>
50
+ <tr>
51
+ <td>FEATURE_EMOJI_1 <b>FEATURE_TITLE_1</b></td>
52
+ <td>FEATURE_DESCRIPTION_1</td>
53
+ </tr>
54
+ <tr>
55
+ <td>FEATURE_EMOJI_2 <b>FEATURE_TITLE_2</b></td>
56
+ <td>FEATURE_DESCRIPTION_2</td>
57
+ </tr>
58
+ <tr>
59
+ <td>FEATURE_EMOJI_3 <b>FEATURE_TITLE_3</b></td>
60
+ <td>FEATURE_DESCRIPTION_3</td>
61
+ </tr>
62
+ <tr>
63
+ <td>FEATURE_EMOJI_4 <b>FEATURE_TITLE_4</b></td>
64
+ <td>FEATURE_DESCRIPTION_4</td>
65
+ </tr>
66
+ <tr>
67
+ <td>🔒 <b>Secure by Design</b></td>
68
+ <td>Role-based access, audit logs, encrypted data pipelines</td>
69
+ </tr>
70
+ <tr>
71
+ <td>🐳 <b>Containerized Deployment</b></td>
72
+ <td>Docker-first architecture, cloud-ready and scalable</td>
73
+ </tr>
74
+ </table>
75
+
76
+ ---
77
+
78
+ ## 🏗️ Architecture
79
+
80
+ ```
81
+ ┌─────────────────────────────────────────────────────────┐
82
+ │ PROJECT_NAME │
83
+ │ │
84
+ │ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
85
+ │ │ Data │───▶│ ML │───▶│ Flask API │ │
86
+ │ │ Sources │ │ Engine │ │ Backend │ │
87
+ │ └───────────┘ └───────────┘ └───────┬───────┘ │
88
+ │ │ │
89
+ │ ┌────────▼────────┐ │
90
+ │ │ Plotly Dash │ │
91
+ │ │ Dashboard │ │
92
+ │ └─────────────────┘ │
93
+ └─────────────────────────────────────────────────────────┘
94
+ ```
95
+
96
+ ---
97
+
98
+ ## 🚀 Getting Started
99
+
100
+ ### Prerequisites
101
+
102
+ - Python 3.10+
103
+ - Docker & Docker Compose
104
+ - Git
105
+
106
+ ### Local Installation
107
+
108
+ ```bash
109
+ # 1. Clone the repository
110
+ git clone https://github.com/mnoorchenar/PROJECT_NAME.git
111
+ cd PROJECT_NAME
112
+
113
+ # 2. Create a virtual environment
114
+ python -m venv venv
115
+ source venv/bin/activate # Windows: venv\Scripts\activate
116
+
117
+ # 3. Install dependencies
118
+ pip install -r requirements.txt
119
+
120
+ # 4. Configure environment variables
121
+ cp .env.example .env
122
+ # Edit .env with your settings
123
+
124
+ # 5. Run the application
125
+ python app.py
126
+ ```
127
+
128
+ Open your browser at `http://localhost:7860` 🎉
129
+
130
+ ---
131
+
132
+ ## 🐳 Docker Deployment
133
+
134
+ ```bash
135
+ # Build and run with Docker Compose
136
+ docker compose up --build
137
+
138
+ # Or pull and run the pre-built image
139
+ docker pull mnoorchenar/PROJECT_NAME
140
+ docker run -p 7860:7860 mnoorchenar/PROJECT_NAME
141
+ ```
142
+
143
+ ---
144
+
145
+ ## 📊 Dashboard Modules
146
+
147
+ | Module | Description | Status |
148
+ |--------|-------------|--------|
149
+ | MODULE_EMOJI_1 MODULE_NAME_1 | MODULE_DESC_1 | ✅ Live |
150
+ | MODULE_EMOJI_2 MODULE_NAME_2 | MODULE_DESC_2 | ✅ Live |
151
+ | MODULE_EMOJI_3 MODULE_NAME_3 | MODULE_DESC_3 | ✅ Live |
152
+ | MODULE_EMOJI_4 MODULE_NAME_4 | MODULE_DESC_4 | 🔄 Beta |
153
+ | MODULE_EMOJI_5 MODULE_NAME_5 | MODULE_DESC_5 | ✅ Live |
154
+ | MODULE_EMOJI_6 MODULE_NAME_6 | MODULE_DESC_6 | 🗓️ Planned |
155
+
156
+ ---
157
+
158
+ ## 🧠 ML Models
159
+
160
+ ```python
161
+ # Core Models Used in PROJECT_NAME
162
+ models = {
163
+ "MODEL_KEY_1": "MODEL_VALUE_1",
164
+ "MODEL_KEY_2": "MODEL_VALUE_2",
165
+ "MODEL_KEY_3": "MODEL_VALUE_3",
166
+ "MODEL_KEY_4": "MODEL_VALUE_4",
167
+ "MODEL_KEY_5": "MODEL_VALUE_5"
168
+ }
169
+ ```
170
+
171
+ ---
172
+
173
+ ## 📁 Project Structure
174
+
175
+ ```
176
+ PROJECT_NAME/
177
+
178
+ ├── 📂 app/
179
+ │ ├── 📂 models/ # ML model definitions & loaders
180
+ │ ├── 📂 routes/ # Flask API endpoints
181
+ │ ├── 📂 dashboards/ # Plotly Dash layouts
182
+ │ └── 📂 utils/ # Helpers, preprocessing, logging
183
+
184
+ ├── 📂 data/
185
+ │ ├── 📂 raw/ # Raw data sources
186
+ │ └── 📂 processed/ # Feature-engineered datasets
187
+
188
+ ├── 📂 notebooks/ # Exploratory analysis & model training
189
+ ├── 📂 tests/ # Unit and integration tests
190
+ ├── 📄 app.py # Application entry point
191
+ ├── 📄 Dockerfile # Container definition
192
+ ├── 📄 docker-compose.yml # Multi-service orchestration
193
+ ├── 📄 requirements.txt # Python dependencies
194
+ └── 📄 .env.example # Environment variable template
195
+ ```
196
+
197
+ ---
198
+
199
+ ## 👨‍💻 Author
200
+
201
+ <div align="center">
202
+
203
+ <table>
204
+ <tr>
205
+ <td align="center" width="100%">
206
+
207
+ <img src="https://avatars.githubusercontent.com/mnoorchenar" width="120" style="border-radius:50%; border: 3px solid #4f46e5;" alt="Mohammad Noorchenarboo"/>
208
+
209
+ <h3>Mohammad Noorchenarboo</h3>
210
+
211
+ <code>Data Scientist</code> &nbsp;|&nbsp; <code>AI Researcher</code> &nbsp;|&nbsp; <code>Biostatistician</code>
212
+
213
+ 📍 &nbsp;Ontario, Canada &nbsp;&nbsp; 📧 &nbsp;[mohammadnoorchenarboo@gmail.com](mailto:mohammadnoorchenarboo@gmail.com)
214
+
215
+ ──────────────────────────────────────
216
+
217
+ [![LinkedIn](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/mnoorchenar)&nbsp;
218
+ [![Personal Site](https://img.shields.io/badge/Website-mnoorchenar.github.io-4f46e5?style=for-the-badge&logo=githubpages&logoColor=white)](https://mnoorchenar.github.io/)&nbsp;
219
+ [![HuggingFace](https://img.shields.io/badge/HuggingFace-ffcc00?style=for-the-badge&logo=huggingface&logoColor=black)](https://huggingface.co/mnoorchenar/spaces)&nbsp;
220
+ [![Google Scholar](https://img.shields.io/badge/Scholar-4285F4?style=for-the-badge&logo=googlescholar&logoColor=white)](https://scholar.google.ca/citations?user=nn_Toq0AAAAJ&hl=en)&nbsp;
221
+ [![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/mnoorchenar)
222
+
223
+ </td>
224
+ </tr>
225
+ </table>
226
+
227
+ </div>
228
+
229
+ ---
230
+
231
+ ## 🤝 Contributing
232
+
233
+ Contributions are welcome! Please follow these steps:
234
+
235
+ 1. **Fork** the repository
236
+ 2. **Create** a feature branch: `git checkout -b feature/amazing-feature`
237
+ 3. **Commit** your changes: `git commit -m 'Add amazing feature'`
238
+ 4. **Push** to the branch: `git push origin feature/amazing-feature`
239
+ 5. **Open** a Pull Request
240
+
241
+ ---
242
+
243
+ ## Disclaimer
244
+
245
+ <span style="color:red">This project is developed strictly for educational and research purposes and does not constitute professional advice of any kind. All datasets used are either synthetically generated or publicly available — no real user data is stored. This software is provided "as is" without warranty of any kind; use at your own risk.</span>
246
+
247
+ ---
248
+
249
+ ## 📜 License
250
+
251
+ Distributed under the **MIT License**. See [`LICENSE`](LICENSE) for more information.
252
+
253
+ ---
254
+
255
+ <div align="center">
256
+
257
+ <img src="https://capsule-render.vercel.app/api?type=waving&color=0:3b82f6,100:4f46e5&height=120&section=footer&text=Made%20with%20%E2%9D%A4%EF%B8%8F%20by%20Mohammad%20Noorchenarboo&fontColor=ffffff&fontSize=18&fontAlignY=80" width="100%"/>
258
+
259
+ [![GitHub Stars](https://img.shields.io/github/stars/mnoorchenar/PROJECT_NAME?style=social)](https://github.com/mnoorchenar/PROJECT_NAME)
260
+ [![GitHub Forks](https://img.shields.io/github/forks/mnoorchenar/PROJECT_NAME?style=social)](https://github.com/mnoorchenar/PROJECT_NAME/fork)
261
+
262
+ <sub>The name "PROJECT_NAME" is used purely for academic and research purposes. Any similarity to existing company names, products, or trademarks is entirely coincidental and unintentional. This project has no affiliation with any commercial entity.</sub>
263
+
264
+ </div>
requirements.txt CHANGED
@@ -1 +1,10 @@
1
- flask==3.0.0
 
 
 
 
 
 
 
 
 
 
1
+ flask==3.0.0
2
+ mlflow==2.14.1
3
+ scikit-learn==1.4.2
4
+ pandas==2.2.2
5
+ numpy==1.26.4
6
+ plotly==5.22.0
7
+ xgboost==2.0.3
8
+ lightgbm==4.3.0
9
+ gunicorn==22.0.0
10
+ scipy==1.13.0
static/css/style.css ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── AutoMLOps — Dark Theme ─────────────────────────────────────────────── */
2
+ :root {
3
+ --bg-primary: #0d1117;
4
+ --bg-secondary: #161b22;
5
+ --bg-tertiary: #21262d;
6
+ --bg-hover: #30363d44;
7
+ --border-color: #30363d;
8
+ --text-primary: #e6edf3;
9
+ --text-secondary:#8b949e;
10
+ --text-muted: #656d76;
11
+ --accent: #8b5cf6;
12
+ --accent-light: #a78bfa;
13
+ --accent-blue: #3b82f6;
14
+ --success: #22c55e;
15
+ --warning: #f59e0b;
16
+ --danger: #ef4444;
17
+ --cyan: #06b6d4;
18
+ --sidebar-w: 240px;
19
+ --navbar-h: 56px;
20
+ --radius: 10px;
21
+ --radius-sm: 6px;
22
+ --shadow: 0 4px 24px rgba(0,0,0,.45);
23
+ }
24
+
25
+ /* ── Reset / base ─────────────────────────────────────────────────────────── */
26
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
27
+ html { font-size: 15px; }
28
+ body {
29
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
30
+ background: var(--bg-primary);
31
+ color: var(--text-primary);
32
+ min-height: 100vh;
33
+ overflow-x: hidden;
34
+ }
35
+ a { color: var(--accent-light); text-decoration: none; }
36
+ a:hover { color: var(--accent); }
37
+
38
+ /* ── Scrollbar ────────────────────────────────────────────────────────────── */
39
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
40
+ ::-webkit-scrollbar-track { background: var(--bg-secondary); }
41
+ ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
42
+
43
+ /* ── Sidebar ──────────────────────────────────────────────────────────────── */
44
+ .sidebar {
45
+ position: fixed;
46
+ top: 0; left: 0;
47
+ width: var(--sidebar-w);
48
+ height: 100vh;
49
+ background: var(--bg-secondary);
50
+ border-right: 1px solid var(--border-color);
51
+ display: flex;
52
+ flex-direction: column;
53
+ z-index: 100;
54
+ transition: transform .25s ease;
55
+ }
56
+ .sidebar-logo {
57
+ display: flex; align-items: center; gap: 10px;
58
+ padding: 18px 20px 16px;
59
+ border-bottom: 1px solid var(--border-color);
60
+ }
61
+ .sidebar-logo-icon {
62
+ width: 34px; height: 34px; border-radius: 8px;
63
+ background: linear-gradient(135deg, var(--accent), var(--accent-blue));
64
+ display: flex; align-items: center; justify-content: center;
65
+ font-size: 17px;
66
+ }
67
+ .sidebar-logo-text { font-weight: 700; font-size: 1rem; letter-spacing: -.3px; }
68
+ .sidebar-logo-sub { font-size: .68rem; color: var(--text-muted); }
69
+
70
+ .sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 0; }
71
+ .nav-section-label {
72
+ padding: 14px 20px 4px;
73
+ font-size: .68rem;
74
+ font-weight: 600;
75
+ letter-spacing: .08em;
76
+ text-transform: uppercase;
77
+ color: var(--text-muted);
78
+ }
79
+ .nav-item {
80
+ display: flex; align-items: center; gap: 10px;
81
+ padding: 8px 20px;
82
+ color: var(--text-secondary);
83
+ cursor: pointer;
84
+ border-radius: 0;
85
+ transition: background .15s, color .15s;
86
+ font-size: .9rem;
87
+ }
88
+ .nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
89
+ .nav-item.active { color: var(--accent-light); background: #8b5cf618; }
90
+ .nav-item .nav-icon { width: 18px; text-align: center; font-size: .95rem; }
91
+ .nav-badge {
92
+ margin-left: auto;
93
+ background: var(--accent);
94
+ color: #fff;
95
+ font-size: .62rem;
96
+ font-weight: 700;
97
+ padding: 1px 6px;
98
+ border-radius: 10px;
99
+ }
100
+
101
+ .sidebar-footer {
102
+ padding: 14px 20px;
103
+ border-top: 1px solid var(--border-color);
104
+ font-size: .78rem;
105
+ color: var(--text-muted);
106
+ }
107
+ .sidebar-footer a { color: var(--text-muted); }
108
+ .sidebar-footer a:hover { color: var(--accent-light); }
109
+
110
+ /* ── Top navbar ───────────────────────────────────────────────────────────── */
111
+ .topnav {
112
+ position: fixed;
113
+ top: 0; left: var(--sidebar-w); right: 0;
114
+ height: var(--navbar-h);
115
+ background: var(--bg-secondary);
116
+ border-bottom: 1px solid var(--border-color);
117
+ display: flex; align-items: center;
118
+ padding: 0 24px;
119
+ z-index: 90;
120
+ gap: 12px;
121
+ }
122
+ .topnav-title { font-weight: 600; font-size: 1rem; flex: 1; }
123
+ .topnav-badge {
124
+ background: linear-gradient(135deg, var(--accent), var(--accent-blue));
125
+ color: #fff;
126
+ font-size: .7rem;
127
+ font-weight: 700;
128
+ padding: 3px 10px;
129
+ border-radius: 20px;
130
+ }
131
+
132
+ /* ── Main content ─────────────────────────────────────────────────────────── */
133
+ .main {
134
+ margin-left: var(--sidebar-w);
135
+ padding-top: var(--navbar-h);
136
+ min-height: 100vh;
137
+ }
138
+ .page-content { padding: 28px 32px; }
139
+
140
+ /* ── Cards ────────────────���───────────────────────────────────────────────── */
141
+ .card {
142
+ background: var(--bg-secondary);
143
+ border: 1px solid var(--border-color);
144
+ border-radius: var(--radius);
145
+ padding: 20px;
146
+ }
147
+ .card-header {
148
+ display: flex; align-items: center; justify-content: space-between;
149
+ margin-bottom: 16px;
150
+ }
151
+ .card-title { font-weight: 600; font-size: .95rem; display: flex; align-items: center; gap: 8px; }
152
+ .card-sm { padding: 14px 18px; }
153
+
154
+ /* ── Stat cards ───────────────────────────────────────────────────────────── */
155
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
156
+ .stat-card {
157
+ background: var(--bg-secondary);
158
+ border: 1px solid var(--border-color);
159
+ border-radius: var(--radius);
160
+ padding: 20px 22px;
161
+ position: relative;
162
+ overflow: hidden;
163
+ }
164
+ .stat-card::before {
165
+ content: '';
166
+ position: absolute;
167
+ top: 0; left: 0; right: 0;
168
+ height: 3px;
169
+ }
170
+ .stat-card.purple::before { background: linear-gradient(90deg, var(--accent), var(--accent-light)); }
171
+ .stat-card.blue::before { background: linear-gradient(90deg, var(--accent-blue), var(--cyan)); }
172
+ .stat-card.green::before { background: linear-gradient(90deg, var(--success), #4ade80); }
173
+ .stat-card.yellow::before { background: linear-gradient(90deg, var(--warning), #fcd34d); }
174
+ .stat-label { font-size: .78rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 6px; }
175
+ .stat-value { font-size: 2rem; font-weight: 700; letter-spacing: -.5px; }
176
+ .stat-sub { font-size: .78rem; color: var(--text-secondary); margin-top: 4px; }
177
+
178
+ /* ── Badges / pills ───────────────────────────────────────────────────────── */
179
+ .badge {
180
+ display: inline-block;
181
+ padding: 2px 9px;
182
+ border-radius: 12px;
183
+ font-size: .72rem;
184
+ font-weight: 600;
185
+ }
186
+ .badge-success { background: #22c55e22; color: var(--success); }
187
+ .badge-warning { background: #f59e0b22; color: var(--warning); }
188
+ .badge-danger { background: #ef444422; color: var(--danger); }
189
+ .badge-info { background: #3b82f622; color: var(--accent-blue); }
190
+ .badge-purple { background: #8b5cf622; color: var(--accent-light); }
191
+ .badge-muted { background: var(--bg-tertiary); color: var(--text-secondary); }
192
+
193
+ .stage-production { background: #22c55e22; color: var(--success); }
194
+ .stage-staging { background: #f59e0b22; color: var(--warning); }
195
+ .stage-archived { background: #ef444422; color: var(--danger); }
196
+ .stage-none { background: var(--bg-tertiary); color: var(--text-muted); }
197
+
198
+ /* ── Buttons ──────────────────────────────────────────────────────────────── */
199
+ .btn {
200
+ display: inline-flex; align-items: center; gap: 7px;
201
+ padding: 8px 18px;
202
+ border-radius: var(--radius-sm);
203
+ font-size: .85rem;
204
+ font-weight: 500;
205
+ cursor: pointer;
206
+ border: none;
207
+ transition: opacity .15s, transform .1s;
208
+ white-space: nowrap;
209
+ }
210
+ .btn:hover { opacity: .88; }
211
+ .btn:active { transform: scale(.97); }
212
+ .btn-primary { background: var(--accent); color: #fff; }
213
+ .btn-blue { background: var(--accent-blue); color: #fff; }
214
+ .btn-success { background: var(--success); color: #fff; }
215
+ .btn-warning { background: var(--warning); color: #000; }
216
+ .btn-danger { background: var(--danger); color: #fff; }
217
+ .btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
218
+ .btn-ghost:hover { color: var(--text-primary); border-color: var(--text-secondary); background: var(--bg-hover); }
219
+ .btn-sm { padding: 5px 12px; font-size: .8rem; }
220
+ .btn-lg { padding: 11px 24px; font-size: .95rem; }
221
+ .btn:disabled { opacity: .45; cursor: not-allowed; }
222
+
223
+ /* ── Tables ───────────────────────────────────────────────────────────────── */
224
+ .table-wrap { overflow-x: auto; }
225
+ table { width: 100%; border-collapse: collapse; }
226
+ thead tr { border-bottom: 1px solid var(--border-color); }
227
+ th {
228
+ text-align: left;
229
+ padding: 10px 14px;
230
+ font-size: .78rem;
231
+ font-weight: 600;
232
+ color: var(--text-muted);
233
+ text-transform: uppercase;
234
+ letter-spacing: .05em;
235
+ white-space: nowrap;
236
+ }
237
+ td { padding: 11px 14px; font-size: .88rem; border-bottom: 1px solid #30363d55; }
238
+ tr:last-child td { border-bottom: none; }
239
+ tr:hover td { background: var(--bg-hover); }
240
+
241
+ /* ── Forms ─────────────────────────────────────────────────────────────��──── */
242
+ .form-group { margin-bottom: 16px; }
243
+ .form-label { display: block; font-size: .82rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
244
+ .form-control, .form-select {
245
+ width: 100%;
246
+ background: var(--bg-tertiary);
247
+ border: 1px solid var(--border-color);
248
+ border-radius: var(--radius-sm);
249
+ color: var(--text-primary);
250
+ padding: 9px 12px;
251
+ font-size: .88rem;
252
+ outline: none;
253
+ transition: border-color .15s;
254
+ appearance: none;
255
+ }
256
+ .form-control:focus, .form-select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #8b5cf620; }
257
+ .form-select {
258
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
259
+ background-repeat: no-repeat;
260
+ background-position: right 10px center;
261
+ padding-right: 32px;
262
+ }
263
+
264
+ /* ── Progress bars ────────────────────────────────────────────────────────── */
265
+ .progress-bar-wrap {
266
+ background: var(--bg-tertiary);
267
+ border-radius: 99px;
268
+ height: 8px;
269
+ overflow: hidden;
270
+ }
271
+ .progress-bar {
272
+ height: 100%;
273
+ border-radius: 99px;
274
+ background: linear-gradient(90deg, var(--accent), var(--accent-blue));
275
+ transition: width .4s ease;
276
+ }
277
+
278
+ /* ── Tabs ─────────────────────────────────────────────────────────────────── */
279
+ .tab-bar {
280
+ display: flex;
281
+ gap: 2px;
282
+ border-bottom: 1px solid var(--border-color);
283
+ margin-bottom: 20px;
284
+ }
285
+ .tab-btn {
286
+ padding: 9px 18px;
287
+ font-size: .87rem;
288
+ font-weight: 500;
289
+ color: var(--text-secondary);
290
+ background: transparent;
291
+ border: none;
292
+ border-bottom: 2px solid transparent;
293
+ cursor: pointer;
294
+ transition: color .15s, border-color .15s;
295
+ margin-bottom: -1px;
296
+ }
297
+ .tab-btn:hover { color: var(--text-primary); }
298
+ .tab-btn.active { color: var(--accent-light); border-bottom-color: var(--accent); }
299
+ .tab-panel { display: none; }
300
+ .tab-panel.active { display: block; }
301
+
302
+ /* ── Modals ───────────────────────────────────────────────────────────────── */
303
+ .modal-overlay {
304
+ position: fixed; inset: 0;
305
+ background: rgba(0,0,0,.65);
306
+ display: flex; align-items: center; justify-content: center;
307
+ z-index: 200;
308
+ opacity: 0; pointer-events: none;
309
+ transition: opacity .2s;
310
+ }
311
+ .modal-overlay.open { opacity: 1; pointer-events: all; }
312
+ .modal {
313
+ background: var(--bg-secondary);
314
+ border: 1px solid var(--border-color);
315
+ border-radius: var(--radius);
316
+ padding: 24px;
317
+ width: min(540px, 95vw);
318
+ max-height: 90vh;
319
+ overflow-y: auto;
320
+ transform: translateY(12px);
321
+ transition: transform .2s;
322
+ }
323
+ .modal-overlay.open .modal { transform: translateY(0); }
324
+ .modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
325
+ .modal-title { font-size: 1.05rem; font-weight: 600; }
326
+ .modal-close { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 1.1rem; }
327
+ .modal-close:hover { color: var(--danger); }
328
+ .modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
329
+
330
+ /* ── Toast notifications ──────────────────────────────────────────────────── */
331
+ #toast-area {
332
+ position: fixed; bottom: 24px; right: 24px;
333
+ z-index: 300; display: flex; flex-direction: column; gap: 10px;
334
+ }
335
+ .toast {
336
+ background: var(--bg-tertiary);
337
+ border: 1px solid var(--border-color);
338
+ border-radius: var(--radius-sm);
339
+ padding: 12px 18px;
340
+ font-size: .86rem;
341
+ box-shadow: var(--shadow);
342
+ display: flex; align-items: center; gap: 10px;
343
+ animation: slideIn .25s ease;
344
+ min-width: 240px;
345
+ }
346
+ .toast.success { border-left: 3px solid var(--success); }
347
+ .toast.error { border-left: 3px solid var(--danger); }
348
+ .toast.info { border-left: 3px solid var(--accent); }
349
+ @keyframes slideIn { from { transform: translateX(40px); opacity: 0; } to { transform: none; opacity: 1; } }
350
+
351
+ /* ── Pipeline / DAG canvas ────────────────────────────────────────────────── */
352
+ .dag-canvas { background: var(--bg-primary); border-radius: var(--radius); min-height: 300px; }
353
+ .pipeline-log {
354
+ background: var(--bg-primary);
355
+ border: 1px solid var(--border-color);
356
+ border-radius: var(--radius-sm);
357
+ padding: 14px;
358
+ font-family: 'Fira Code', 'Courier New', monospace;
359
+ font-size: .82rem;
360
+ color: var(--text-secondary);
361
+ max-height: 240px;
362
+ overflow-y: auto;
363
+ line-height: 1.7;
364
+ }
365
+ .log-line-ok { color: var(--success); }
366
+ .log-line-err { color: var(--danger); }
367
+ .log-line-info { color: var(--accent-light); }
368
+
369
+ /* ── AutoML leaderboard ───────────────────────────────────────────────────── */
370
+ .leaderboard-row {
371
+ display: grid;
372
+ grid-template-columns: 30px 1fr auto auto;
373
+ align-items: center;
374
+ gap: 12px;
375
+ padding: 10px 14px;
376
+ border-radius: var(--radius-sm);
377
+ transition: background .15s;
378
+ }
379
+ .leaderboard-row:hover { background: var(--bg-hover); }
380
+ .rank-badge {
381
+ width: 26px; height: 26px; border-radius: 50%;
382
+ display: flex; align-items: center; justify-content: center;
383
+ font-weight: 700; font-size: .8rem;
384
+ }
385
+ .rank-1 { background: #f59e0b33; color: var(--warning); }
386
+ .rank-2 { background: #8b949e33; color: var(--text-secondary); }
387
+ .rank-3 { background: #d97706aa; color: #d97706; }
388
+ .rank-n { background: var(--bg-tertiary); color: var(--text-muted); }
389
+
390
+ /* ── Metric value highlight ───────────────────────────────────────────────── */
391
+ .metric-val {
392
+ font-weight: 700;
393
+ font-size: .95rem;
394
+ font-variant-numeric: tabular-nums;
395
+ }
396
+ .metric-good { color: var(--success); }
397
+ .metric-medium { color: var(--warning); }
398
+ .metric-bad { color: var(--danger); }
399
+
400
+ /* ── Section headings ─────────────────────────────────────────────────────── */
401
+ .page-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 4px; }
402
+ .page-sub { font-size: .88rem; color: var(--text-secondary); margin-bottom: 24px; }
403
+
404
+ /* ── Grid helpers ─────────────────────────────────────────────────────────── */
405
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
406
+ .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
407
+ .flex-between { display: flex; align-items: center; justify-content: space-between; }
408
+ .flex-gap { display: flex; align-items: center; gap: 10px; }
409
+ .mt-4 { margin-top: 4px; }
410
+ .mt-8 { margin-top: 8px; }
411
+ .mt-12 { margin-top: 12px; }
412
+ .mt-20 { margin-top: 20px; }
413
+ .mb-20 { margin-bottom: 20px; }
414
+ .gap-12 { gap: 12px; }
415
+
416
+ /* ── Responsive ───────────────────────────────────────────────────────────── */
417
+ @media (max-width: 768px) {
418
+ .sidebar { transform: translateX(-100%); }
419
+ .sidebar.open { transform: none; }
420
+ .main, .topnav { margin-left: 0; left: 0; }
421
+ .page-content { padding: 16px; }
422
+ .grid-2, .grid-3 { grid-template-columns: 1fr; }
423
+ .stat-grid { grid-template-columns: 1fr 1fr; }
424
+ }
425
+
426
+ /* ── Plotly overrides ─────────────────────────────────────────────────────── */
427
+ .js-plotly-plot .plotly { border-radius: var(--radius-sm); }
428
+ .modebar { display: none !important; }
429
+
430
+ /* ── Category pill row ────────────────────────────────────────────────────── */
431
+ .category-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
432
+ .category-pill {
433
+ padding: 4px 12px;
434
+ border-radius: 20px;
435
+ font-size: .78rem;
436
+ font-weight: 500;
437
+ background: var(--bg-tertiary);
438
+ color: var(--text-secondary);
439
+ border: 1px solid var(--border-color);
440
+ cursor: pointer;
441
+ transition: all .15s;
442
+ }
443
+ .category-pill:hover, .category-pill.active {
444
+ background: var(--accent);
445
+ color: #fff;
446
+ border-color: var(--accent);
447
+ }
448
+
449
+ /* ── Algorithm card grid ──────────────────────────────────────────────────── */
450
+ .algo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
451
+ .algo-card {
452
+ background: var(--bg-tertiary);
453
+ border: 1px solid var(--border-color);
454
+ border-radius: var(--radius-sm);
455
+ padding: 12px 14px;
456
+ cursor: pointer;
457
+ transition: border-color .15s, background .15s;
458
+ position: relative;
459
+ }
460
+ .algo-card:hover { border-color: var(--accent); background: #8b5cf610; }
461
+ .algo-card.selected { border-color: var(--accent); background: #8b5cf618; }
462
+ .algo-card .algo-name { font-weight: 500; font-size: .85rem; margin-bottom: 3px; }
463
+ .algo-card .algo-desc { font-size: .75rem; color: var(--text-muted); line-height: 1.4; }
464
+ .algo-card .algo-dot {
465
+ width: 8px; height: 8px; border-radius: 50%;
466
+ position: absolute; top: 12px; right: 12px;
467
+ }
468
+
469
+ /* ── Spinning loader ──────────────────────────────────────────────────────── */
470
+ .spinner {
471
+ width: 20px; height: 20px; border-radius: 50%;
472
+ border: 2px solid var(--border-color);
473
+ border-top-color: var(--accent);
474
+ animation: spin .6s linear infinite;
475
+ display: inline-block;
476
+ }
477
+ @keyframes spin { to { transform: rotate(360deg); } }
478
+
479
+ /* ── Empty state ──────────────────────────────────────────────────────────── */
480
+ .empty-state {
481
+ text-align: center;
482
+ padding: 48px 20px;
483
+ color: var(--text-muted);
484
+ }
485
+ .empty-state-icon { font-size: 3rem; margin-bottom: 12px; }
486
+ .empty-state-title { font-size: 1rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
static/js/app.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* AutoMLOps — shared utilities */
2
+
3
+ // ── Toast notifications ────────────────────────────────────────────────────
4
+ function showToast(message, type = 'info', duration = 3500) {
5
+ const area = document.getElementById('toast-area');
6
+ if (!area) return;
7
+
8
+ const icons = { success: '✅', error: '❌', info: 'ℹ️' };
9
+ const toast = document.createElement('div');
10
+ toast.className = `toast ${type}`;
11
+ toast.innerHTML = `<span>${icons[type] || 'ℹ️'}</span><span>${message}</span>`;
12
+ area.appendChild(toast);
13
+
14
+ setTimeout(() => {
15
+ toast.style.opacity = '0';
16
+ toast.style.transform = 'translateX(40px)';
17
+ toast.style.transition = 'opacity .3s, transform .3s';
18
+ setTimeout(() => toast.remove(), 300);
19
+ }, duration);
20
+ }
21
+
22
+ // ── Responsive sidebar toggle ──────────────────────────────────────────────
23
+ (function () {
24
+ function checkWidth() {
25
+ const toggle = document.getElementById('sidebar-toggle');
26
+ if (toggle) toggle.style.display = window.innerWidth <= 768 ? 'inline-flex' : 'none';
27
+ }
28
+ window.addEventListener('resize', checkWidth);
29
+ document.addEventListener('DOMContentLoaded', checkWidth);
30
+
31
+ // Close sidebar when clicking outside on mobile
32
+ document.addEventListener('click', (e) => {
33
+ const sidebar = document.getElementById('sidebar');
34
+ const toggle = document.getElementById('sidebar-toggle');
35
+ if (window.innerWidth <= 768 && sidebar && sidebar.classList.contains('open')) {
36
+ if (!sidebar.contains(e.target) && e.target !== toggle) {
37
+ sidebar.classList.remove('open');
38
+ }
39
+ }
40
+ });
41
+ })();
42
+
43
+ // ── Generic tab switcher ───────────────────────────────────────────────────
44
+ function activateTab(panelId, btn, groupClass) {
45
+ document.querySelectorAll(`.${groupClass}`).forEach(p => p.classList.remove('active'));
46
+ document.querySelectorAll(`[data-tab-group="${groupClass}"]`).forEach(b => b.classList.remove('active'));
47
+ document.getElementById(panelId)?.classList.add('active');
48
+ if (btn) btn.classList.add('active');
49
+ }
templates/automl.html ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% set active_page = "automl" %}
3
+
4
+ {% block title %}AutoML{% endblock %}
5
+ {% block page_title %}<i class="fa-solid fa-wand-magic-sparkles" style="color:var(--accent)"></i> AutoML{% endblock %}
6
+
7
+ {% block content %}
8
+ <div class="page-title">AutoML Sweep</div>
9
+ <div class="page-sub">Automatically benchmark every algorithm category against your dataset and rank by chosen metric</div>
10
+
11
+ <div class="grid-2" style="align-items:start">
12
+
13
+ <!-- Config panel -->
14
+ <div class="card">
15
+ <div class="card-title" style="margin-bottom:16px"><i class="fa-solid fa-sliders" style="color:var(--accent)"></i> Configuration</div>
16
+
17
+ <div class="form-group">
18
+ <label class="form-label">Dataset</label>
19
+ <select class="form-select" id="aml-dataset" onchange="updateTaskFromDataset()">
20
+ {% for name, cfg in datasets.items() %}
21
+ <option value="{{ name }}" data-task="{{ cfg.task }}">{{ cfg.icon }} {{ name }}</option>
22
+ {% endfor %}
23
+ </select>
24
+ </div>
25
+
26
+ <div class="form-group">
27
+ <label class="form-label">Task Type</label>
28
+ <select class="form-select" id="aml-task" onchange="updateMetrics()">
29
+ <option value="classification">Classification</option>
30
+ <option value="regression">Regression</option>
31
+ </select>
32
+ </div>
33
+
34
+ <div class="form-group">
35
+ <label class="form-label">Optimise Metric</label>
36
+ <select class="form-select" id="aml-metric">
37
+ <option value="accuracy">Accuracy</option>
38
+ <option value="f1_score">F1 Score</option>
39
+ <option value="precision">Precision</option>
40
+ <option value="recall">Recall</option>
41
+ </select>
42
+ </div>
43
+
44
+ <div class="form-group">
45
+ <label class="form-label">Max Algorithms to Run</label>
46
+ <select class="form-select" id="aml-max-runs">
47
+ <option value="10">10 — Quick sweep</option>
48
+ <option value="20" selected>20 — Standard</option>
49
+ <option value="35">35 — Exhaustive</option>
50
+ </select>
51
+ </div>
52
+
53
+ <!-- Coverage preview -->
54
+ <div style="background:var(--bg-tertiary);border-radius:8px;padding:12px;margin-bottom:16px">
55
+ <div style="font-size:.78rem;color:var(--text-muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em">Algorithm Categories</div>
56
+ <div id="cat-pills" class="category-pills" style="margin-bottom:0"></div>
57
+ </div>
58
+
59
+ <!-- Progress -->
60
+ <div id="aml-progress-wrap" style="display:none;margin-bottom:14px">
61
+ <div class="flex-between" style="margin-bottom:6px">
62
+ <span style="font-size:.82rem;color:var(--text-secondary)" id="aml-status-text">Starting…</span>
63
+ <span style="font-size:.82rem;font-weight:600" id="aml-pct">0%</span>
64
+ </div>
65
+ <div class="progress-bar-wrap"><div class="progress-bar" id="aml-bar" style="width:0%"></div></div>
66
+ <div id="aml-current-algo" style="font-size:.75rem;color:var(--text-muted);margin-top:4px"></div>
67
+ </div>
68
+
69
+ <button class="btn btn-primary" id="btn-aml-run" onclick="runAutoML()" style="width:100%">
70
+ <i class="fa-solid fa-wand-magic-sparkles"></i> Run AutoML Sweep
71
+ </button>
72
+ </div>
73
+
74
+ <!-- Results panel -->
75
+ <div style="display:flex;flex-direction:column;gap:16px">
76
+
77
+ <!-- Best model highlight -->
78
+ <div class="card" id="aml-best-card" style="display:none;border-color:var(--accent)">
79
+ <div class="card-title" style="margin-bottom:10px;color:var(--accent-light)">
80
+ <i class="fa-solid fa-trophy" style="color:var(--warning)"></i> Best Model
81
+ </div>
82
+ <div id="aml-best-content"></div>
83
+ </div>
84
+
85
+ <!-- Score chart -->
86
+ <div class="card" id="aml-chart-card" style="display:none">
87
+ <div class="card-header">
88
+ <div class="card-title"><i class="fa-solid fa-chart-bar" style="color:var(--accent-blue)"></i> Score Comparison</div>
89
+ </div>
90
+ <div id="aml-chart" style="height:260px"></div>
91
+ </div>
92
+
93
+ <!-- Leaderboard -->
94
+ <div class="card">
95
+ <div class="card-header">
96
+ <div class="card-title"><i class="fa-solid fa-ranking-star" style="color:var(--warning)"></i> Leaderboard</div>
97
+ <span id="aml-count" style="font-size:.8rem;color:var(--text-muted)"></span>
98
+ </div>
99
+ <div id="aml-leaderboard">
100
+ <div class="empty-state" style="padding:32px 16px">
101
+ <div class="empty-state-icon" style="font-size:2rem">🏁</div>
102
+ <div class="empty-state-title">No results yet</div>
103
+ <div>Configure and run the AutoML sweep to see rankings</div>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ {% endblock %}
110
+
111
+ {% block scripts %}
112
+ <script>
113
+ const ALGO_DATA_AML = {{ algorithms | tojson }};
114
+
115
+ document.addEventListener('DOMContentLoaded', () => {
116
+ updateTaskFromDataset();
117
+ });
118
+
119
+ function updateTaskFromDataset() {
120
+ const ds = document.getElementById('aml-dataset');
121
+ const opt = ds.options[ds.selectedIndex];
122
+ document.getElementById('aml-task').value = opt.dataset.task || 'classification';
123
+ updateMetrics();
124
+ updateCategoryPills();
125
+ }
126
+
127
+ function updateMetrics() {
128
+ const task = document.getElementById('aml-task').value;
129
+ const sel = document.getElementById('aml-metric');
130
+ sel.innerHTML = task === 'classification'
131
+ ? `<option value="accuracy">Accuracy</option>
132
+ <option value="f1_score">F1 Score</option>
133
+ <option value="precision">Precision</option>
134
+ <option value="recall">Recall</option>`
135
+ : `<option value="r2_score">R² Score</option>
136
+ <option value="mae">MAE (lower = better)</option>
137
+ <option value="rmse">RMSE (lower = better)</option>`;
138
+ updateCategoryPills();
139
+ }
140
+
141
+ function updateCategoryPills() {
142
+ const task = document.getElementById('aml-task').value;
143
+ const cats = Object.keys(ALGO_DATA_AML[task] || {});
144
+ const el = document.getElementById('cat-pills');
145
+ el.innerHTML = cats.map(c => `<span class="category-pill active">${c}</span>`).join('');
146
+ }
147
+
148
+ async function runAutoML() {
149
+ const dataset = document.getElementById('aml-dataset').value;
150
+ const task_type = document.getElementById('aml-task').value;
151
+ const metric = document.getElementById('aml-metric').value;
152
+ const max_runs = parseInt(document.getElementById('aml-max-runs').value);
153
+
154
+ document.getElementById('btn-aml-run').disabled = true;
155
+ document.getElementById('btn-aml-run').innerHTML = '<span class="spinner"></span> Sweeping…';
156
+ document.getElementById('aml-progress-wrap').style.display = 'block';
157
+ document.getElementById('aml-best-card').style.display = 'none';
158
+ document.getElementById('aml-chart-card').style.display = 'none';
159
+ document.getElementById('aml-leaderboard').innerHTML =
160
+ '<div style="text-align:center;padding:20px;color:var(--text-muted)"><span class="spinner"></span> Running sweep…</div>';
161
+
162
+ try {
163
+ const res = await fetch('/api/automl', {
164
+ method: 'POST', headers:{'Content-Type':'application/json'},
165
+ body: JSON.stringify({ dataset, task_type, metric, max_runs }),
166
+ });
167
+ const data = await res.json();
168
+ if (data.error) { showToast(data.error, 'error'); resetAML(); return; }
169
+ pollAutoML(data.job_id, metric);
170
+ } catch(e) { showToast('Failed to start AutoML', 'error'); resetAML(); }
171
+ }
172
+
173
+ function pollAutoML(jobId, metric) {
174
+ const bar = document.getElementById('aml-bar');
175
+ const pct = document.getElementById('aml-pct');
176
+ const statusT = document.getElementById('aml-status-text');
177
+ const curAlgo = document.getElementById('aml-current-algo');
178
+
179
+ const iv = setInterval(async () => {
180
+ const res = await fetch(`/api/automl/status/${jobId}`);
181
+ const job = await res.json();
182
+
183
+ bar.style.width = job.progress + '%';
184
+ pct.textContent = job.progress + '%';
185
+ statusT.textContent = job.status === 'running' ? 'Running sweep…' : job.status;
186
+ if (job.current_algo) curAlgo.textContent = `▶ ${job.current_algo}`;
187
+
188
+ const results = job.results || [];
189
+ if (results.length > 0) {
190
+ renderLeaderboard(results, metric);
191
+ document.getElementById('aml-count').textContent = `${results.length} models`;
192
+ }
193
+
194
+ if (job.status === 'completed') {
195
+ clearInterval(iv);
196
+ statusT.textContent = 'Completed';
197
+ curAlgo.textContent = '';
198
+ renderLeaderboard(results, metric);
199
+ if (job.best) renderBest(job.best, metric);
200
+ renderChart(results, metric);
201
+ document.getElementById('btn-aml-run').disabled = false;
202
+ document.getElementById('btn-aml-run').innerHTML = '<i class="fa-solid fa-rotate-right"></i> Re-run Sweep';
203
+ showToast(`AutoML complete — best: ${job.best?.algorithm} (${metric}: ${job.best?.metrics[metric]})`, 'success');
204
+ } else if (job.status === 'failed') {
205
+ clearInterval(iv);
206
+ showToast('AutoML failed: ' + (job.error || 'unknown'), 'error');
207
+ resetAML();
208
+ }
209
+ }, 1200);
210
+ }
211
+
212
+ function renderLeaderboard(results, metric) {
213
+ const el = document.getElementById('aml-leaderboard');
214
+ if (!results.length) return;
215
+ el.innerHTML = results.map(r => {
216
+ const rankClass = r.rank === 1 ? 'rank-1' : r.rank === 2 ? 'rank-2' : r.rank === 3 ? 'rank-3' : 'rank-n';
217
+ const v = r.metrics[metric];
218
+ const isGood = v >= 0.9;
219
+ const isMed = v >= 0.7;
220
+ const metricClass = isGood ? 'metric-good' : isMed ? 'metric-medium' : 'metric-bad';
221
+ return `<div class="leaderboard-row">
222
+ <div class="rank-badge ${rankClass}">${r.rank}</div>
223
+ <div>
224
+ <div style="font-weight:500;font-size:.88rem">${r.algorithm}</div>
225
+ <div style="font-size:.75rem;color:var(--text-muted)">${r.category} · ${r.duration}s</div>
226
+ </div>
227
+ <span class="badge badge-purple" style="font-size:.72rem">${r.category.split(' ')[0]}</span>
228
+ <div class="metric-val ${metricClass}" style="min-width:52px;text-align:right">${typeof v === 'number' ? v.toFixed(4) : '—'}</div>
229
+ </div>`;
230
+ }).join('');
231
+ }
232
+
233
+ function renderBest(best, metric) {
234
+ const card = document.getElementById('aml-best-card');
235
+ const v = best.metrics[metric];
236
+ document.getElementById('aml-best-content').innerHTML = `
237
+ <div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
238
+ <div>
239
+ <div style="font-size:1.4rem;font-weight:700">${best.algorithm}</div>
240
+ <div style="font-size:.82rem;color:var(--text-secondary)">${best.category}</div>
241
+ </div>
242
+ <div style="margin-left:auto;text-align:right">
243
+ <div style="font-size:.75rem;color:var(--text-muted);text-transform:uppercase">${metric}</div>
244
+ <div style="font-size:2rem;font-weight:700;color:var(--success)">${typeof v === 'number' ? v.toFixed(4) : '—'}</div>
245
+ </div>
246
+ </div>
247
+ <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
248
+ ${Object.entries(best.metrics).map(([k,val]) =>
249
+ `<div style="background:var(--bg-tertiary);padding:6px 12px;border-radius:6px;font-size:.82rem">
250
+ <span style="color:var(--text-muted)">${k}:</span>
251
+ <strong>${typeof val === 'number' ? val.toFixed(4) : val}</strong>
252
+ </div>`).join('')}
253
+ </div>`;
254
+ card.style.display = 'block';
255
+ }
256
+
257
+ function renderChart(results, metric) {
258
+ const top = results.slice(0, 15);
259
+ const bg2 = '#161b22', border = '#30363d', txt = '#8b949e';
260
+ const colors = top.map(r => r.color || '#8b5cf6');
261
+
262
+ Plotly.newPlot('aml-chart', [{
263
+ type: 'bar', orientation: 'h',
264
+ y: top.map(r => r.algorithm).reverse(),
265
+ x: top.map(r => r.metrics[metric] || 0).reverse(),
266
+ marker: { color: colors.slice().reverse() },
267
+ hovertemplate: '<b>%{y}</b><br>' + metric + ': %{x:.4f}<extra></extra>',
268
+ }], {
269
+ paper_bgcolor: bg2, plot_bgcolor: bg2,
270
+ margin: { t: 8, b: 40, l: 8, r: 16 },
271
+ xaxis: { gridcolor: border, color: txt, tickfont:{size:10} },
272
+ yaxis: { color: txt, tickfont:{size:10}, automargin: true },
273
+ bargap: .3,
274
+ }, { responsive: true, displayModeBar: false });
275
+
276
+ document.getElementById('aml-chart-card').style.display = 'block';
277
+ }
278
+
279
+ function resetAML() {
280
+ document.getElementById('btn-aml-run').disabled = false;
281
+ document.getElementById('btn-aml-run').innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i> Run AutoML Sweep';
282
+ }
283
+ </script>
284
+ {% endblock %}
templates/base.html ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>{% block title %}AutoMLOps{% endblock %} — ML Experiment Platform</title>
7
+
8
+ <!-- Google Font -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
12
+
13
+ <!-- Font Awesome -->
14
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
15
+
16
+ <!-- Plotly.js -->
17
+ <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" defer></script>
18
+
19
+ <link rel="stylesheet" href="/static/css/style.css">
20
+
21
+ {% block head_extra %}{% endblock %}
22
+ </head>
23
+ <body>
24
+
25
+ <!-- ── Sidebar ──────────────────────────────────────────────────────────── -->
26
+ <aside class="sidebar" id="sidebar">
27
+ <div class="sidebar-logo">
28
+ <div class="sidebar-logo-icon">🤖</div>
29
+ <div>
30
+ <div class="sidebar-logo-text">AutoMLOps</div>
31
+ <div class="sidebar-logo-sub">ML Experiment Platform</div>
32
+ </div>
33
+ </div>
34
+
35
+ <nav class="sidebar-nav">
36
+ <div class="nav-section-label">Overview</div>
37
+ <a href="/" class="nav-item {% if active_page == 'dashboard' %}active{% endif %}">
38
+ <span class="nav-icon"><i class="fa-solid fa-gauge-high"></i></span> Dashboard
39
+ </a>
40
+
41
+ <div class="nav-section-label">Experiments</div>
42
+ <a href="/experiments" class="nav-item {% if active_page == 'experiments' %}active{% endif %}">
43
+ <span class="nav-icon"><i class="fa-solid fa-flask"></i></span> Experiments
44
+ </a>
45
+ <a href="/automl" class="nav-item {% if active_page == 'automl' %}active{% endif %}">
46
+ <span class="nav-icon"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AutoML
47
+ <span class="nav-badge">NEW</span>
48
+ </a>
49
+
50
+ <div class="nav-section-label">Operations</div>
51
+ <a href="/pipeline" class="nav-item {% if active_page == 'pipeline' %}active{% endif %}">
52
+ <span class="nav-icon"><i class="fa-solid fa-diagram-project"></i></span> Pipelines
53
+ </a>
54
+ <a href="/models" class="nav-item {% if active_page == 'models' %}active{% endif %}">
55
+ <span class="nav-icon"><i class="fa-solid fa-box-archive"></i></span> Model Registry
56
+ </a>
57
+ </nav>
58
+
59
+ <div class="sidebar-footer">
60
+ <div>Powered by <a href="https://mlflow.org" target="_blank">MLflow</a> &amp; sklearn</div>
61
+ <div class="mt-4" style="color: var(--text-muted); font-size:.72rem;">
62
+ <i class="fa-brands fa-python"></i> Python 3.11 &nbsp;·&nbsp;
63
+ <i class="fa-solid fa-code-branch"></i> v1.0
64
+ </div>
65
+ </div>
66
+ </aside>
67
+
68
+ <!-- ── Top navbar ────────────────────────────────────────────────────────── -->
69
+ <header class="topnav">
70
+ <button class="btn btn-ghost btn-sm" id="sidebar-toggle" style="display:none"
71
+ onclick="document.getElementById('sidebar').classList.toggle('open')">
72
+ <i class="fa-solid fa-bars"></i>
73
+ </button>
74
+ <span class="topnav-title">{% block page_title %}AutoMLOps{% endblock %}</span>
75
+ <span class="topnav-badge"><i class="fa-solid fa-circle" style="color:#22c55e;font-size:.55rem"></i> Live</span>
76
+ {% block topnav_actions %}{% endblock %}
77
+ </header>
78
+
79
+ <!-- ── Main ──────────────────────────────────────────────────────────────── -->
80
+ <main class="main">
81
+ <div class="page-content">
82
+ {% block content %}{% endblock %}
83
+ </div>
84
+ </main>
85
+
86
+ <!-- ── Toast area ────────────────────────────────────────────────────────── -->
87
+ <div id="toast-area"></div>
88
+
89
+ <!-- ── Global JS ─────────────────────────────────────────────────────────── -->
90
+ <script src="/static/js/app.js"></script>
91
+ {% block scripts %}{% endblock %}
92
+ </body>
93
+ </html>
templates/dashboard.html ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% set active_page = "dashboard" %}
3
+
4
+ {% block title %}Dashboard{% endblock %}
5
+ {% block page_title %}<i class="fa-solid fa-gauge-high" style="color:var(--accent)"></i> Dashboard{% endblock %}
6
+
7
+ {% block topnav_actions %}
8
+ <button class="btn btn-primary btn-sm" onclick="openTrainModal()">
9
+ <i class="fa-solid fa-play"></i> New Run
10
+ </button>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="page-title">Overview</div>
15
+ <div class="page-sub">Real-time ML experiment tracking powered by MLflow</div>
16
+
17
+ <!-- Stat cards -->
18
+ <div class="stat-grid">
19
+ <div class="stat-card purple">
20
+ <div class="stat-label">Total Runs</div>
21
+ <div class="stat-value" id="stat-total">{{ total_runs }}</div>
22
+ <div class="stat-sub"><i class="fa-solid fa-arrow-trend-up"></i> all time</div>
23
+ </div>
24
+ <div class="stat-card blue">
25
+ <div class="stat-label">Completed</div>
26
+ <div class="stat-value" id="stat-completed">{{ completed_runs }}</div>
27
+ <div class="stat-sub">finished successfully</div>
28
+ </div>
29
+ <div class="stat-card green">
30
+ <div class="stat-label">Best Score</div>
31
+ <div class="stat-value" id="stat-best">{{ best_metric }}</div>
32
+ <div class="stat-sub">accuracy / R²</div>
33
+ </div>
34
+ <div class="stat-card yellow">
35
+ <div class="stat-label">Experiments</div>
36
+ <div class="stat-value" id="stat-exps">{{ n_experiments }}</div>
37
+ <div class="stat-sub">active datasets</div>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Charts row -->
42
+ <div class="grid-2 mb-20">
43
+ <div class="card">
44
+ <div class="card-header">
45
+ <div class="card-title"><i class="fa-solid fa-chart-pie" style="color:var(--accent)"></i> Algorithm Categories</div>
46
+ </div>
47
+ <div id="chart-algo" style="height:220px"></div>
48
+ </div>
49
+ <div class="card">
50
+ <div class="card-header">
51
+ <div class="card-title"><i class="fa-solid fa-database" style="color:var(--accent-blue)"></i> Runs by Dataset</div>
52
+ </div>
53
+ <div id="chart-ds" style="height:220px"></div>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- Recent runs -->
58
+ <div class="card">
59
+ <div class="card-header">
60
+ <div class="card-title"><i class="fa-solid fa-clock-rotate-left" style="color:var(--warning)"></i> Recent Runs</div>
61
+ <a href="/experiments" class="btn btn-ghost btn-sm">View all <i class="fa-solid fa-arrow-right"></i></a>
62
+ </div>
63
+ <div class="table-wrap">
64
+ <table id="recent-table">
65
+ <thead>
66
+ <tr>
67
+ <th>Run ID</th><th>Algorithm</th><th>Category</th><th>Dataset</th>
68
+ <th>Primary Metric</th><th>Duration</th><th>Status</th>
69
+ </tr>
70
+ </thead>
71
+ <tbody>
72
+ {% for r in recent_runs %}
73
+ <tr>
74
+ <td><code style="font-size:.8rem;color:var(--accent-light)">{{ r.run_id }}</code></td>
75
+ <td><strong>{{ r.algorithm }}</strong></td>
76
+ <td><span class="badge badge-purple">{{ r.category }}</span></td>
77
+ <td>{{ r.dataset }}</td>
78
+ <td>
79
+ <span class="metric-val {% if r.primary_metric >= 0.9 %}metric-good{% elif r.primary_metric >= 0.7 %}metric-medium{% else %}metric-bad{% endif %}">
80
+ {{ r.primary_metric }}
81
+ </span>
82
+ </td>
83
+ <td>{{ r.duration }}s</td>
84
+ <td>
85
+ {% if r.status == 'FINISHED' %}
86
+ <span class="badge badge-success"><i class="fa-solid fa-check"></i> Done</span>
87
+ {% elif r.status == 'RUNNING' %}
88
+ <span class="badge badge-info"><span class="spinner" style="width:10px;height:10px;border-width:1.5px"></span> Running</span>
89
+ {% else %}
90
+ <span class="badge badge-muted">{{ r.status }}</span>
91
+ {% endif %}
92
+ </td>
93
+ </tr>
94
+ {% else %}
95
+ <tr><td colspan="7">
96
+ <div class="empty-state">
97
+ <div class="empty-state-icon">🔬</div>
98
+ <div class="empty-state-title">No runs yet</div>
99
+ <div>Click <strong>New Run</strong> to train your first model</div>
100
+ </div>
101
+ </td></tr>
102
+ {% endfor %}
103
+ </tbody>
104
+ </table>
105
+ </div>
106
+ </div>
107
+
108
+ <!-- ── Quick Train Modal ──────────────────────────────────────────────────── -->
109
+ <div class="modal-overlay" id="train-modal">
110
+ <div class="modal">
111
+ <div class="modal-header">
112
+ <div class="modal-title"><i class="fa-solid fa-play" style="color:var(--accent)"></i> New Training Run</div>
113
+ <button class="modal-close" onclick="closeTrainModal()"><i class="fa-solid fa-xmark"></i></button>
114
+ </div>
115
+
116
+ <div class="form-group">
117
+ <label class="form-label">Dataset</label>
118
+ <select class="form-select" id="sel-dataset" onchange="updateTaskType()">
119
+ {% for name, cfg in datasets.items() %}
120
+ <option value="{{ name }}" data-task="{{ cfg.task }}">{{ cfg.icon }} {{ name }} — {{ cfg.description[:55] }}…</option>
121
+ {% endfor %}
122
+ </select>
123
+ </div>
124
+
125
+ <div class="form-group">
126
+ <label class="form-label">Task Type</label>
127
+ <select class="form-select" id="sel-task" onchange="populateCategories()">
128
+ <option value="classification">Classification</option>
129
+ <option value="regression">Regression</option>
130
+ </select>
131
+ </div>
132
+
133
+ <div class="form-group">
134
+ <label class="form-label">Algorithm Category</label>
135
+ <select class="form-select" id="sel-category" onchange="populateAlgorithms()"></select>
136
+ </div>
137
+
138
+ <div class="form-group">
139
+ <label class="form-label">Algorithm</label>
140
+ <select class="form-select" id="sel-algorithm"></select>
141
+ </div>
142
+
143
+ <!-- Progress (hidden until running) -->
144
+ <div id="train-progress-wrap" style="display:none;margin-top:12px">
145
+ <div class="flex-between mb-20" style="margin-bottom:6px">
146
+ <span style="font-size:.85rem;color:var(--text-secondary)" id="train-status-text">Initialising…</span>
147
+ <span style="font-size:.85rem;font-weight:600" id="train-pct">0%</span>
148
+ </div>
149
+ <div class="progress-bar-wrap"><div class="progress-bar" id="train-bar" style="width:0%"></div></div>
150
+ </div>
151
+
152
+ <!-- Result (hidden until done) -->
153
+ <div id="train-result" style="display:none;margin-top:14px"></div>
154
+
155
+ <div class="modal-footer">
156
+ <button class="btn btn-ghost" onclick="closeTrainModal()">Cancel</button>
157
+ <button class="btn btn-primary" id="btn-start-train" onclick="startTraining()">
158
+ <i class="fa-solid fa-play"></i> Train
159
+ </button>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ {% endblock %}
164
+
165
+ {% block scripts %}
166
+ <script>
167
+ const ALGO_DATA = {{ algorithms | tojson }};
168
+ const ALGO_COUNTS = {{ algo_counts }};
169
+ const DS_COUNTS = {{ ds_counts }};
170
+
171
+ // ── Charts ────────────────────────────────────────────────────────────────
172
+ document.addEventListener('DOMContentLoaded', () => {
173
+ const dark = '#0d1117', bg2 = '#161b22', border = '#30363d', txt = '#8b949e';
174
+
175
+ // Donut — algorithm categories
176
+ const cats = Object.keys(ALGO_COUNTS);
177
+ const vals = Object.values(ALGO_COUNTS);
178
+ const COLORS = ['#8b5cf6','#3b82f6','#22c55e','#f59e0b','#ef4444','#06b6d4','#ec4899','#a855f7'];
179
+ Plotly.newPlot('chart-algo', [{
180
+ type: 'pie', hole: .55,
181
+ labels: cats, values: vals,
182
+ marker: { colors: COLORS.slice(0, cats.length) },
183
+ textinfo: 'none',
184
+ hovertemplate: '<b>%{label}</b><br>%{value} runs<extra></extra>',
185
+ }], {
186
+ paper_bgcolor: bg2, plot_bgcolor: bg2,
187
+ margin: { t:8, b:8, l:8, r:8 },
188
+ legend: { font: { color: txt, size: 11 }, bgcolor: 'transparent', x: 1.05 },
189
+ showlegend: cats.length > 0,
190
+ annotations: [{ text: `<b>${vals.reduce((a,b)=>a+b,0)}</b><br><span style="font-size:10px">runs</span>`,
191
+ x:.5, y:.5, font:{size:14,color:'#e6edf3'}, showarrow:false }],
192
+ }, { responsive: true, displayModeBar: false });
193
+
194
+ // Bar — datasets
195
+ const dsKeys = Object.keys(DS_COUNTS);
196
+ const dsVals = Object.values(DS_COUNTS);
197
+ Plotly.newPlot('chart-ds', [{
198
+ type: 'bar', orientation: 'h',
199
+ y: dsKeys, x: dsVals,
200
+ marker: { color: '#3b82f6', opacity: .85 },
201
+ hovertemplate: '<b>%{y}</b>: %{x} runs<extra></extra>',
202
+ }], {
203
+ paper_bgcolor: bg2, plot_bgcolor: bg2,
204
+ margin: { t:8, b:24, l:8, r:16 },
205
+ xaxis: { gridcolor: border, color: txt, tickfont:{size:10} },
206
+ yaxis: { color: txt, tickfont:{size:10}, automargin:true },
207
+ bargap: .35,
208
+ }, { responsive: true, displayModeBar: false });
209
+
210
+ populateCategories();
211
+ });
212
+
213
+ // ── Train modal helpers ────────────────────────────────────────────────────
214
+ function updateTaskType() {
215
+ const ds = document.getElementById('sel-dataset');
216
+ const opt = ds.options[ds.selectedIndex];
217
+ document.getElementById('sel-task').value = opt.dataset.task || 'classification';
218
+ populateCategories();
219
+ }
220
+
221
+ function populateCategories() {
222
+ const task = document.getElementById('sel-task').value;
223
+ const sel = document.getElementById('sel-category');
224
+ sel.innerHTML = '';
225
+ const cats = Object.keys(ALGO_DATA[task] || {});
226
+ cats.forEach(c => { const o = document.createElement('option'); o.value = c; o.text = c; sel.add(o); });
227
+ populateAlgorithms();
228
+ }
229
+
230
+ function populateAlgorithms() {
231
+ const task = document.getElementById('sel-task').value;
232
+ const cat = document.getElementById('sel-category').value;
233
+ const sel = document.getElementById('sel-algorithm');
234
+ sel.innerHTML = '';
235
+ const algos = Object.keys((ALGO_DATA[task] || {})[cat] || {});
236
+ algos.forEach(a => { const o = document.createElement('option'); o.value = a; o.text = a; sel.add(o); });
237
+ }
238
+
239
+ function openTrainModal() { document.getElementById('train-modal').classList.add('open'); resetTrainModal(); }
240
+ function closeTrainModal() { document.getElementById('train-modal').classList.remove('open'); }
241
+
242
+ function resetTrainModal() {
243
+ document.getElementById('train-progress-wrap').style.display = 'none';
244
+ document.getElementById('train-result').style.display = 'none';
245
+ document.getElementById('btn-start-train').disabled = false;
246
+ document.getElementById('btn-start-train').innerHTML = '<i class="fa-solid fa-play"></i> Train';
247
+ }
248
+
249
+ async function startTraining() {
250
+ const dataset = document.getElementById('sel-dataset').value;
251
+ const task_type = document.getElementById('sel-task').value;
252
+ const category = document.getElementById('sel-category').value;
253
+ const algorithm = document.getElementById('sel-algorithm').value;
254
+
255
+ document.getElementById('btn-start-train').disabled = true;
256
+ document.getElementById('btn-start-train').innerHTML = '<span class="spinner"></span> Running…';
257
+ document.getElementById('train-progress-wrap').style.display = 'block';
258
+ document.getElementById('train-result').style.display = 'none';
259
+
260
+ try {
261
+ const res = await fetch('/api/train', {
262
+ method: 'POST', headers:{'Content-Type':'application/json'},
263
+ body: JSON.stringify({ dataset, task_type, category, algorithm }),
264
+ });
265
+ const data = await res.json();
266
+ if (data.error) { showToast(data.error, 'error'); resetTrainModal(); return; }
267
+ pollTraining(data.job_id);
268
+ } catch(e) { showToast('Request failed', 'error'); resetTrainModal(); }
269
+ }
270
+
271
+ function pollTraining(jobId) {
272
+ const bar = document.getElementById('train-bar');
273
+ const pct = document.getElementById('train-pct');
274
+ const statusT = document.getElementById('train-status-text');
275
+ const resultD = document.getElementById('train-result');
276
+
277
+ const iv = setInterval(async () => {
278
+ const res = await fetch(`/api/run/${jobId}/status`);
279
+ const job = await res.json();
280
+
281
+ bar.style.width = job.progress + '%';
282
+ pct.textContent = job.progress + '%';
283
+ statusT.textContent = job.status.charAt(0).toUpperCase() + job.status.slice(1) + '…';
284
+
285
+ if (job.status === 'completed') {
286
+ clearInterval(iv);
287
+ statusT.textContent = 'Completed';
288
+ const m = job.metrics || {};
289
+ const keys = Object.keys(m);
290
+ const primary = keys[0];
291
+ resultD.style.display = 'block';
292
+ resultD.innerHTML = `
293
+ <div style="background:var(--bg-tertiary);border-radius:8px;padding:14px">
294
+ <div style="font-weight:600;margin-bottom:10px;color:var(--success)">
295
+ <i class="fa-solid fa-circle-check"></i> Training complete
296
+ </div>
297
+ <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px">
298
+ ${keys.map(k=>`<div style="background:var(--bg-secondary);padding:8px 10px;border-radius:6px">
299
+ <div style="font-size:.72rem;color:var(--text-muted);text-transform:uppercase">${k}</div>
300
+ <div style="font-size:1.1rem;font-weight:700;color:var(--success)">${m[k]}</div>
301
+ </div>`).join('')}
302
+ </div>
303
+ </div>`;
304
+ document.getElementById('btn-start-train').innerHTML = '<i class="fa-solid fa-rotate-right"></i> Run Again';
305
+ document.getElementById('btn-start-train').disabled = false;
306
+ showToast(`${job.algorithm} → ${primary}: ${m[primary]}`, 'success');
307
+ refreshStats();
308
+ } else if (job.status === 'failed') {
309
+ clearInterval(iv);
310
+ showToast('Training failed: ' + (job.error || 'unknown'), 'error');
311
+ resetTrainModal();
312
+ }
313
+ }, 1000);
314
+ }
315
+
316
+ async function refreshStats() {
317
+ try {
318
+ const r = await fetch('/api/stats');
319
+ const s = await r.json();
320
+ document.getElementById('stat-total').textContent = s.total_runs;
321
+ document.getElementById('stat-completed').textContent = s.completed_runs;
322
+ document.getElementById('stat-best').textContent = s.best_metric;
323
+ document.getElementById('stat-exps').textContent = s.n_experiments;
324
+ } catch(_) {}
325
+ }
326
+ </script>
327
+ {% endblock %}
templates/experiments.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% set active_page = "experiments" %}
3
+
4
+ {% block title %}Experiments{% endblock %}
5
+ {% block page_title %}<i class="fa-solid fa-flask" style="color:var(--accent-blue)"></i> Experiments{% endblock %}
6
+
7
+ {% block content %}
8
+ <div class="page-title">Experiment Tracker</div>
9
+ <div class="page-sub">Browse all MLflow experiments and compare runs side-by-side</div>
10
+
11
+ {% if experiments %}
12
+ <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
13
+ {% for exp in experiments %}
14
+ <a href="/experiments/{{ exp.experiment_id }}" style="text-decoration:none">
15
+ <div class="card" style="cursor:pointer;transition:border-color .15s" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border-color)'">
16
+ <div class="flex-between mb-20" style="margin-bottom:12px">
17
+ <div style="font-weight:600;font-size:.95rem">{{ exp.name }}</div>
18
+ <span class="badge badge-info">{{ exp.run_count }} runs</span>
19
+ </div>
20
+ <div style="display:flex;gap:20px;font-size:.85rem">
21
+ <div>
22
+ <div style="color:var(--text-muted);font-size:.75rem;margin-bottom:2px">Best Score</div>
23
+ <div class="metric-val {% if exp.best_metric >= 0.9 %}metric-good{% elif exp.best_metric >= 0.7 %}metric-medium{% else %}metric-bad{% endif %}">
24
+ {{ exp.best_metric if exp.best_metric > 0 else '—' }}
25
+ </div>
26
+ </div>
27
+ <div>
28
+ <div style="color:var(--text-muted);font-size:.75rem;margin-bottom:2px">Created</div>
29
+ <div style="color:var(--text-secondary)">{{ exp.created_at }}</div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </a>
34
+ {% endfor %}
35
+ </div>
36
+
37
+ {% else %}
38
+ <div class="card">
39
+ <div class="empty-state">
40
+ <div class="empty-state-icon">🔬</div>
41
+ <div class="empty-state-title">No experiments yet</div>
42
+ <div style="margin-bottom:16px">Start a training run from the Dashboard to create your first experiment.</div>
43
+ <a href="/" class="btn btn-primary"><i class="fa-solid fa-play"></i> Go to Dashboard</a>
44
+ </div>
45
+ </div>
46
+ {% endif %}
47
+ {% endblock %}
templates/models.html ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% set active_page = "models" %}
3
+
4
+ {% block title %}Model Registry{% endblock %}
5
+ {% block page_title %}<i class="fa-solid fa-box-archive" style="color:var(--warning)"></i> Model Registry{% endblock %}
6
+
7
+ {% block content %}
8
+ <div class="page-title">Model Registry</div>
9
+ <div class="page-sub">Track model versions and manage stage transitions: None → Staging → Production → Archived</div>
10
+
11
+ {% if models %}
12
+ <div style="display:flex;flex-direction:column;gap:16px">
13
+ {% for model in models %}
14
+ <div class="card">
15
+ <div class="flex-between" style="margin-bottom:14px">
16
+ <div>
17
+ <div style="font-weight:700;font-size:1rem">{{ model.name }}</div>
18
+ <div style="font-size:.82rem;color:var(--text-secondary);margin-top:2px">{{ model.description }}</div>
19
+ </div>
20
+ <span class="badge stage-{{ model.latest_stage | lower }}">
21
+ {% if model.latest_stage == 'Production' %}<i class="fa-solid fa-rocket"></i>
22
+ {% elif model.latest_stage == 'Staging' %}<i class="fa-solid fa-flask"></i>
23
+ {% elif model.latest_stage == 'Archived' %}<i class="fa-solid fa-archive"></i>
24
+ {% else %}<i class="fa-solid fa-box"></i>{% endif %}
25
+ {{ model.latest_stage }}
26
+ </span>
27
+ </div>
28
+ <div class="table-wrap">
29
+ <table>
30
+ <thead>
31
+ <tr><th>Version</th><th>Stage</th><th>Run ID</th><th>Key Metrics</th><th>Registered</th><th>Actions</th></tr>
32
+ </thead>
33
+ <tbody>
34
+ {% for v in model.versions %}
35
+ <tr>
36
+ <td><span class="badge badge-muted">v{{ v.version }}</span></td>
37
+ <td>
38
+ <span class="badge stage-{{ v.stage | lower }}">
39
+ {{ v.stage }}
40
+ </span>
41
+ </td>
42
+ <td><code style="font-size:.8rem;color:var(--accent-light)">{{ v.run_id }}</code></td>
43
+ <td>
44
+ {% if v.metrics %}
45
+ {% for k, val in v.metrics.items() %}
46
+ <span style="font-size:.8rem;margin-right:8px">
47
+ <span style="color:var(--text-muted)">{{ k }}:</span>
48
+ <strong>{{ val }}</strong>
49
+ </span>
50
+ {% endfor %}
51
+ {% else %}—{% endif %}
52
+ </td>
53
+ <td style="font-size:.8rem;color:var(--text-muted)">{{ v.created_at }}</td>
54
+ <td>
55
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
56
+ {% if v.stage != 'Staging' and v.stage != 'Production' %}
57
+ <button class="btn btn-ghost btn-sm"
58
+ onclick="transitionStage('{{ model.name }}','{{ v.version }}','Staging', this)">
59
+ → Staging
60
+ </button>
61
+ {% endif %}
62
+ {% if v.stage == 'Staging' %}
63
+ <button class="btn btn-success btn-sm"
64
+ onclick="transitionStage('{{ model.name }}','{{ v.version }}','Production', this)">
65
+ <i class="fa-solid fa-rocket"></i> Promote
66
+ </button>
67
+ {% endif %}
68
+ {% if v.stage == 'Production' %}
69
+ <button class="btn btn-warning btn-sm"
70
+ onclick="transitionStage('{{ model.name }}','{{ v.version }}','Archived', this)">
71
+ Archive
72
+ </button>
73
+ {% endif %}
74
+ </div>
75
+ </td>
76
+ </tr>
77
+ {% endfor %}
78
+ </tbody>
79
+ </table>
80
+ </div>
81
+ </div>
82
+ {% endfor %}
83
+ </div>
84
+
85
+ {% else %}
86
+ <div class="card">
87
+ <div class="empty-state">
88
+ <div class="empty-state-icon">📦</div>
89
+ <div class="empty-state-title">No registered models yet</div>
90
+ <div style="margin-bottom:16px;max-width:400px;margin-left:auto;margin-right:auto">
91
+ Run the <strong>Training Pipeline</strong> or use the AutoML sweep,
92
+ then register the best model from the Experiments page.
93
+ </div>
94
+ <a href="/pipeline" class="btn btn-primary"><i class="fa-solid fa-diagram-project"></i> Go to Pipelines</a>
95
+ </div>
96
+ </div>
97
+ {% endif %}
98
+
99
+ <!-- Register modal -->
100
+ <div class="modal-overlay" id="register-modal">
101
+ <div class="modal">
102
+ <div class="modal-header">
103
+ <div class="modal-title"><i class="fa-solid fa-plus" style="color:var(--accent)"></i> Register Model</div>
104
+ <button class="modal-close" onclick="document.getElementById('register-modal').classList.remove('open')"><i class="fa-solid fa-xmark"></i></button>
105
+ </div>
106
+ <div class="form-group">
107
+ <label class="form-label">MLflow Run ID</label>
108
+ <input type="text" class="form-control" id="reg-run-id" placeholder="e.g. abc12345…">
109
+ </div>
110
+ <div class="form-group">
111
+ <label class="form-label">Model Name</label>
112
+ <input type="text" class="form-control" id="reg-name" placeholder="e.g. iris-classifier">
113
+ </div>
114
+ <div class="modal-footer">
115
+ <button class="btn btn-ghost" onclick="document.getElementById('register-modal').classList.remove('open')">Cancel</button>
116
+ <button class="btn btn-primary" onclick="registerModel()"><i class="fa-solid fa-box-archive"></i> Register</button>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ {% endblock %}
121
+
122
+ {% block scripts %}
123
+ <script>
124
+ async function transitionStage(name, version, stage, btn) {
125
+ btn.disabled = true;
126
+ btn.innerHTML = '<span class="spinner" style="width:12px;height:12px;border-width:1.5px"></span>';
127
+ try {
128
+ const res = await fetch(`/api/models/${encodeURIComponent(name)}/${version}/stage`, {
129
+ method: 'POST', headers:{'Content-Type':'application/json'},
130
+ body: JSON.stringify({ stage }),
131
+ });
132
+ const data = await res.json();
133
+ if (data.error) { showToast(data.error, 'error'); btn.disabled = false; return; }
134
+ showToast(`${name} v${version} → ${stage}`, 'success');
135
+ setTimeout(() => location.reload(), 900);
136
+ } catch(e) { showToast('Request failed', 'error'); btn.disabled = false; }
137
+ }
138
+
139
+ async function registerModel() {
140
+ const runId = document.getElementById('reg-run-id').value.trim();
141
+ const name = document.getElementById('reg-name').value.trim();
142
+ if (!runId || !name) { showToast('Run ID and name are required', 'error'); return; }
143
+ try {
144
+ const res = await fetch('/api/models/register', {
145
+ method: 'POST', headers:{'Content-Type':'application/json'},
146
+ body: JSON.stringify({ run_id: runId, name }),
147
+ });
148
+ const data = await res.json();
149
+ if (data.error) { showToast(data.error, 'error'); return; }
150
+ showToast(`Registered ${data.name} v${data.version}`, 'success');
151
+ setTimeout(() => location.reload(), 900);
152
+ } catch(e) { showToast('Request failed', 'error'); }
153
+ }
154
+ </script>
155
+ {% endblock %}
templates/pipeline.html ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% set active_page = "pipeline" %}
3
+
4
+ {% block title %}Pipelines{% endblock %}
5
+ {% block page_title %}<i class="fa-solid fa-diagram-project" style="color:var(--cyan)"></i> Pipelines{% endblock %}
6
+
7
+ {% block content %}
8
+ <div class="page-title">Pipeline Orchestration</div>
9
+ <div class="page-sub">Airflow-style DAG visualisation and execution — click any pipeline to inspect and run</div>
10
+
11
+ <!-- Pipeline selector tabs -->
12
+ <div class="tab-bar" id="pipeline-tabs">
13
+ <button class="tab-btn active" onclick="switchPipeline('training_pipeline', this)">
14
+ <i class="fa-solid fa-brain"></i> Training Pipeline
15
+ </button>
16
+ <button class="tab-btn" onclick="switchPipeline('retraining_pipeline', this)">
17
+ <i class="fa-solid fa-rotate"></i> Retraining Pipeline
18
+ </button>
19
+ <button class="tab-btn" onclick="switchPipeline('data_pipeline', this)">
20
+ <i class="fa-solid fa-database"></i> Data Pipeline
21
+ </button>
22
+ </div>
23
+
24
+ <!-- Pipeline description + run button -->
25
+ <div class="card mb-20" style="margin-bottom:16px">
26
+ <div class="flex-between">
27
+ <div>
28
+ <div class="card-title" id="pipeline-name" style="font-size:1rem;margin-bottom:4px">Training Pipeline</div>
29
+ <div id="pipeline-desc" style="font-size:.85rem;color:var(--text-secondary)">
30
+ End-to-end model training: ingest → preprocess → train → evaluate → register
31
+ </div>
32
+ </div>
33
+ <div class="flex-gap">
34
+ <!-- Context form for training pipeline -->
35
+ <div id="ctx-form" style="display:flex;gap:8px;flex-wrap:wrap">
36
+ <select class="form-select" id="ctx-dataset" style="width:auto;padding:6px 28px 6px 10px;font-size:.82rem">
37
+ {% for name in datasets %}<option>{{ name }}</option>{% endfor %}
38
+ </select>
39
+ </div>
40
+ <button class="btn btn-primary" id="btn-run-pipeline" onclick="runPipeline()">
41
+ <i class="fa-solid fa-play"></i> Execute DAG
42
+ </button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+
47
+ <!-- DAG canvas -->
48
+ <div class="card mb-20" style="margin-bottom:16px">
49
+ <div class="card-header">
50
+ <div class="card-title"><i class="fa-solid fa-sitemap" style="color:var(--accent)"></i> DAG Graph</div>
51
+ <span id="exec-status-badge"></span>
52
+ </div>
53
+ <div id="dag-canvas" class="dag-canvas" style="height:320px"></div>
54
+ </div>
55
+
56
+ <!-- Execution state grid + log -->
57
+ <div class="grid-2">
58
+ <div class="card">
59
+ <div class="card-header">
60
+ <div class="card-title"><i class="fa-solid fa-list-check" style="color:var(--success)"></i> Task Status</div>
61
+ <div id="exec-progress-wrap" style="display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--text-muted)">
62
+ <span id="exec-pct">—</span>
63
+ </div>
64
+ </div>
65
+ <div id="task-list" style="display:flex;flex-direction:column;gap:6px"></div>
66
+ </div>
67
+
68
+ <div class="card">
69
+ <div class="card-header">
70
+ <div class="card-title"><i class="fa-solid fa-terminal" style="color:var(--warning)"></i> Execution Log</div>
71
+ <button class="btn btn-ghost btn-sm" onclick="clearLog()"><i class="fa-solid fa-trash"></i></button>
72
+ </div>
73
+ <div class="pipeline-log" id="exec-log">Waiting for execution…</div>
74
+ </div>
75
+ </div>
76
+ {% endblock %}
77
+
78
+ {% block scripts %}
79
+ <script>
80
+ const DAGS = {{ dags | safe }};
81
+ let currentPipeline = 'training_pipeline';
82
+ let currentExecId = null;
83
+ let pollIv = null;
84
+ let currentDag = null;
85
+
86
+ const STATUS_COLORS = {
87
+ pending: '#30363d', running: '#f59e0b', success: '#22c55e', failed: '#ef4444',
88
+ };
89
+ const STATUS_ICONS = {
90
+ pending: '⏳', running: '⚡', success: '✅', failed: '❌',
91
+ };
92
+
93
+ // ── Init ─────────────────────────────────────────────────────────────────────
94
+ document.addEventListener('DOMContentLoaded', () => {
95
+ switchPipeline('training_pipeline',
96
+ document.querySelector('.tab-btn.active'));
97
+ });
98
+
99
+ function switchPipeline(id, btn) {
100
+ currentPipeline = id;
101
+ currentExecId = null;
102
+ if (pollIv) { clearInterval(pollIv); pollIv = null; }
103
+
104
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
105
+ btn.classList.add('active');
106
+
107
+ const dag = DAGS[id];
108
+ currentDag = dag;
109
+
110
+ document.getElementById('pipeline-name').textContent = dag.name;
111
+ document.getElementById('pipeline-desc').textContent = dag.description;
112
+
113
+ // Show dataset selector only for training pipeline
114
+ document.getElementById('ctx-form').style.display =
115
+ id === 'training_pipeline' ? 'flex' : 'none';
116
+
117
+ renderDAG(dag);
118
+ renderTaskList(dag, {});
119
+ document.getElementById('exec-log').textContent = 'Waiting for execution…';
120
+ document.getElementById('exec-status-badge').innerHTML = '';
121
+ document.getElementById('exec-pct').textContent = '—';
122
+ document.getElementById('btn-run-pipeline').disabled = false;
123
+ document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Execute DAG';
124
+ }
125
+
126
+ // ── DAG rendering with Plotly ─────────────────────────────────────────────────
127
+ function renderDAG(dag, taskStates) {
128
+ taskStates = taskStates || {};
129
+ const tasks = Object.values(dag.tasks);
130
+ const layers = {};
131
+ tasks.forEach(t => { layers[t.layer] = (layers[t.layer] || []); layers[t.layer].push(t); });
132
+
133
+ const nodeX = {}, nodeY = {};
134
+ const maxLayer = Math.max(...tasks.map(t => t.layer));
135
+ const xStep = 1 / (maxLayer + 1);
136
+
137
+ Object.entries(layers).forEach(([layer, ts]) => {
138
+ const xPos = (parseInt(layer) + 0.5) * xStep;
139
+ const yStep = 1 / (ts.length + 1);
140
+ ts.forEach((t, i) => {
141
+ nodeX[t.task_id] = xPos;
142
+ nodeY[t.task_id] = (i + 1) * yStep;
143
+ });
144
+ });
145
+
146
+ // Build edge traces
147
+ const edgeTraces = [];
148
+ tasks.forEach(t => {
149
+ t.upstream.forEach(upId => {
150
+ if (!nodeX[upId]) return;
151
+ edgeTraces.push({
152
+ type: 'scatter', mode: 'lines',
153
+ x: [nodeX[upId], nodeX[t.task_id]],
154
+ y: [nodeY[upId], nodeY[t.task_id]],
155
+ line: { color: '#30363d', width: 2 },
156
+ hoverinfo: 'none', showlegend: false,
157
+ });
158
+ });
159
+ });
160
+
161
+ // Node trace
162
+ const nodeColors = tasks.map(t => {
163
+ const st = (taskStates[t.task_id] || {}).status || 'pending';
164
+ return STATUS_COLORS[st] || '#30363d';
165
+ });
166
+ const nodeText = tasks.map(t => {
167
+ const st = (taskStates[t.task_id] || {}).status || 'pending';
168
+ return `${t.icon} ${t.name}<br><span style="font-size:10px">${STATUS_ICONS[st]} ${st}</span>`;
169
+ });
170
+ const nodeHover = tasks.map(t => {
171
+ const st = (taskStates[t.task_id] || {}).status || 'pending';
172
+ return `<b>${t.name}</b><br>${t.description}<br>Status: ${st}`;
173
+ });
174
+
175
+ const nodeTrace = {
176
+ type: 'scatter', mode: 'markers+text',
177
+ x: tasks.map(t => nodeX[t.task_id]),
178
+ y: tasks.map(t => nodeY[t.task_id]),
179
+ text: nodeText,
180
+ textposition: 'bottom center',
181
+ textfont: { color: '#e6edf3', size: 11 },
182
+ marker: {
183
+ size: 36,
184
+ color: nodeColors,
185
+ line: { color: '#e6edf3', width: 1.5 },
186
+ symbol: 'circle',
187
+ },
188
+ hovertemplate: nodeHover.map(h => h + '<extra></extra>'),
189
+ showlegend: false,
190
+ };
191
+
192
+ const bg = '#0d1117';
193
+ Plotly.react('dag-canvas', [...edgeTraces, nodeTrace], {
194
+ paper_bgcolor: bg, plot_bgcolor: bg,
195
+ margin: { t: 20, b: 40, l: 20, r: 20 },
196
+ xaxis: { showgrid: false, zeroline: false, showticklabels: false, range: [0,1] },
197
+ yaxis: { showgrid: false, zeroline: false, showticklabels: false, range: [0,1] },
198
+ dragmode: false,
199
+ }, { responsive: true, displayModeBar: false });
200
+ }
201
+
202
+ // ── Task status list ──────────────────────────────────────────────────────────
203
+ function renderTaskList(dag, taskStates) {
204
+ const tasks = Object.values(dag.tasks).sort((a,b) => a.layer - b.layer);
205
+ const el = document.getElementById('task-list');
206
+ el.innerHTML = tasks.map(t => {
207
+ const st = (taskStates[t.task_id] || {}).status || 'pending';
208
+ const res = (taskStates[t.task_id] || {}).result || '';
209
+ const clr = STATUS_COLORS[st] || '#30363d';
210
+ return `<div style="display:flex;align-items:flex-start;gap:10px;padding:8px 10px;border-radius:6px;background:var(--bg-tertiary)">
211
+ <div style="width:10px;height:10px;border-radius:50%;background:${clr};margin-top:4px;flex-shrink:0"></div>
212
+ <div style="flex:1;min-width:0">
213
+ <div style="font-size:.85rem;font-weight:500">${t.icon} ${t.name}</div>
214
+ ${res ? `<div style="font-size:.75rem;color:var(--text-muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${res}">${res}</div>` : ''}
215
+ </div>
216
+ <span style="font-size:.72rem;padding:2px 7px;border-radius:10px;background:${clr}22;color:${clr};white-space:nowrap">${STATUS_ICONS[st]} ${st}</span>
217
+ </div>`;
218
+ }).join('');
219
+ }
220
+
221
+ // ── Execute pipeline ──────────────────────────────────────────────────────────
222
+ async function runPipeline() {
223
+ document.getElementById('btn-run-pipeline').disabled = true;
224
+ document.getElementById('btn-run-pipeline').innerHTML = '<span class="spinner"></span> Running…';
225
+ document.getElementById('exec-log').textContent = '';
226
+ document.getElementById('exec-status-badge').innerHTML =
227
+ '<span class="badge badge-warning"><span class="spinner" style="width:9px;height:9px;border-width:1.5px"></span> Running</span>';
228
+
229
+ const ctx = {};
230
+ if (currentPipeline === 'training_pipeline') {
231
+ ctx.dataset = document.getElementById('ctx-dataset').value;
232
+ }
233
+
234
+ try {
235
+ const res = await fetch(`/api/pipeline/${currentPipeline}/execute`, {
236
+ method: 'POST', headers:{'Content-Type':'application/json'},
237
+ body: JSON.stringify(ctx),
238
+ });
239
+ const data = await res.json();
240
+ currentExecId = data.exec_id;
241
+ pollExecution();
242
+ } catch(e) {
243
+ showToast('Failed to start pipeline', 'error');
244
+ document.getElementById('btn-run-pipeline').disabled = false;
245
+ document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Execute DAG';
246
+ }
247
+ }
248
+
249
+ function pollExecution() {
250
+ if (pollIv) clearInterval(pollIv);
251
+ pollIv = setInterval(async () => {
252
+ const res = await fetch(`/api/pipeline/status/${currentExecId}`);
253
+ const exec = await res.json();
254
+
255
+ // Update progress
256
+ document.getElementById('exec-pct').textContent = exec.progress + '%';
257
+
258
+ // Update log
259
+ const logEl = document.getElementById('exec-log');
260
+ logEl.innerHTML = (exec.logs || []).map(line => {
261
+ let cls = '';
262
+ if (line.includes('✔')) cls = 'log-line-ok';
263
+ else if (line.includes('✖')) cls = 'log-line-err';
264
+ else if (line.includes('▶')) cls = 'log-line-info';
265
+ return `<div class="${cls}">${line}</div>`;
266
+ }).join('');
267
+ logEl.scrollTop = logEl.scrollHeight;
268
+
269
+ // Update DAG
270
+ if (exec.task_states) {
271
+ renderDAG(currentDag, exec.task_states);
272
+ renderTaskList(currentDag, exec.task_states);
273
+ }
274
+
275
+ if (exec.status === 'completed') {
276
+ clearInterval(pollIv); pollIv = null;
277
+ document.getElementById('exec-status-badge').innerHTML =
278
+ '<span class="badge badge-success"><i class="fa-solid fa-check"></i> Completed</span>';
279
+ document.getElementById('btn-run-pipeline').disabled = false;
280
+ document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-rotate-right"></i> Run Again';
281
+ showToast(`Pipeline "${currentDag.name}" completed`, 'success');
282
+ } else if (exec.status === 'failed') {
283
+ clearInterval(pollIv); pollIv = null;
284
+ document.getElementById('exec-status-badge').innerHTML =
285
+ '<span class="badge badge-danger"><i class="fa-solid fa-xmark"></i> Failed</span>';
286
+ document.getElementById('btn-run-pipeline').disabled = false;
287
+ document.getElementById('btn-run-pipeline').innerHTML = '<i class="fa-solid fa-play"></i> Retry';
288
+ showToast('Pipeline failed: ' + (exec.error || 'unknown'), 'error');
289
+ }
290
+ }, 800);
291
+ }
292
+
293
+ function clearLog() { document.getElementById('exec-log').textContent = 'Cleared.'; }
294
+ </script>
295
+ {% endblock %}
templates/runs.html ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% set active_page = "experiments" %}
3
+
4
+ {% block title %}{{ experiment.name }} Runs{% endblock %}
5
+ {% block page_title %}<i class="fa-solid fa-flask" style="color:var(--accent-blue)"></i> {{ experiment.name }}{% endblock %}
6
+
7
+ {% block topnav_actions %}
8
+ <button class="btn btn-ghost btn-sm" id="btn-compare" onclick="compareSelected()" disabled>
9
+ <i class="fa-solid fa-code-compare"></i> Compare Selected
10
+ </button>
11
+ {% endblock %}
12
+
13
+ {% block content %}
14
+ <div class="flex-gap mb-20" style="margin-bottom:16px">
15
+ <a href="/experiments" class="btn btn-ghost btn-sm"><i class="fa-solid fa-arrow-left"></i> All Experiments</a>
16
+ <span class="badge badge-info">{{ runs | length }} runs</span>
17
+ </div>
18
+
19
+ <!-- Filter bar -->
20
+ <div class="card card-sm mb-20" style="margin-bottom:16px">
21
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
22
+ <div style="font-size:.82rem;color:var(--text-muted);margin-right:4px"><i class="fa-solid fa-filter"></i> Filter:</div>
23
+ <select class="form-select" id="filter-task" style="width:auto;padding:5px 28px 5px 10px;font-size:.82rem" onchange="filterRuns()">
24
+ <option value="">All tasks</option>
25
+ <option value="classification">Classification</option>
26
+ <option value="regression">Regression</option>
27
+ </select>
28
+ <select class="form-select" id="filter-cat" style="width:auto;padding:5px 28px 5px 10px;font-size:.82rem" onchange="filterRuns()">
29
+ <option value="">All categories</option>
30
+ {% set cats = runs | map(attribute='category') | unique | list %}
31
+ {% for cat in cats %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
32
+ </select>
33
+ <input type="text" class="form-control" id="filter-search" placeholder="Search algorithm…"
34
+ style="width:200px;padding:5px 10px;font-size:.82rem" oninput="filterRuns()">
35
+ <button class="btn btn-ghost btn-sm" onclick="clearFilters()"><i class="fa-solid fa-xmark"></i> Clear</button>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Runs table -->
40
+ <div class="card">
41
+ <div class="table-wrap">
42
+ <table id="runs-table">
43
+ <thead>
44
+ <tr>
45
+ <th style="width:32px"><input type="checkbox" id="chk-all" onchange="toggleAll(this)"></th>
46
+ <th>Run ID</th><th>Algorithm</th><th>Category</th><th>Task</th>
47
+ {% if runs and runs[0].task_type == 'classification' %}
48
+ <th>Accuracy</th><th>F1</th><th>Precision</th><th>Recall</th>
49
+ {% else %}
50
+ <th>R²</th><th>MAE</th><th>RMSE</th>
51
+ {% endif %}
52
+ <th>Duration</th><th>Started</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody id="runs-tbody">
56
+ {% for r in runs %}
57
+ <tr data-task="{{ r.task_type }}" data-cat="{{ r.category }}" data-alg="{{ r.algorithm | lower }}"
58
+ data-run="{{ r.run_id }}">
59
+ <td><input type="checkbox" class="run-chk" value="{{ r.run_id }}" onchange="updateCompareBtn()"></td>
60
+ <td><code style="font-size:.78rem;color:var(--accent-light)">{{ r.run_id_short }}</code></td>
61
+ <td><strong>{{ r.algorithm }}</strong></td>
62
+ <td><span class="badge badge-purple">{{ r.category }}</span></td>
63
+ <td><span class="badge {% if r.task_type == 'classification' %}badge-info{% else %}badge-warning{% endif %}">{{ r.task_type }}</span></td>
64
+ {% if r.task_type == 'classification' %}
65
+ {% set acc = r.metrics.get('accuracy', 0) %}
66
+ <td><span class="metric-val {% if acc >= 0.9 %}metric-good{% elif acc >= 0.7 %}metric-medium{% else %}metric-bad{% endif %}">{{ acc }}</span></td>
67
+ <td>{{ r.metrics.get('f1_score', '—') }}</td>
68
+ <td>{{ r.metrics.get('precision', '—') }}</td>
69
+ <td>{{ r.metrics.get('recall', '—') }}</td>
70
+ {% else %}
71
+ {% set r2 = r.metrics.get('r2_score', 0) %}
72
+ <td><span class="metric-val {% if r2 >= 0.8 %}metric-good{% elif r2 >= 0.5 %}metric-medium{% else %}metric-bad{% endif %}">{{ r2 }}</span></td>
73
+ <td>{{ r.metrics.get('mae', '—') }}</td>
74
+ <td>{{ r.metrics.get('rmse', '—') }}</td>
75
+ {% endif %}
76
+ <td>{{ r.duration }}s</td>
77
+ <td style="color:var(--text-muted);font-size:.8rem">{{ r.start_time }}</td>
78
+ </tr>
79
+ {% else %}
80
+ <tr><td colspan="12">
81
+ <div class="empty-state"><div class="empty-state-icon">📊</div><div class="empty-state-title">No runs in this experiment</div></div>
82
+ </td></tr>
83
+ {% endfor %}
84
+ </tbody>
85
+ </table>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Compare Modal -->
90
+ <div class="modal-overlay" id="compare-modal">
91
+ <div class="modal" style="width:min(820px,96vw)">
92
+ <div class="modal-header">
93
+ <div class="modal-title"><i class="fa-solid fa-code-compare" style="color:var(--accent)"></i> Run Comparison</div>
94
+ <button class="modal-close" onclick="document.getElementById('compare-modal').classList.remove('open')"><i class="fa-solid fa-xmark"></i></button>
95
+ </div>
96
+ <div id="compare-chart" style="height:320px"></div>
97
+ <div id="compare-table" style="margin-top:16px"></div>
98
+ </div>
99
+ </div>
100
+ {% endblock %}
101
+
102
+ {% block scripts %}
103
+ <script>
104
+ const ALL_RUNS = {{ runs | tojson }};
105
+
106
+ function filterRuns() {
107
+ const task = document.getElementById('filter-task').value.toLowerCase();
108
+ const cat = document.getElementById('filter-cat').value.toLowerCase();
109
+ const search = document.getElementById('filter-search').value.toLowerCase();
110
+ document.querySelectorAll('#runs-tbody tr[data-run]').forEach(row => {
111
+ const matchTask = !task || row.dataset.task === task;
112
+ const matchCat = !cat || row.dataset.cat.toLowerCase() === cat;
113
+ const matchAlg = !search || row.dataset.alg.includes(search);
114
+ row.style.display = (matchTask && matchCat && matchAlg) ? '' : 'none';
115
+ });
116
+ }
117
+ function clearFilters() {
118
+ document.getElementById('filter-task').value = '';
119
+ document.getElementById('filter-cat').value = '';
120
+ document.getElementById('filter-search').value = '';
121
+ filterRuns();
122
+ }
123
+ function toggleAll(cb) {
124
+ document.querySelectorAll('.run-chk').forEach(c => c.checked = cb.checked);
125
+ updateCompareBtn();
126
+ }
127
+ function updateCompareBtn() {
128
+ const count = document.querySelectorAll('.run-chk:checked').length;
129
+ document.getElementById('btn-compare').disabled = count < 2;
130
+ }
131
+
132
+ function compareSelected() {
133
+ const checked = [...document.querySelectorAll('.run-chk:checked')].map(c => c.value);
134
+ const selected = ALL_RUNS.filter(r => checked.includes(r.run_id));
135
+ if (selected.length < 2) return;
136
+
137
+ const isClass = selected[0].task_type === 'classification';
138
+ const metricKeys = isClass
139
+ ? ['accuracy','f1_score','precision','recall']
140
+ : ['r2_score','mae','mse','rmse'];
141
+
142
+ const colors = ['#8b5cf6','#3b82f6','#22c55e','#f59e0b','#ef4444','#06b6d4'];
143
+ const traces = metricKeys.map((mk, i) => ({
144
+ type: 'bar',
145
+ name: mk,
146
+ x: selected.map(r => r.algorithm),
147
+ y: selected.map(r => r.metrics[mk] || 0),
148
+ marker: { color: colors[i] },
149
+ hovertemplate: `<b>%{x}</b><br>${mk}: %{y:.4f}<extra></extra>`,
150
+ }));
151
+
152
+ const bg2 = '#161b22', border = '#30363d', txt = '#8b949e';
153
+ Plotly.newPlot('compare-chart', traces, {
154
+ paper_bgcolor: bg2, plot_bgcolor: bg2, barmode: 'group',
155
+ margin: { t:12, b:60, l:48, r:12 },
156
+ xaxis: { color: txt, tickfont:{size:10}, gridcolor: border },
157
+ yaxis: { color: txt, tickfont:{size:10}, gridcolor: border },
158
+ legend: { font:{color: txt, size:11}, bgcolor:'transparent' },
159
+ }, { responsive:true, displayModeBar:false });
160
+
161
+ // Table
162
+ let html = `<div class="table-wrap"><table><thead><tr><th>Algorithm</th><th>Dataset</th>`;
163
+ metricKeys.forEach(mk => html += `<th>${mk}</th>`);
164
+ html += '</tr></thead><tbody>';
165
+ selected.forEach(r => {
166
+ html += `<tr><td><strong>${r.algorithm}</strong></td><td>${r.dataset}</td>`;
167
+ metricKeys.forEach(mk => {
168
+ const v = r.metrics[mk];
169
+ html += `<td>${v !== undefined ? v : '—'}</td>`;
170
+ });
171
+ html += '</tr>';
172
+ });
173
+ html += '</tbody></table></div>';
174
+ document.getElementById('compare-table').innerHTML = html;
175
+
176
+ document.getElementById('compare-modal').classList.add('open');
177
+ }
178
+ </script>
179
+ {% endblock %}