diff --git a/.gitignore b/.gitignore
index ef1c844..eb9a66d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,4 +28,5 @@ scaler.pkl
instance/
shit/
*.db
-.vscode/
\ No newline at end of file
+.vscode/
+dataset/sample.csv
\ No newline at end of file
diff --git a/app/app.py b/app/app.py
index b3efa14..4d71380 100644
--- a/app/app.py
+++ b/app/app.py
@@ -1,9 +1,13 @@
from .schema.validation import UserInput
-from fastapi import FastAPI, Request , Depends
+from fastapi import FastAPI, Request , Depends, UploadFile
from fastapi.responses import JSONResponse, FileResponse
+from fastapi.exceptions import HTTPException
from fastapi.staticfiles import StaticFiles
from typing import List, Tuple
import numpy as np
+import pandas as pd
+import shutil
+import uuid
from sklearn.pipeline import Pipeline
import joblib
from contextlib import asynccontextmanager
@@ -13,8 +17,11 @@
# paths to the pickle files (relative to project root)
MODEL_PATH = Path(__file__).parent.parent / "models" / "estimator.pkl"
NAMES_PATH = Path(__file__).parent.parent / "models" / "names.pkl"
+# Directory for user-uploaded files
+UPLOAD_DIR = Path("app","uploads")
+UPLOAD_DIR.mkdir(exist_ok=True)
-# if pickle files are not found, this function will train the model
+# if pickle files are not found, this helper will train the model
def ensure_models():
print("🔍 Checking for existing model files...")
if not MODEL_PATH.is_file() or not NAMES_PATH.is_file():
@@ -30,6 +37,24 @@ def ensure_models():
print("✅ Model and feature names loaded successfully.")
return model, names
+# helper for validating the user-uploaded .csv file
+def validate_csv(payload:pd.DataFrame, expected_columns:List)-> pd.DataFrame:
+ expected_columns = [r for r in expected_columns if r != "alert"]
+ if payload.columns.tolist() != expected_columns:
+ raise HTTPException(
+ status_code=422,
+ detail="Uploaded csv file does not match the expected column configuration"
+ )
+
+ try:
+ df = payload.astype(float)
+ except Exception:
+ raise HTTPException(
+ status_code=422,
+ detail="Value in the uploaded csv file must be numeric (float-compatible)"
+ )
+ return df
+
@asynccontextmanager
async def lifespan(app:FastAPI):
pipe,feat_names = ensure_models()
@@ -92,3 +117,38 @@ def predict_things(value:UserInput,dep:Tuple[Pipeline,np.ndarray] = Depends(get_
return JSONResponse(
status_code=201, content=msg
)
+
+@app.post("/predict/batch",status_code=201)
+def predict_things_in_batch(payload:UploadFile,dep:Tuple[Pipeline,np.ndarray] = Depends(get_things)):
+ pipe,feat_names = dep
+ feat_names:List[str] = feat_names.tolist()
+
+ # Receive and validate incoming upload
+ valid_exts = [".csv"]
+ extension = Path(payload.filename).suffix
+ if extension not in valid_exts:
+ raise HTTPException(
+ status_code=422, detail=f"Only `.csv` files are accepted as input, got {extension} instead"
+ )
+ df_prev = pd.read_csv(payload.file)
+ df: pd.DataFrame = validate_csv(df_prev,feat_names)
+
+ # Run Predictions
+ user_inp = df.to_numpy()
+ pred_label:List[float] = pipe.predict(user_inp).tolist()
+ pred_proba:List[List[float]] = pipe.predict_proba(user_inp).tolist()
+
+ # Postprocessing
+ label_map = {0: "green", 1: "orange", 2: "red", 3: "yellow"}
+ pred_label:List[str] = [label_map.get(r) for r in pred_label]
+ pred_proba = [[round(proba,3) for proba in sample] for sample in pred_proba]
+
+ # Final Output
+ msg = {
+ "message": "batch prediction successful",
+ "prediction": pred_label,
+ "probabilities": pred_proba
+ }
+ return JSONResponse(
+ status_code=201, content=msg
+ )
\ No newline at end of file
diff --git a/app/static/script.js b/app/static/script.js
index e82fe9e..e4ed256 100644
--- a/app/static/script.js
+++ b/app/static/script.js
@@ -161,3 +161,228 @@ if (clearBtn && form) {
}
});
}
+
+// File input display
+const csvFileInput = document.getElementById('csvfile');
+const fileNameDisplay = document.getElementById('fileName');
+if (csvFileInput && fileNameDisplay) {
+ csvFileInput.addEventListener('change', (e) => {
+ if (e.target.files.length > 0) {
+ const file = e.target.files[0];
+ if (file.size > 5 * 1024 * 1024) {
+ showToast('File size exceeds 5MB limit. Please upload a smaller file.', 'error');
+ file.value = '';
+ fileNameDisplay.textContent = 'Choose CSV file...';
+ return;
+ }
+ fileNameDisplay.textContent = file.name;
+ }
+ });
+}
+
+// Batch Prediction Form
+const batchForm = document.getElementById('batchForm');
+const batchBtn = document.getElementById('batchPredictBtn');
+const batchLoader = document.getElementById('batchLoader');
+
+if (batchForm && batchBtn) {
+ batchForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const fileInput = document.getElementById('csvfile');
+ if (!fileInput || !fileInput.files.length) {
+ showToast('Please select a CSV file', 'error');
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('payload', fileInput.files[0]);
+
+ const btnText = batchBtn.querySelector('.btn-text');
+ batchBtn.disabled = true;
+ if (btnText) {
+ btnText.innerHTML = ' Processing...';
+ }
+ if (batchLoader) {
+ batchLoader.style.display = 'inline-block';
+ }
+
+ try {
+ const response = await fetch('/predict/batch', {
+ method: 'POST',
+ body: formData
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ showToast('Batch prediction successful!', 'success');
+ displayBatchResults(data);
+ } else if (response.status === 422) {
+ if (data.detail) {
+ showToast(data.detail, 'error');
+ } else if (Array.isArray(data.detail)) {
+ data.detail.forEach(err => {
+ const field = err.loc ? err.loc[err.loc.length - 1] : 'validation';
+ showToast(`Validation Error (${field}): ${err.msg}`, 'error');
+ });
+ } else {
+ showToast('Validation Error', 'error');
+ }
+ } else {
+ showToast(data.message || 'An error occurred', 'error');
+ }
+ } catch (error) {
+ showToast('Network error or server down', 'error');
+ } finally {
+ batchBtn.disabled = false;
+ if (btnText) {
+ btnText.innerHTML = ' Run Batch Prediction';
+ }
+ if (batchLoader) {
+ batchLoader.style.display = 'none';
+ }
+ }
+ });
+}
+
+function displayBatchResults(data) {
+ const container = document.getElementById('batch-result-container');
+ if (!container) return;
+
+ const predictions = data.prediction;
+ const probabilities = data.probabilities;
+ const count = predictions.length;
+
+ const counts = { green: 0, orange: 0, red: 0, yellow: 0 };
+ predictions.forEach(p => {
+ if (counts[p] !== undefined) counts[p]++;
+ });
+
+ let tableRows = '';
+ const predictionDetails = { green: [], orange: [], red: [], yellow: [] };
+ predictions.forEach((pred, i) => {
+ const probs = probabilities[i];
+ const maxProb = Math.max(...probs) * 100;
+ predictionDetails[pred].push(i + 1);
+ tableRows += `
+
+ | ${i + 1} |
+ ${pred.toUpperCase()} |
+ ${maxProb.toFixed(1)}% |
+
+ `;
+ });
+
+ container.innerHTML = `
+
+
+
+
+
+
${count}
+
Total Predictions
+
+
+
${counts.green}
+
Green
+
+
+
${counts.orange}
+
Orange
+
+
+
+
${counts.yellow}
+
Yellow
+
+
+
+
+
+ | # |
+ Alert |
+ Confidence |
+
+
+
+ ${tableRows}
+
+
+ `;
+
+ const ctx = document.getElementById('batchPieChart');
+ if (ctx) {
+ new Chart(ctx, {
+ type: 'pie',
+ data: {
+ labels: ['Green', 'Orange', 'Red', 'Yellow'],
+ datasets: [{
+ data: [counts.green, counts.orange, counts.red, counts.yellow],
+ backgroundColor: ['#22c55e', '#f97316', '#ef4444', '#eab308'],
+ borderColor: ['#22c55e', '#f97316', '#ef4444', '#eab308'],
+ borderWidth: 1
+ }]
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ color: '#8b949e'
+ }
+ },
+ tooltip: {
+ callbacks: {
+ label: function(context) {
+ const label = context.label || '';
+ const value = context.raw || 0;
+ const predictionList = predictionDetails[label.toLowerCase()];
+ return `${label}: ${value} predictions`;
+ },
+ afterLabel: function(context) {
+ const label = context.label || '';
+ const predictionList = predictionDetails[label.toLowerCase()];
+ if (predictionList && predictionList.length > 0) {
+ return `Rows: ${predictionList.join(', ')}`;
+ }
+ return '';
+ }
+ }
+ }
+ }
+ }
+ });
+ }
+}
+
+// Clear batch button
+const clearBatchBtn = document.getElementById('clearBatchBtn');
+if (clearBatchBtn && batchForm) {
+ clearBatchBtn.addEventListener('click', () => {
+ const fileInput = document.getElementById('csvfile');
+ if (fileInput) fileInput.value = '';
+
+ const fileNameDisplay = document.getElementById('fileName');
+ if (fileNameDisplay) fileNameDisplay.textContent = 'Choose CSV file...';
+
+ const batchContainer = document.getElementById('batch-result-container');
+ if (batchContainer) {
+ batchContainer.innerHTML = `
+
+
+
+
+
Upload a CSV file and click "Run Batch Prediction"
+
+
+
+
+ `;
+ }
+ });
+}
diff --git a/app/static/style.css b/app/static/style.css
index 4cbe0a9..15d0d34 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -166,6 +166,7 @@ body::before {
.tab-panel {
display: none;
animation: fadeIn 0.3s ease;
+ min-height: calc(100vh - 200px);
}
.tab-panel.active {
@@ -832,3 +833,153 @@ body::before {
max-width: 100%;
}
}
+
+/* File Upload Styles */
+.file-upload-wrapper {
+ position: relative;
+}
+
+.file-input {
+ position: absolute;
+ width: 0.1px;
+ height: 0.1px;
+ opacity: 0;
+ overflow: hidden;
+ z-index: -1;
+}
+
+.file-label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 1rem 1.5rem;
+ background: var(--bg-input);
+ border: 2px dashed var(--border);
+ border-radius: 10px;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.file-label:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.file-input:focus + .file-label {
+ border-color: var(--accent);
+ box-shadow: var(--shadow-glow);
+}
+
+.file-label i {
+ font-size: 1.2rem;
+}
+
+/* Batch Results Table */
+.batch-results-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 1rem;
+ font-size: 0.85rem;
+}
+
+.batch-results-table th,
+.batch-results-table td {
+ padding: 0.75rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+
+.batch-results-table th {
+ color: var(--text-secondary);
+ font-weight: 600;
+ background: var(--bg-tertiary);
+}
+
+.batch-results-table td {
+ color: var(--text-primary);
+}
+
+.batch-results-table tr:hover td {
+ background: var(--bg-tertiary);
+}
+
+.batch-results-table .alert-cell {
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.batch-results-table .alert-cell.green { color: var(--green); }
+.batch-results-table .alert-cell.orange { color: var(--orange); }
+.batch-results-table .alert-cell.red { color: var(--red); }
+.batch-results-table .alert-cell.yellow { color: var(--yellow); }
+
+.batch-summary {
+ display: flex;
+ gap: 1rem;
+ margin-top: 1rem;
+ flex-wrap: wrap;
+}
+
+.batch-stat {
+ flex: 1;
+ min-width: 80px;
+ padding: 0.75rem;
+ background: var(--bg-tertiary);
+ border-radius: 8px;
+ text-align: center;
+}
+
+.batch-stat-value {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--accent);
+}
+
+.batch-stat-label {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ margin-top: 0.25rem;
+}
+
+.chart-container {
+ margin: 1.5rem 0;
+ max-width: 400px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.batch-help {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ background: var(--bg-tertiary);
+ border-radius: 10px;
+ border-left: 3px solid var(--accent);
+}
+
+.batch-help h4 {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--accent);
+ margin-bottom: 0.5rem;
+}
+
+.batch-help p {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+}
+
+.batch-columns {
+ padding: 0.5rem;
+ background: var(--bg-input);
+ border-radius: 5px;
+ text-align: center;
+}
+
+.batch-columns code {
+ color: var(--accent);
+ font-family: 'JetBrains Mono', monospace;
+}
diff --git a/app/templates/index.html b/app/templates/index.html
index 1ed72f5..af28527 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -7,6 +7,7 @@
+
@@ -24,6 +25,10 @@
Predict
+