HarshitaSuri commited on
Commit
50c55fd
·
verified ·
1 Parent(s): 42beb12

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +319 -150
app.py CHANGED
@@ -1,171 +1,340 @@
1
- # app.py
 
 
 
 
2
  import pandas as pd
3
  import numpy as np
4
  import gradio as gr
5
- from sklearn.ensemble import RandomForestClassifier
6
- import joblib
7
- import os
8
-
9
- # ---------------- CONFIG ----------------
10
- MODEL_PATH = "rf_balanced_model.pkl"
11
- REPORT_PATH = "risk_report.csv"
12
-
13
- # ---------------- PIPELINE HELPERS ----------------
14
- def preprocess(df):
15
- """Clean Fees Paid column and create proxy Fail label"""
16
- data = df.copy()
17
- fees_map = {"yes": 1, "y": 1, "paid": 1, "true": 1,
18
- "no": 0, "n": 0, "false": 0}
19
- if "Fees Paid" in data.columns:
20
- data["Fees_Paid_Flag"] = (
21
- data["Fees Paid"].astype(str).str.strip().str.lower().map(fees_map).fillna(0).astype(int)
22
- )
23
- return data
24
 
25
- def attendance_risk(att):
26
- if att < 60: return 1.0
27
- elif att < 70: return 0.7
28
- elif att < 80: return 0.4
29
- elif att < 85: return 0.2
30
- else: return 0.0
31
-
32
- def combine_scores(ml_prob, att_risk, mode="max", ml_w=0.6, att_w=0.4):
33
- if mode == "max":
34
- return np.maximum(ml_prob, att_risk)
35
- else:
36
- return (ml_prob * ml_w) + (att_risk * att_w)
37
 
38
- def label_from_score(score):
39
- if score >= 0.6: return "High"
40
- elif score >= 0.4: return "Medium"
41
- else: return "Low"
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- def explain_row(row):
44
- reasons = []
45
- if row.get("Attendance_Risk", 0) >= 0.7:
46
- reasons.append(f"Low attendance ({row['Attendance (%)']}%)")
47
- if row.get("ML_Fail_Prob", 0) >= 0.6:
48
- reasons.append(f"ML_prob high ({row['ML_Fail_Prob']:.2f})")
49
- if row.get("Fees_Paid_Flag", 1) == 0:
50
- reasons.append("Fees unpaid")
51
- return "; ".join(reasons) if reasons else "None"
52
-
53
- # ---------------- MERGE SOURCES ----------------
54
  def normalise_colnames(df):
 
 
55
  df = df.copy()
56
- df.columns = [c.strip().title().replace("Roll No", "Roll Number") for c in df.columns]
57
- return df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  def merge_sources(att_df, test_df, fee_df):
60
- att = normalise_colnames(att_df) if att_df is not None else None
61
- test = normalise_colnames(test_df) if test_df is not None else None
62
- fee = normalise_colnames(fee_df) if fee_df is not None else None
63
-
64
- key = "Roll Number" if (
65
- (att is not None and "Roll Number" in att.columns) or
66
- (test is not None and "Roll Number" in test.columns) or
67
- (fee is not None and "Roll Number" in fee.columns)
68
- ) else "Name"
69
-
70
- dfs = [d for d in [att, test, fee] if d is not None]
 
 
 
 
 
 
 
71
  if not dfs:
72
  return pd.DataFrame()
73
-
74
  merged = dfs[0]
75
  for d in dfs[1:]:
76
- merged = pd.merge(merged, d, on=key, how="outer", suffixes=("", "_dup"))
77
- merged = merged.loc[:, ~merged.columns.duplicated()]
78
-
79
  return merged
80
 
81
- # ---------------- PREDICTION FUNCTIONS ----------------
82
- def load_model():
83
- if os.path.exists(MODEL_PATH):
84
- return joblib.load(MODEL_PATH)
85
- return RandomForestClassifier().fit([[80, 1]], [0]) # dummy
86
-
87
- model = load_model()
88
-
89
- def predict_single(name, roll, attendance, marks, fees_paid):
90
- df = pd.DataFrame([{
91
- "Name": name,
92
- "Roll Number": roll,
93
- "Attendance (%)": attendance,
94
- "Marks (%)": marks,
95
- "Fees Paid": fees_paid
96
- }])
97
- df = preprocess(df)
98
- X = df[["Attendance (%)", "Fees_Paid_Flag"]].values
99
- prob = model.predict_proba(X)[:,1][0]
100
- att_r = attendance_risk(attendance)
101
- combined = combine_scores(prob, att_r)
102
- label = label_from_score(combined)
103
- reason = explain_row({
104
- "Attendance_Risk": att_r,
105
- "Attendance (%)": attendance,
106
- "ML_Fail_Prob": prob,
107
- "Fees_Paid_Flag": df["Fees_Paid_Flag"].iloc[0]
108
- })
109
- return f"Risk: {label}\nReasons: {reason}"
110
-
111
- def process_and_report(att_path, test_path, fee_path):
112
- att_df = pd.read_excel(att_path.name) if att_path else None
113
- test_df = pd.read_excel(test_path.name) if test_path else None
114
- fee_df = pd.read_excel(fee_path.name) if fee_path else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  merged = merge_sources(att_df, test_df, fee_df)
117
- merged = preprocess(merged)
118
-
119
- if "Attendance (%)" not in merged or "Fees_Paid_Flag" not in merged:
120
- return pd.DataFrame(), None
121
-
122
- X = merged[["Attendance (%)", "Fees_Paid_Flag"]].values
123
- merged["ML_Fail_Prob"] = model.predict_proba(X)[:,1]
124
- merged["Attendance_Risk"] = merged["Attendance (%)"].apply(attendance_risk)
125
- merged["Combined_Risk_Score"] = combine_scores(
126
- merged["ML_Fail_Prob"].values,
127
- merged["Attendance_Risk"].values
128
- )
129
- merged["Risk_Label"] = merged["Combined_Risk_Score"].apply(label_from_score)
130
- merged["Reason"] = merged.apply(explain_row, axis=1)
131
-
132
- cols = ["Name", "Roll Number", "Attendance (%)", "Marks (%)", "Fees Paid",
133
- "ML_Fail_Prob", "Attendance_Risk", "Combined_Risk_Score", "Risk_Label", "Reason"]
134
-
135
- merged.to_csv(REPORT_PATH, index=False)
136
- return merged[cols], REPORT_PATH
137
-
138
- # ---------------- GRADIO APP ----------------
139
- with gr.Blocks(title="Student Dropout Prediction") as demo:
140
- gr.Markdown("## 🎓 Student Dropout Prediction Dashboard")
141
-
142
- with gr.Tab("Single Student"):
143
- name = gr.Textbox(label="Name")
144
- roll = gr.Textbox(label="Roll Number")
145
- att = gr.Slider(0, 100, value=75, label="Attendance (%)")
146
- marks = gr.Slider(0, 100, value=60, label="Marks (%)")
147
- fees = gr.Dropdown(["Yes", "No"], label="Fees Paid?")
148
- predict_btn = gr.Button("Predict Risk")
149
- result = gr.Textbox(label="Prediction Result")
150
- predict_btn.click(
151
- fn=predict_single,
152
- inputs=[name, roll, att, marks, fees],
153
- outputs=result
154
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
- with gr.Tab("Bulk Dashboard"):
157
- upload_att = gr.File(label="Upload Attendance", type="file")
158
- upload_test = gr.File(label="Upload Test Results", type="file")
159
- upload_fee = gr.File(label="Upload Fees", type="file")
160
- run_btn = gr.Button("Run Analysis")
161
- output_table = gr.Dataframe(label="Risk Report", wrap=True)
162
- output_file = gr.File(label="Download CSV Report")
163
-
164
- run_btn.click(
165
- fn=process_and_report,
166
- inputs=[upload_att, upload_test, upload_fee],
167
- outputs=[output_table, output_file]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
- if __name__ == "__main__":
171
- demo.launch()
 
1
+ # app.py (improved)
2
+ # Requirements: pandas, numpy, gradio, matplotlib, openpyxl
3
+
4
+ import os, io, math
5
+ from datetime import datetime
6
  import pandas as pd
7
  import numpy as np
8
  import gradio as gr
9
+ import matplotlib.pyplot as plt
10
+ import smtplib
11
+ from email.message import EmailMessage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
+ # ---------------- config defaults ----------------
14
+ DEFAULT_ATTENDANCE_THRESHOLD = 75.0
15
+ DEFAULT_ATTENDANCE_HIGH_RISK = 60.0
16
+ DEFAULT_TEST_DECLINE_PERCENT = 10.0
17
+ DEFAULT_MAX_ATTEMPTS = 3
18
+ DEFAULT_HIGH_CUT = 0.6
19
+ DEFAULT_MED_CUT = 0.4
 
 
 
 
 
20
 
21
+ # ---------------- utils (kept/cleaned) ----------------
22
+ def load_df_from_file(f):
23
+ if f is None:
24
+ return None
25
+ try:
26
+ name = getattr(f, "name", "")
27
+ if str(name).lower().endswith((".xls", ".xlsx")):
28
+ return pd.read_excel(f)
29
+ else:
30
+ return pd.read_csv(f)
31
+ except Exception:
32
+ try:
33
+ f.seek(0)
34
+ return pd.read_csv(f)
35
+ except Exception as e:
36
+ raise RuntimeError(f"Could not read file: {e}")
37
 
 
 
 
 
 
 
 
 
 
 
 
38
  def normalise_colnames(df):
39
+ if df is None:
40
+ return None
41
  df = df.copy()
42
+ df.columns = [c.strip() for c in df.columns]
43
+ colmap = {}
44
+ for c in df.columns:
45
+ lc = c.lower().replace(" ", "").replace("_", "")
46
+ if "roll" in lc and ("no" in lc or "number" in lc or "id" in lc):
47
+ colmap[c] = "Roll Number"
48
+ elif "name" == lc or "studentname" == lc or lc == "student":
49
+ colmap[c] = "Name"
50
+ elif "attendance" in lc:
51
+ colmap[c] = "Attendance (%)"
52
+ elif "mark" in lc or "score" in lc or "percentage" in lc:
53
+ colmap[c] = "Marks (%)"
54
+ elif "fee" in lc:
55
+ colmap[c] = "Fees Paid"
56
+ elif "attempt" in lc:
57
+ colmap[c] = "Attempts"
58
+ elif any(k in lc for k in ("test","exam","mid","quiz","assessment")):
59
+ colmap[c] = c # preserve test columns
60
+ return df.rename(columns=colmap)
61
 
62
  def merge_sources(att_df, test_df, fee_df):
63
+ att = normalise_colnames(att_df)
64
+ test = normalise_colnames(test_df)
65
+ fee = normalise_colnames(fee_df)
66
+ # choose merge key
67
+ key = None
68
+ for d in (att, test, fee):
69
+ if d is not None and 'Roll Number' in d.columns:
70
+ key = 'Roll Number'
71
+ break
72
+ if key is None:
73
+ key = 'Name'
74
+ dfs = []
75
+ for d in (att, test, fee):
76
+ if d is None:
77
+ continue
78
+ if key not in d.columns:
79
+ d[key] = np.nan
80
+ dfs.append(d)
81
  if not dfs:
82
  return pd.DataFrame()
 
83
  merged = dfs[0]
84
  for d in dfs[1:]:
85
+ merged = pd.merge(merged, d, on=key, how='outer', suffixes=(False, False))
 
 
86
  return merged
87
 
88
+ def parse_test_scores(df):
89
+ if df is None:
90
+ return []
91
+ test_cols = [c for c in df.columns if any(k in c.lower() for k in ("test","exam","mid","quiz","assessment","score")) and c not in ["Marks (%)", "Attendance (%)"]]
92
+ if "Marks (%)" in df.columns:
93
+ test_cols.append("Marks (%)")
94
+ return test_cols
95
+
96
+ def attendance_risk(att, threshold, high_risk):
97
+ try:
98
+ att = float(att)
99
+ except:
100
+ return 0.0
101
+ if math.isnan(att):
102
+ return 0.0
103
+ if att < high_risk:
104
+ return 1.0
105
+ if att < threshold:
106
+ span = max(1.0, threshold - high_risk)
107
+ return (threshold - att) / span * 0.9
108
+ return 0.0
109
+
110
+ def test_decline_risk(row, test_cols, decline_pct):
111
+ scores = []
112
+ for c in test_cols:
113
+ v = row.get(c, None)
114
+ try:
115
+ scores.append(float(v))
116
+ except:
117
+ continue
118
+ if len(scores) < 2:
119
+ return 0.0
120
+ latest, prev = scores[-1], scores[-2]
121
+ if prev == 0:
122
+ return 0.0
123
+ drop = (prev - latest) / prev * 100.0
124
+ if drop >= decline_pct:
125
+ return min(1.0, drop / (2 * decline_pct))
126
+ return 0.0
127
+
128
+ def attempts_risk(attempts_val, max_attempts):
129
+ try:
130
+ a = int(attempts_val)
131
+ except:
132
+ return 0.0
133
+ if a >= max_attempts:
134
+ return 1.0
135
+ if a == 0:
136
+ return 0.0
137
+ return a / max(1, max_attempts)
138
+
139
+ def combine_signals(signals, mode="max", weights=None):
140
+ arr = np.array(signals)
141
+ if mode == "max":
142
+ return np.max(arr, axis=0)
143
+ elif mode == "weighted" and weights is not None:
144
+ w = np.array(weights)
145
+ w = w / w.sum()
146
+ return (arr * w[:, None]).sum(axis=0)
147
+ return np.mean(arr, axis=0)
148
 
149
+ def label_from_score(score, high_cut=DEFAULT_HIGH_CUT, med_cut=DEFAULT_MED_CUT):
150
+ if score >= high_cut:
151
+ return "High"
152
+ elif score >= med_cut:
153
+ return "Medium"
154
+ else:
155
+ return "Low"
156
+
157
+ def send_email_notification(to_emails, subject, body, smtp_host, smtp_port, smtp_user, smtp_pass):
158
+ try:
159
+ msg = EmailMessage()
160
+ msg["Subject"] = subject
161
+ msg["From"] = smtp_user
162
+ msg["To"] = ", ".join(to_emails)
163
+ msg.set_content(body)
164
+ with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as s:
165
+ s.starttls()
166
+ s.login(smtp_user, smtp_pass)
167
+ s.send_message(msg)
168
+ return True, "Sent"
169
+ except Exception as e:
170
+ return False, str(e)
171
+
172
+ # ---------------- main processing ----------------
173
+ def process_and_report(att_file, test_file, fee_file,
174
+ attendance_threshold=DEFAULT_ATTENDANCE_THRESHOLD,
175
+ attendance_high_risk=DEFAULT_ATTENDANCE_HIGH_RISK,
176
+ decline_pct=DEFAULT_TEST_DECLINE_PERCENT,
177
+ max_attempts=DEFAULT_MAX_ATTEMPTS,
178
+ combine_mode="max",
179
+ weight_att=0.5, weight_test=0.3, weight_attempt=0.2,
180
+ notify=False, notify_threshold="High", notify_emails="", smtp_overrides=None):
181
+ # load files
182
+ try:
183
+ att_df = load_df_from_file(att_file) if att_file else None
184
+ test_df = load_df_from_file(test_file) if test_file else None
185
+ fee_df = load_df_from_file(fee_file) if fee_file else None
186
+ except Exception as e:
187
+ return pd.DataFrame(), {"error": str(e)}
188
  merged = merge_sources(att_df, test_df, fee_df)
189
+ if merged.empty:
190
+ return pd.DataFrame(), {"error": "No data loaded. Upload at least one sheet."}
191
+ merged = merged.reset_index(drop=True)
192
+ merged['Attendance (%)'] = merged.get('Attendance (%)', np.nan)
193
+ merged['Fees Paid'] = merged.get('Fees Paid', merged.get('FeesPaid', merged.get('Fees', np.nan)))
194
+ test_cols = parse_test_scores(merged)
195
+ n = len(merged)
196
+ att_risks = np.zeros(n); test_risks = np.zeros(n); attempt_risks = np.zeros(n)
197
+ for i, row in merged.iterrows():
198
+ att_risks[i] = attendance_risk(row.get('Attendance (%)', np.nan), attendance_threshold, attendance_high_risk)
199
+ test_risks[i] = test_decline_risk(row, test_cols, decline_pct)
200
+ attempt_risks[i] = attempts_risk(row.get('Attempts', 0), max_attempts)
201
+ merged['Attendance_Risk'] = att_risks
202
+ merged['Test_Decline_Risk'] = test_risks
203
+ merged['Attempts_Risk'] = attempt_risks
204
+
205
+ signals = [att_risks, test_risks, attempt_risks]
206
+ if combine_mode == "max":
207
+ combined = combine_signals(signals, mode="max")
208
+ else:
209
+ weights = [weight_att, weight_test, weight_attempt]
210
+ combined = combine_signals(signals, mode="weighted", weights=weights)
211
+ merged['Combined_Risk_Score'] = combined
212
+ merged['Risk_Label'] = merged['Combined_Risk_Score'].apply(label_from_score)
213
+
214
+ reasons = []
215
+ for i, row in merged.iterrows():
216
+ r = []
217
+ if row['Attendance_Risk'] >= 0.7:
218
+ r.append(f"Low attendance ({row.get('Attendance (%)', 'NA')}%)")
219
+ elif row['Attendance_Risk'] > 0:
220
+ r.append(f"Attendance below threshold ({row.get('Attendance (%)', 'NA')}%)")
221
+ if row['Test_Decline_Risk'] > 0:
222
+ r.append("Recent test decline")
223
+ if row['Attempts_Risk'] > 0:
224
+ r.append(f"High attempts ({row.get('Attempts','NA')})")
225
+ if str(row.get('Fees Paid','')).strip().lower() in ("no","n","false","0","unpaid"):
226
+ r.append("Fees unpaid")
227
+ reasons.append("; ".join(r) if r else "None")
228
+ merged['Flag_Reason'] = reasons
229
+ summary = merged['Risk_Label'].value_counts().to_dict()
230
+
231
+ notif_result = None
232
+ if notify:
233
+ notify_mask = merged['Risk_Label'] == notify_threshold
234
+ notify_rows = merged[notify_mask]
235
+ smtp = smtp_overrides or {
236
+ "host": os.environ.get("SMTP_HOST"),
237
+ "port": int(os.environ.get("SMTP_PORT", 587)),
238
+ "user": os.environ.get("SMTP_USER"),
239
+ "pass": os.environ.get("SMTP_PASS")
240
+ }
241
+ if not notify_rows.empty and smtp["user"] and smtp["pass"] and smtp["host"]:
242
+ emails = [e.strip() for e in str(notify_emails).split(",") if e.strip()]
243
+ subject = f"[Early Warning] {len(notify_rows)} students flagged {notify_threshold}"
244
+ body_lines = ["Students flagged:\n"]
245
+ for _, r in notify_rows.iterrows():
246
+ body_lines.append(f"{r.get('Name','')}\t{r.get('Roll Number','')}\t{r.get('Risk_Label')}\tReasons: {r.get('Flag_Reason')}")
247
+ ok, msg = send_email_notification(emails, subject, "\n".join(body_lines), smtp["host"], smtp["port"], smtp["user"], smtp["pass"])
248
+ notif_result = (ok, msg)
249
+ else:
250
+ notif_result = (False, "Notification missing SMTP settings or no flagged students")
251
+ return merged, {"summary": summary, "notify_result": notif_result}
252
 
253
+ # --------------- UI ----------------
254
+ def build_plot(df):
255
+ if df.empty:
256
+ return None
257
+ counts = df['Risk_Label'].value_counts().reindex(['High','Medium','Low']).fillna(0)
258
+ fig, ax = plt.subplots(figsize=(6,3))
259
+ ax.bar(counts.index, counts.values)
260
+ ax.set_title("Risk distribution")
261
+ ax.set_ylabel("Number of students")
262
+ plt.tight_layout()
263
+ return fig
264
+
265
+ def df_to_colored_html(df):
266
+ if df.empty:
267
+ return "<p>No data</p>"
268
+ df = df.copy()
269
+ # show a few important cols if available
270
+ cols = [c for c in ['Roll Number','Name','Attendance (%)','Marks (%)','Fees Paid','Combined_Risk_Score','Risk_Label','Flag_Reason'] if c in df.columns]
271
+ df = df[cols]
272
+ def row_style(r):
273
+ label = r.get("Risk_Label","Low")
274
+ if label=="High":
275
+ return 'background:#ffcccc' # pale red
276
+ if label=="Medium":
277
+ return 'background:#fff2cc' # pale orange
278
+ return ''
279
+ styled = df.style.apply(lambda r: [row_style(r)]*len(r), axis=1)
280
+ return styled.hide_index().to_html()
281
+
282
+ with gr.Blocks() as demo:
283
+ gr.Markdown("## Student Early-Warning Dashboard")
284
+ with gr.Row():
285
+ with gr.Column():
286
+ att_file = gr.File(label="Attendance", file_types=['.csv','.xlsx'])
287
+ test_file = gr.File(label="Tests", file_types=['.csv','.xlsx'])
288
+ fee_file = gr.File(label="Fees", file_types=['.csv','.xlsx'])
289
+ run_btn = gr.Button("Run")
290
+ download = gr.File()
291
+ with gr.Column():
292
+ att_thresh = gr.Slider(50,100,value=DEFAULT_ATTENDANCE_THRESHOLD,label="Attendance threshold")
293
+ att_high = gr.Slider(20,80,value=DEFAULT_ATTENDANCE_HIGH_RISK,label="High-risk attendance cutoff")
294
+ decline_pct = gr.Slider(1,50,value=DEFAULT_TEST_DECLINE_PERCENT,label="Test decline % to flag")
295
+ max_attempts = gr.Number(value=DEFAULT_MAX_ATTEMPTS,label="Max attempts before flag",precision=0)
296
+ combine_mode = gr.Radio(["max","weighted"],value="max",label="Combine mode")
297
+ weight_att = gr.Slider(0.0,1.0,value=0.5,label="Weight: attendance (only for weighted)")
298
+ weight_test = gr.Slider(0.0,1.0,value=0.3,label="Weight: test decline")
299
+ weight_attempt = gr.Slider(0.0,1.0,value=0.2,label="Weight: attempts")
300
+ notify = gr.Checkbox(False,label="Send email notifications to mentors (uses SMTP env vars or enter below)")
301
+ notify_emails = gr.Textbox(label="Notify emails (comma-separated)")
302
+ smtp_host = gr.Textbox(label="SMTP host (optional override)")
303
+ smtp_port = gr.Number(value=587,label="SMTP port (optional override)",precision=0)
304
+ smtp_user = gr.Textbox(label="SMTP user (optional override)")
305
+ smtp_pass = gr.Textbox(type="password", label="SMTP pass (optional override)")
306
+ result_html = gr.HTML()
307
+ risk_plot = gr.Plot()
308
+ summary_box = gr.Textbox()
309
+
310
+ def on_run(att_file_, test_file_, fee_file_,
311
+ att_thresh_, att_high_, decline_pct_, max_attempts_,
312
+ combine_mode_, weight_att_, weight_test_, weight_attempt_,
313
+ notify_, notify_emails_, smtp_host_, smtp_port_, smtp_user_, smtp_pass_):
314
+ smtp_overrides = None
315
+ if smtp_user_ and smtp_pass_ and smtp_host_:
316
+ smtp_overrides = {"host": smtp_host_, "port": int(smtp_port_), "user": smtp_user_, "pass": smtp_pass_}
317
+ df, meta = process_and_report(
318
+ att_file=att_file_, test_file=test_file_, fee_file=fee_file_,
319
+ attendance_threshold=float(att_thresh_), attendance_high_risk=float(att_high_),
320
+ decline_pct=float(decline_pct_), max_attempts=int(max_attempts_ or DEFAULT_MAX_ATTEMPTS),
321
+ combine_mode=combine_mode_, weight_att=float(weight_att_), weight_test=float(weight_test_), weight_attempt=float(weight_attempt_),
322
+ notify=notify_, notify_threshold="High", notify_emails=notify_emails_, smtp_overrides=smtp_overrides
323
  )
324
+ if isinstance(meta, dict) and meta.get("error"):
325
+ return f"<pre style='color:red'>{meta['error']}</pre>", None, str(meta)
326
+ html = df_to_colored_html(df)
327
+ fig = build_plot(df)
328
+ # prepare CSV bytes for download
329
+ csv_bytes = df.to_csv(index=False).encode('utf-8')
330
+ file_obj = io.BytesIO(csv_bytes)
331
+ file_obj.name = f"risk_report_{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}.csv"
332
+ return html, fig, str(meta), file_obj
333
+
334
+ run_btn.click(fn=on_run, inputs=[att_file, test_file, fee_file,
335
+ att_thresh, att_high, decline_pct, max_attempts,
336
+ combine_mode, weight_att, weight_test, weight_attempt,
337
+ notify, notify_emails, smtp_host, smtp_port, smtp_user, smtp_pass],
338
+ outputs=[result_html, risk_plot, summary_box, download])
339
 
340
+ demo.launch()