Therdpoom commited on
Commit
90093ec
·
1 Parent(s): c0a49c4

Fix: use final_app with default data embedded

Browse files
Files changed (1) hide show
  1. app.py +705 -423
app.py CHANGED
@@ -1,466 +1,748 @@
1
- import os, json, time
2
- import numpy as np
3
  import pandas as pd
4
- from flask import Flask, request, Response
 
5
  from flask_cors import CORS
6
- import joblib
7
- import warnings
8
- warnings.filterwarnings('ignore')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  app = Flask(__name__)
11
  CORS(app)
12
 
13
- # ── Load models ──
14
- BASE = os.path.dirname(os.path.abspath(__file__))
15
- MODELS_DIR = os.path.join(BASE, "models")
 
 
 
 
16
 
17
- FEATURES = joblib.load(os.path.join(MODELS_DIR, "features.pkl"))
18
- with open(os.path.join(MODELS_DIR, "results.json")) as f:
19
- MODEL_RESULTS = json.load(f)
 
 
 
20
 
21
- TARGETS = ["dm_readmission", "sepsis_risk", "ckd_progression", "ht_crisis"]
22
- TARGET_NAMES = {
23
- "dm_readmission": "DM Readmission (30-day)",
24
- "sepsis_risk": "Sepsis Risk",
25
- "ckd_progression": "CKD Progression",
26
- "ht_crisis": "Hypertension Crisis",
27
- }
28
 
29
- ALGO_MAP = {"lr": "Logistic Regression", "rf": "Random Forest", "xgb": "XGBoost"}
30
-
31
- # Pre-load all models
32
- LOADED = {}
33
- for target in TARGETS:
34
- LOADED[target] = {}
35
- for algo in ["lr", "rf", "xgb"]:
36
- path = os.path.join(MODELS_DIR, f"{target}_{algo}.pkl")
37
- LOADED[target][algo] = joblib.load(path)
38
-
39
- print(f"✅ Loaded {len(TARGETS) * 3} models")
40
-
41
- # Normal ranges for lab interpretation
42
- NORMAL_RANGES = {
43
- "hba1c": (4.0, 6.4, "%"),
44
- "fasting_glucose": (70, 100, "mg/dL"),
45
- "creatinine": (0.6, 1.2, "mg/dL"),
46
- "egfr": (60, 120, "mL/min"),
47
- "wbc": (4.0, 11.0, "×10³/μL"),
48
- "hemoglobin": (12.0, 17.5, "g/dL"),
49
- "sbp": (90, 140, "mmHg"),
50
- "dbp": (60, 90, "mmHg"),
51
- "heart_rate": (60, 100, "bpm"),
52
- "temperature": (36.1, 37.5, "°C"),
53
- "spo2": (95, 100, "%"),
54
- "bmi": (18.5, 24.9, "kg/m²"),
55
- "sodium": (135, 145, "mEq/L"),
56
- "potassium": (3.5, 5.0, "mEq/L"),
57
- "cholesterol": (0, 200, "mg/dL"),
58
- "ldl": (0, 130, "mg/dL"),
59
- "bun": (7, 25, "mg/dL"),
60
- "platelet": (150, 400, "×10³/μL"),
61
- }
62
 
63
- def interpret_value(key, value):
64
- if key not in NORMAL_RANGES:
65
- return "normal"
66
- lo, hi, _ = NORMAL_RANGES[key]
67
- if value > hi: return "high"
68
- if value < lo: return "low"
69
- return "normal"
70
-
71
- def get_risk_recommendations(target, risk_prob, top_factors):
72
- recs = {
73
- "dm_readmission": {
74
- "high": ["นัดติดตามผลภายใน 2 สัปดาห์", "ตรวจ HbA1c ซ้ำ", "ประเมินการใช้ยา Insulin", "ให้ความรู้การดูแลเท้าและอาหาร"],
75
- "medium": ["นัดติดตามผลภายใน 1 เดือน", "ตรวจ HbA1c และ Fasting glucose", "ทบทวนการใช้ยาเบาหวาน"],
76
- "low": ["นัดติดตามปกติ 3 เดือน", "ตรวจ HbA1c ปีละ 2 ครั้ง"],
77
- },
78
- "sepsis_risk": {
79
- "high": ["ส่งต่อห้องฉุกเฉินทันที", "เก็บ Blood culture 2 set", "เริ่ม Broad-spectrum antibiotics ภายใน 1 ชั่วโมง", "ให้สารน้ำ IV 30 mL/kg"],
80
- "medium": ["Monitor vital signs ทุก 4 ชั่วโมง", "ตรวจ CBC, Lactate, CRP", "เตรียม IV access"],
81
- "low": ["Monitor ปกติ", "แนะนำสังเกตอาการไข้และหนาวสั่น"],
82
- },
83
- "ckd_progression": {
84
- "high": ["ส่งพบอายุรแพทย์โรคไต", "ลด protein diet < 0.8 g/kg/day", "ควบคุม BP < 130/80", "หลีกเลี่ยง NSAIDs และ contrast"],
85
- "medium": ["ติดตาม Creatinine และ eGFR ทุก 3 เดือน", "ควบคุม DM และ HT ให้ดี", "ตรวจ Urine protein/creatinine ratio"],
86
- "low": ["ติดตาม eGFR ปีละครั้ง", "ดูแลความดันโลหิตและน้ำตาล"],
87
- },
88
- "ht_crisis": {
89
- "high": ["วัด BP ซ้ำทั้ง 2 แขน", "ตรวจ ECG และ Fundoscopy", "ให้ยาลด BP ฉุกเฉิน IV", "Monitor ใน ICU"],
90
- "medium": ["ปรับยา Antihypertensive", "ลดเกลือ < 2g/วัน", "นัดติดตาม BP ใน 1 สัปดาห์"],
91
- "low": ["ติดตาม BP สม่ำเสมอ", "ให้ Lifestyle modification"],
92
- },
93
- }
94
- level = "high" if risk_prob > 0.7 else "medium" if risk_prob > 0.4 else "low"
95
- return recs.get(target, {}).get(level, ["ติดตามตามปกติ"])
96
-
97
- def safe_json(data):
98
- class Enc(json.JSONEncoder):
99
- def default(self, o):
100
- if isinstance(o, (np.integer,)): return int(o)
101
- if isinstance(o, (np.floating,)): return float(o)
102
- if isinstance(o, (np.bool_,)): return bool(o)
103
- if isinstance(o, np.ndarray): return o.tolist()
104
- return super().default(o)
105
- return Response(json.dumps(data, cls=Enc, ensure_ascii=False), mimetype='application/json')
106
 
107
- @app.after_request
108
- def add_cors(r):
109
- r.headers["Access-Control-Allow-Origin"] = "*"
110
- r.headers["Access-Control-Allow-Headers"] = "Content-Type"
111
- r.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
112
- return r
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  @app.route("/", methods=["GET"])
115
  def index():
116
- return safe_json({
117
  "status": "ok",
118
- "service": "Healthcare AI Risk Prediction API",
119
- "models": list(ALGO_MAP.values()),
120
- "targets": TARGET_NAMES,
121
  "endpoints": [
122
- "POST /api/health/classify — single patient",
123
- "POST /api/health/batch — multiple patients",
124
- "GET /api/health/model-info — model performance",
125
- "GET /api/health/population — population stats",
126
- "GET /health",
 
 
127
  ]
128
  })
129
 
130
  @app.route("/health", methods=["GET"])
131
- def health():
132
- return safe_json({"status": "ok", "models_loaded": len(TARGETS)*3})
133
-
134
- @app.route("/api/health/model-info", methods=["GET"])
135
- def model_info():
136
- return safe_json({
137
- "features": FEATURES,
138
- "feature_count": len(FEATURES),
139
- "targets": MODEL_RESULTS,
140
- "algorithms": ALGO_MAP,
141
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- # ── Single patient predict ──
144
- @app.route("/api/health/classify", methods=["POST", "OPTIONS"])
145
- def classify():
146
- if request.method == "OPTIONS":
147
- return safe_json({}), 200
 
 
 
 
 
 
 
 
 
 
 
 
148
  try:
149
- t0 = time.time()
150
  body = request.get_json()
151
-
152
- algo = body.get("model", "xgb")
153
- target = body.get("target", "ckd_progression")
154
- patient = body.get("patient", {})
155
-
156
- if algo not in ALGO_MAP:
157
- return safe_json({"error": f"Unknown model: {algo}"}), 400
158
- if target not in TARGETS:
159
- return safe_json({"error": f"Unknown target: {target}"}), 400
160
- if not patient:
161
- return safe_json({"error": "patient data required"}), 400
162
-
163
- # Build feature vector
164
- row = {}
165
- for feat in FEATURES:
166
- val = patient.get(feat)
167
- if val is None:
168
- val = 0 # default
169
- row[feat] = float(val)
170
-
171
- X = pd.DataFrame([row])[FEATURES]
172
-
173
- loaded = LOADED[target][algo]
174
- model = loaded["model"]
175
- scaler = loaded["scaler"]
176
-
177
- X_input = scaler.transform(X) if scaler else X
178
- prob = float(model.predict_proba(X_input)[0][1])
179
- pred = int(prob > 0.5)
180
-
181
- # Risk level
182
- if prob > 0.7: risk_level = "HIGH"
183
- elif prob > 0.4: risk_level = "MEDIUM"
184
- else: risk_level = "LOW"
185
-
186
- # Feature importance / pseudo-SHAP
187
- if algo == "xgb":
188
- fi = model.feature_importances_
189
- elif algo == "rf":
190
- fi = model.feature_importances_
191
  else:
192
- fi = np.abs(model.coef_[0])
193
-
194
- fi_norm = fi / fi.sum()
195
- top_factors = sorted(
196
- [{"feature": f, "importance": round(float(v), 4),
197
- "value": round(float(row[f]), 2),
198
- "status": interpret_value(f, row[f]),
199
- "unit": NORMAL_RANGES.get(f, (None,None,""))[2]}
200
- for f, v in zip(FEATURES, fi_norm)],
201
- key=lambda x: x["importance"], reverse=True
202
- )[:5]
203
-
204
- # Lab interpretation
205
- lab_status = {
206
- feat: {"value": round(float(row[feat]), 2),
207
- "status": interpret_value(feat, row[feat]),
208
- "unit": NORMAL_RANGES.get(feat, (None,None,""))[2]}
209
- for feat in NORMAL_RANGES if feat in row
210
- }
211
-
212
- recs = get_risk_recommendations(target, prob, top_factors)
213
- elapsed = round(time.time() - t0, 3)
214
-
215
- return safe_json({
216
- "patient_id": patient.get("patient_id", "unknown"),
217
- "target": target,
218
- "target_name": TARGET_NAMES[target],
219
- "model": algo,
220
- "model_name": ALGO_MAP[algo],
221
- "risk_prob": round(prob, 3),
222
- "risk_pct": round(prob * 100, 1),
223
- "prediction": pred,
224
- "risk_level": risk_level,
225
- "top_factors": top_factors,
226
- "lab_status": lab_status,
227
- "recommendations": recs,
228
- "model_auroc": MODEL_RESULTS[target]["auroc"][algo],
229
- "processing_ms": round(elapsed * 1000, 1),
230
- })
 
 
 
 
 
231
  except Exception as e:
232
  import traceback; print(traceback.format_exc())
233
- return safe_json({"error": str(e)}), 500
234
 
235
- # ── Batch predict ──
236
- @app.route("/api/health/batch", methods=["POST", "OPTIONS"])
237
- def batch_classify():
238
- if request.method == "OPTIONS":
239
- return safe_json({}), 200
 
240
  try:
241
- t0 = time.time()
242
- body = request.get_json()
243
- algo = body.get("model", "xgb")
244
- target = body.get("target", "ckd_progression")
245
- patients = body.get("patients", [])
246
-
247
- if not patients:
248
- return safe_json({"error": "patients list required"}), 400
249
-
250
- loaded = LOADED[target][algo]
251
- model = loaded["model"]
252
- scaler = loaded["scaler"]
253
-
254
- rows = []
255
- for p in patients:
256
- row = {feat: float(p.get(feat, 0)) for feat in FEATURES}
257
- rows.append(row)
258
-
259
- X = pd.DataFrame(rows)[FEATURES]
260
- X_input = scaler.transform(X) if scaler else X
261
- probs = model.predict_proba(X_input)[:, 1]
262
-
263
- results = []
264
- for i, (p, prob) in enumerate(zip(patients, probs)):
265
- prob = float(prob)
266
- risk_level = "HIGH" if prob > 0.7 else "MEDIUM" if prob > 0.4 else "LOW"
267
- results.append({
268
- "patient_id": p.get("patient_id", f"P{i:04d}"),
269
- "full_name": p.get("full_name", ""),
270
- "age": p.get("age", 0),
271
- "risk_prob": round(prob, 3),
272
- "risk_pct": round(prob * 100, 1),
273
- "risk_level": risk_level,
274
- })
275
-
276
- results.sort(key=lambda x: x["risk_prob"], reverse=True)
277
-
278
- cnt = {"HIGH":0,"MEDIUM":0,"LOW":0}
279
- for r in results:
280
- cnt[r["risk_level"]] += 1
281
-
282
- elapsed = round(time.time() - t0, 3)
283
- return safe_json({
284
- "total": len(results),
285
- "target": target,
286
- "target_name": TARGET_NAMES[target],
287
- "model": algo,
288
- "model_name": ALGO_MAP[algo],
289
- "risk_summary": cnt,
290
- "results": results,
291
- "processing_ms": round(elapsed * 1000, 1),
292
- })
293
  except Exception as e:
294
  import traceback; print(traceback.format_exc())
295
- return safe_json({"error": str(e)}), 500
296
 
297
- # ── Population stats ──
298
- @app.route("/api/health/population", methods=["GET"])
299
- def population():
 
 
 
300
  try:
301
- csv_path = os.path.join(BASE, "synthetic_patients.csv")
302
- df = pd.read_csv(csv_path)
303
-
304
- algo = request.args.get("model", "xgb")
305
- target = request.args.get("target", "ckd_progression")
306
-
307
- loaded = LOADED[target][algo]
308
- model = loaded["model"]
309
- scaler = loaded["scaler"]
310
-
311
- X = df[FEATURES].fillna(df[FEATURES].median())
312
- X_input = scaler.transform(X) if scaler else X
313
- probs = model.predict_proba(X_input)[:, 1]
314
-
315
- df["risk_prob"] = probs
316
- df["risk_level"] = pd.cut(probs,
317
- bins=[-0.001, 0.4, 0.7, 1.001],
318
- labels=["LOW","MEDIUM","HIGH"])
319
-
320
- # Sample for scatter plot (50 points)
321
- sample = df.sample(min(200, len(df)), random_state=42)
322
- scatter = sample[["patient_id","full_name","age","risk_prob",
323
- "risk_level","has_diabetes","has_hypertension",
324
- "has_ckd","sbp","hba1c","creatinine"]].to_dict("records")
325
-
326
- # Age group breakdown
327
- df["age_group"] = pd.cut(df["age"],
328
- bins=[0,30,45,60,75,100],
329
- labels=["<30","30-44","45-59","60-74","75+"])
330
- age_risk = df.groupby("age_group")["risk_prob"].mean().round(3).to_dict()
331
-
332
- return safe_json({
333
- "total": len(df),
334
- "target": target,
335
- "model": algo,
336
- "risk_summary": df["risk_level"].value_counts().to_dict(),
337
- "mean_risk": round(float(probs.mean()), 3),
338
- "high_risk_count": int((probs > 0.7).sum()),
339
- "age_risk_trend": {str(k): float(v) for k,v in age_risk.items()},
340
- "scatter_data": scatter,
341
- })
342
  except Exception as e:
343
  import traceback; print(traceback.format_exc())
344
- return safe_json({"error": str(e)}), 500
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
- # ── Patient list by risk level — ครบทุกคน ไม่ใช่ sample ──
348
- @app.route("/api/health/patients", methods=["GET","OPTIONS"])
349
- def get_patients():
350
- if request.method == "OPTIONS":
351
- return safe_json({}), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  try:
353
- algo = request.args.get("model", "xgb")
354
- target = request.args.get("target", "ckd_progression")
355
- risk = request.args.get("risk", "all").upper()
356
- page = int(request.args.get("page", 1))
357
- per_page = int(request.args.get("per_page", 20))
358
-
359
- csv_path = os.path.join(BASE, "synthetic_patients.csv")
360
- df = pd.read_csv(csv_path)
361
-
362
- loaded = LOADED[target][algo]
363
- model = loaded["model"]
364
- scaler = loaded["scaler"]
365
-
366
- X = df[FEATURES].fillna(df[FEATURES].median())
367
- X_input = scaler.transform(X) if scaler else X
368
- probs = model.predict_proba(X_input)[:, 1]
369
-
370
- df["risk_prob"] = probs
371
- df["risk_pct"] = (probs * 100).round(1)
372
- df["risk_level"] = pd.cut(probs,
373
- bins=[-0.001, 0.4, 0.7, 1.001],
374
- labels=["LOW","MEDIUM","HIGH"])
375
-
376
- if risk in ["HIGH","MEDIUM","LOW"]:
377
- filtered = df[df["risk_level"] == risk].copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  else:
379
- filtered = df.copy()
380
-
381
- filtered = filtered.sort_values("risk_prob", ascending=False)
382
- total = len(filtered)
383
- start = (page - 1) * per_page
384
- page_df = filtered.iloc[start:start+per_page]
385
-
386
- def safe_float(v):
387
- try: return round(float(v), 3)
388
- except: return 0.0
389
-
390
- def safe_int(v):
391
- try: return int(v)
392
- except: return 0
393
-
394
- patients = []
395
- for _, row in page_df.iterrows():
396
- patients.append({
397
- "patient_id": row["patient_id"],
398
- "full_name": row["full_name"],
399
- "age": safe_int(row["age"]),
400
- "gender": str(row.get("gender","")),
401
- "risk_prob": round(float(row["risk_prob"]), 3),
402
- "risk_pct": round(float(row["risk_pct"]), 1),
403
- "risk_level": str(row["risk_level"]),
404
- "bmi": safe_float(row.get("bmi",0)),
405
- "sbp": safe_float(row.get("sbp",0)),
406
- "dbp": safe_float(row.get("dbp",0)),
407
- "heart_rate": safe_float(row.get("heart_rate",0)),
408
- "temperature": safe_float(row.get("temperature",0)),
409
- "spo2": safe_float(row.get("spo2",0)),
410
- "respiratory_rate": safe_float(row.get("respiratory_rate",0)),
411
- "hba1c": safe_float(row.get("hba1c",0)),
412
- "fasting_glucose": safe_float(row.get("fasting_glucose",0)),
413
- "creatinine": safe_float(row.get("creatinine",0)),
414
- "egfr": safe_float(row.get("egfr",0)),
415
- "bun": safe_float(row.get("bun",0)),
416
- "wbc": safe_float(row.get("wbc",0)),
417
- "hemoglobin": safe_float(row.get("hemoglobin",0)),
418
- "platelet": safe_float(row.get("platelet",0)),
419
- "sodium": safe_float(row.get("sodium",0)),
420
- "potassium": safe_float(row.get("potassium",0)),
421
- "cholesterol": safe_float(row.get("cholesterol",0)),
422
- "ldl": safe_float(row.get("ldl",0)),
423
- "hdl": safe_float(row.get("hdl",0)),
424
- "triglyceride": safe_float(row.get("triglyceride",0)),
425
- "has_diabetes": safe_int(row.get("has_diabetes",0)),
426
- "has_hypertension": safe_int(row.get("has_hypertension",0)),
427
- "has_ckd": safe_int(row.get("has_ckd",0)),
428
- "has_cvd": safe_int(row.get("has_cvd",0)),
429
- "has_sepsis_hx": safe_int(row.get("has_sepsis_hx",0)),
430
- "num_medications": safe_int(row.get("num_medications",0)),
431
- "visit_per_year": safe_int(row.get("visit_per_year",0)),
432
- "admit_per_year": safe_int(row.get("admit_per_year",0)),
433
- "er_visit": safe_int(row.get("er_visit",0)),
434
- "days_since_visit": safe_int(row.get("days_since_visit",0)),
435
- "province": str(row.get("province","")),
436
- "smoking": str(row.get("smoking","")),
437
- "alcohol": str(row.get("alcohol","")),
438
- "icd10_codes": str(row.get("icd10_codes","")),
439
- "medications": str(row.get("medications","")),
440
- })
441
-
442
- all_levels = df["risk_level"].value_counts().to_dict()
443
-
444
- return safe_json({
445
- "total_filtered": total,
446
- "total_patients": len(df),
447
- "page": page,
448
- "per_page": per_page,
449
- "total_pages": max(1, (total + per_page - 1) // per_page),
450
- "risk_filter": risk,
451
- "model": algo,
452
- "target": target,
453
- "risk_summary": {
454
- "HIGH": int(all_levels.get("HIGH", 0)),
455
- "MEDIUM": int(all_levels.get("MEDIUM", 0)),
456
- "LOW": int(all_levels.get("LOW", 0)),
457
- },
458
- "patients": patients,
459
- })
460
  except Exception as e:
461
  import traceback; print(traceback.format_exc())
462
- return safe_json({"error": str(e)}), 500
463
 
464
  if __name__ == "__main__":
465
  port = int(os.environ.get("PORT", 7860))
466
- app.run(host="0.0.0.0", port=port, debug=False)
 
1
+ import os
 
2
  import pandas as pd
3
+ import numpy as np
4
+ from flask import Flask, request, jsonify
5
  from flask_cors import CORS
6
+ from prophet import Prophet
7
+ from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
8
+ from sklearn.linear_model import LogisticRegression
9
+ from sklearn.preprocessing import LabelEncoder, StandardScaler
10
+ from sklearn.pipeline import Pipeline
11
+ from sklearn.metrics import accuracy_score
12
+ import shap
13
+ import json
14
+
15
+ # ── Custom JSON encoder ──
16
+ class NumpyEncoder(json.JSONEncoder):
17
+ def default(self, obj):
18
+ if isinstance(obj, (np.integer,)): return int(obj)
19
+ if isinstance(obj, (np.floating,)): return float(obj)
20
+ if isinstance(obj, (np.bool_,)): return bool(obj)
21
+ if isinstance(obj, (np.ndarray,)): return obj.tolist()
22
+ return super().default(obj)
23
+
24
+ def safe_jsonify(data):
25
+ from flask import Response
26
+ return Response(json.dumps(data, cls=NumpyEncoder, ensure_ascii=False), mimetype='application/json')
27
 
28
  app = Flask(__name__)
29
  CORS(app)
30
 
31
+ @app.after_request
32
+ def add_headers(response):
33
+ response.headers["ngrok-skip-browser-warning"] = "true"
34
+ response.headers["Access-Control-Allow-Origin"] = "*"
35
+ response.headers["Access-Control-Allow-Headers"] = "Content-Type"
36
+ response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS"
37
+ return response
38
 
39
+ # ════════════════════════════════════════════════════════
40
+ # DEFAULT DATA embedded สำหรับสอน นศ
41
+ # ถ้า user ไม่ browse ไฟล์ → ใช้ข้อมูลนี้อัตโนมัติ
42
+ # ════════════════════════════════════════════════════════
43
+ import random as _rnd
44
+ _rnd.seed(42); np.random.seed(42)
45
 
46
+ def _def_sales():
47
+ months = pd.date_range("2022-01-01", periods=36, freq="MS")
48
+ base=850000; trend=np.linspace(0,200000,36)
49
+ seas=np.array([0.85,0.80,0.90,0.95,1.05,1.10,1.15,1.20,1.10,1.00,0.95,1.30]*3)
50
+ s=(base+trend)*seas+np.random.normal(0,30000,36)
51
+ return [{"ds":m.strftime("%Y-%m-%d"),"y":max(0,round(float(v),0))} for m,v in zip(months,s)]
 
52
 
53
+ def _def_health():
54
+ rows=[]
55
+ for i in range(100):
56
+ risk=_rnd.choices(["low","medium","high"],weights=[0.5,0.35,0.15])[0]
57
+ r={"low":1.0,"medium":1.3,"high":1.7}[risk]
58
+ rows.append({"patient_id":f"PT{i+1:04d}","age":_rnd.randint(30,80),"gender":_rnd.choice(["M","F"]),
59
+ "glucose_mg_dl":round(_rnd.gauss(95*r,20),1),"cholesterol_mg_dl":round(_rnd.gauss(200*r,35),1),
60
+ "hdl_mg_dl":round(_rnd.gauss(50/r,8),1),"ldl_mg_dl":round(_rnd.gauss(120*r,25),1),
61
+ "triglycerides_mg_dl":round(_rnd.gauss(130*r,40),1),"systolic_bp_mmhg":round(_rnd.gauss(120*r,15),1),
62
+ "diastolic_bp_mmhg":round(_rnd.gauss(80*r,10),1),"bmi":round(_rnd.gauss(24*r,4),1),
63
+ "hba1c_percent":round(_rnd.gauss(5.5*r,0.8),1),"risk_level":risk})
64
+ return rows
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
+ def _def_coffee():
67
+ months=pd.date_range("2022-01-01",periods=36,freq="MS")
68
+ base=320000; trend=np.linspace(0,80000,36)
69
+ seas=np.array([0.82,0.78,0.88,0.92,1.02,1.08,1.12,1.18,1.05,0.98,0.90,1.25]*3)
70
+ r=(base+trend)*seas+np.random.normal(0,12000,36)
71
+ return [{"ds":m.strftime("%Y-%m-%d"),"y":max(0,round(float(v),0))} for m,v in zip(months,r)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
+ def _def_menu():
74
+ menus=[("ลาเต้ร้อน","coffee",280,0.68),("อเมริกาโน่","coffee",220,0.72),
75
+ ("คาปูชิโน่","coffee",250,0.65),("มอคค่า","coffee",300,0.62),
76
+ ("ชาเย็น","tea",180,0.70),("ชานมไข่มุก","tea",220,0.58),
77
+ ("ชาไทย","tea",160,0.74),("เค้กช็อกโกแลต","bakery",150,0.55),
78
+ ("บราวนี่","bakery",90,0.60),("คุกกี้","bakery",120,0.65),
79
+ ("แซนวิชไข่","food",95,0.52),("โทสต์","food",80,0.58),
80
+ ("ลาเต้เย็น","coffee",310,0.66),("กรีนที","tea",140,0.68),
81
+ ("ช็อกโกแลตฮอท","coffee",200,0.63)]
82
+ rows=[]
83
+ for name,cat,qty_b,mg in menus:
84
+ qty=int(qty_b*_rnd.uniform(0.7,1.3)); price=_rnd.choice([55,65,75,85,95,105]); rev=qty*price
85
+ rows.append({"menu_name":name,"category":cat,"total_qty":qty,"total_revenue":rev,
86
+ "total_profit":round(rev*mg,0),"margin_pct":round(mg*100,1),"avg_price":price})
87
+ return rows
88
+
89
+ def _def_hr():
90
+ depts=["Engineering","Sales","Marketing","HR","Finance","Operations","IT"]; rows=[]
91
+ for i in range(200):
92
+ dept=_rnd.choice(depts); sat=max(1,min(5,round(_rnd.gauss(3.5,0.8),1)))
93
+ ot=max(0,round(_rnd.gauss(20,10),0)); yrs=_rnd.randint(1,15); sal=_rnd.randint(25000,120000)
94
+ rp=((0.3 if sat<2.5 else 0)+(0.2 if ot>35 else 0)+(0.15 if sal<35000 else 0)
95
+ +(0.1 if yrs<2 else 0)+_rnd.uniform(-0.1,0.1))
96
+ rows.append({"employee_id":f"EMP{i+1:04d}","department":dept,"gender":_rnd.choice(["M","F"]),
97
+ "education":_rnd.choice(["Bachelor","Master","PhD","Diploma"]),
98
+ "position":f"Senior {dept} Specialist" if yrs>5 else f"{dept} Officer",
99
+ "age":_rnd.randint(22,55),"years_at_company":yrs,"years_in_current_role":_rnd.randint(1,yrs),
100
+ "salary_thb":sal,"salary_hike_pct":round(_rnd.gauss(8,3),1),
101
+ "distance_from_home_km":_rnd.randint(1,60),"overtime_hours_monthly":ot,
102
+ "monthly_absent_days":round(_rnd.gauss(1.5,1),1),"satisfaction_score":sat,
103
+ "performance_rating":round(_rnd.gauss(3.5,0.6),1),"num_projects":_rnd.randint(1,8),
104
+ "training_hours_yearly":_rnd.randint(0,80),"promotion_last_3years":_rnd.randint(0,2),
105
+ "work_life_balance":round(_rnd.gauss(3,0.8),1),"job_involvement":round(_rnd.gauss(3.2,0.7),1),
106
+ "manager_rating":round(_rnd.gauss(3.5,0.6),1),"resigned":1 if rp>0.35 else 0})
107
+ return rows
108
+
109
+ def _def_lottery():
110
+ dates=pd.date_range("2020-01-01",periods=120,freq="SMS")
111
+ return [{"draw_date":d.strftime("%Y-%m-%d"),"last2_num":_rnd.randint(0,99),
112
+ "last3_num":_rnd.randint(0,999),"first_prize":"".join([str(_rnd.randint(0,9)) for _ in range(6)])}
113
+ for d in dates]
114
+
115
+ _PR=["สินค้าดีมาก คุ้มค่า แนะนำเลย","บริการประทับใจ ส่งเร็ว","excellent quality love it",
116
+ "ของดี ชอบมาก","very satisfied","คุณภาพดี ใช้งานง่าย"]
117
+ _NR=["สินค้าแย่มาก ผิดหวัง","ส่งช้ามาก ไม่พอใจ","terrible disappointed",
118
+ "คุณภาพห่วย ไม่คุ้มราคา","slow delivery bad service"]
119
+ _NU=["ใช้งานได้ปกติ","สินค้าโอเค ตรงปก","okay nothing special","พอใช้งานได้","average as expected"]
120
+
121
+ def _def_reviews():
122
+ channels=["LINE","Facebook","Website","Shopee","Lazada"]
123
+ months=[f"2024-{m:02d}" for m in range(1,13)]+[f"2025-{m:02d}" for m in range(1,7)]
124
+ rows=[]
125
+ for i in range(800):
126
+ s=_rnd.choices(["Positive","Negative","Neutral"],weights=[0.55,0.20,0.25])[0]
127
+ text=_rnd.choice(_PR if s=="Positive" else _NR if s=="Negative" else _NU)
128
+ rating=_rnd.choice([4,5]) if s=="Positive" else _rnd.choice([1,2]) if s=="Negative" else 3
129
+ mo=_rnd.choice(months)
130
+ rows.append({"review_id":f"REV{i+1:04d}","review_text":text,"sentiment":s,"rating":rating,
131
+ "channel":_rnd.choice(channels),
132
+ "category":_rnd.choice(["สินค้า","บริการ","การจัดส่ง"]),
133
+ "month":mo,"date":f"{mo}-{_rnd.randint(1,28):02d}"})
134
+ return rows
135
+
136
+ DEFAULT_INFO = {
137
+ "sales": {"rows":36, "desc":"ยอดขาย 36 เดือน 2022-2024"},
138
+ "health": {"rows":100,"desc":"ผู้ป่วยจำลอง 100 คน"},
139
+ "coffee": {"rows":36, "desc":"รายได้กาแฟ 36 เดือน"},
140
+ "menu": {"rows":15, "desc":"เมนูกาแฟ 15 รายการ"},
141
+ "hr": {"rows":200,"desc":"พนักงานจำลอง 200 คน"},
142
+ "lottery": {"rows":120,"desc":"ล็อตเตอรี่ 120 งวด"},
143
+ "reviews": {"rows":800,"desc":"รีวิวจำลอง 800 รายการ"},
144
+ }
145
+ # ════════════════════════════════════════════════════════
146
 
147
  @app.route("/", methods=["GET"])
148
  def index():
149
+ return safe_jsonify({
150
  "status": "ok",
151
+ "message": "AI Analytics API Forecast Hub",
152
+ "default_data": DEFAULT_INFO,
 
153
  "endpoints": [
154
+ "POST /api/forecast",
155
+ "POST /api/health/classify",
156
+ "POST /api/coffee/forecast",
157
+ "POST /api/coffee/menu-analysis",
158
+ "POST /api/hr/classify",
159
+ "POST /api/lottery/analyze",
160
+ "POST /api/sentiment/analyze"
161
  ]
162
  })
163
 
164
  @app.route("/health", methods=["GET"])
165
+ def health_check():
166
+ return safe_jsonify({"status":"ok","default_data_available":True})
167
+
168
+ # ─────────────────────────────────────
169
+ # POST /api/forecast — Sales
170
+ # ─────────────────────────────────────
171
+ @app.route("/api/forecast", methods=["POST","OPTIONS"])
172
+ def forecast():
173
+ if request.method == "OPTIONS": return jsonify({}), 200
174
+ try:
175
+ body = request.get_json()
176
+ data = body.get("data", [])
177
+ periods = body.get("periods", 6)
178
+ model_type = body.get("model","auto").lower()
179
+
180
+ # ── DEFAULT FALLBACK ──
181
+ using_default = not data
182
+ if using_default:
183
+ data = _def_sales()
184
+ print("📊 /api/forecast: using default sales data")
185
+
186
+ df = pd.DataFrame(data)
187
+ df.columns = ["ds","y"]
188
+ df["ds"] = pd.to_datetime(df["ds"])
189
+ if len(df) < 6:
190
+ return safe_jsonify({"error":"ต้องการข้อมูลอย่างน้อย 6 เดือน"}), 400
191
+
192
+ y = df["y"].values
193
+ last_date = df["ds"].iloc[-1]
194
+ forecast_dates = pd.date_range(start=last_date, periods=periods+1, freq="MS")[1:]
195
+
196
+ if model_type in ["auto","prophet"]:
197
+ m = Prophet(yearly_seasonality=True, weekly_seasonality=False,
198
+ daily_seasonality=False, seasonality_mode="additive",
199
+ changepoint_prior_scale=0.3, interval_width=0.90)
200
+ m.fit(df)
201
+ future = m.make_future_dataframe(periods=periods, freq="MS")
202
+ result = m.predict(future)
203
+ fdf = result.tail(periods)
204
+ preds=np.clip(fdf["yhat"].values,0,None)
205
+ lowers=np.clip(fdf["yhat_lower"].values,0,None)
206
+ uppers=fdf["yhat_upper"].values
207
+ used_model="Prophet"
208
+ elif model_type in ["holt-winters","holtwinters"]:
209
+ from statsmodels.tsa.holtwinters import ExponentialSmoothing
210
+ sp=min(12,len(y)//2)
211
+ hw=ExponentialSmoothing(y,trend="add",seasonal="add",seasonal_periods=sp).fit()
212
+ preds=np.clip(hw.forecast(periods),0,None)
213
+ std=np.std(y-hw.fittedvalues)*1.96
214
+ lowers=np.clip(preds-std,0,None); uppers=preds+std; used_model="Holt-Winters"
215
+ elif model_type == "sarima":
216
+ from statsmodels.tsa.statespace.sarimax import SARIMAX
217
+ fit=SARIMAX(y,order=(1,1,1),seasonal_order=(1,1,1,12)).fit(disp=False)
218
+ res=fit.get_forecast(steps=periods)
219
+ preds=np.clip(res.predicted_mean,0,None); ci=res.conf_int()
220
+ lowers=np.clip(ci[:,0],0,None); uppers=ci[:,1]; used_model="SARIMA"
221
+ elif model_type == "linear":
222
+ from sklearn.linear_model import LinearRegression
223
+ X=np.arange(len(y)).reshape(-1,1); lr=LinearRegression().fit(X,y)
224
+ X_f=np.arange(len(y),len(y)+periods).reshape(-1,1)
225
+ preds=np.clip(lr.predict(X_f),0,None); std=np.std(y-lr.predict(X))*1.96
226
+ lowers=np.clip(preds-std,0,None); uppers=preds+std; used_model="Linear"
227
+ elif model_type == "arima":
228
+ from statsmodels.tsa.arima.model import ARIMA
229
+ fit=ARIMA(y,order=(2,1,2)).fit()
230
+ res=fit.get_forecast(steps=periods)
231
+ preds=np.clip(res.predicted_mean,0,None); ci=res.conf_int()
232
+ lowers=np.clip(ci.iloc[:,0].values,0,None); uppers=ci.iloc[:,1].values; used_model="ARIMA"
233
+ else:
234
+ return safe_jsonify({"error":f"ไม่รู้จัก model: {model_type}"}), 400
235
 
236
+ diff_pct=(preds[0]-y[-1])/y[-1]*100
237
+ trend="up" if diff_pct>2 else "down" if diff_pct<-2 else "stable"
238
+ forecast_list=[{"period":d.strftime("%Y-%m"),"predicted":round(float(preds[i]),2),
239
+ "lower":round(float(lowers[i]),2),"upper":round(float(uppers[i]),2)}
240
+ for i,d in enumerate(forecast_dates)]
241
+ return safe_jsonify({"forecast":forecast_list,"trend":trend,"confidence":85,
242
+ "used_model":used_model,"using_default_data":using_default,
243
+ "default_info":DEFAULT_INFO["sales"] if using_default else None})
244
+ except Exception as e:
245
+ return safe_jsonify({"error":str(e)}), 500
246
+
247
+ # ─────────────────────────────────────
248
+ # POST /api/health/classify
249
+ # ─────────────────────────────────────
250
+ @app.route("/api/health/classify", methods=["POST","OPTIONS"])
251
+ def health_classify():
252
+ if request.method == "OPTIONS": return jsonify({}), 200
253
  try:
 
254
  body = request.get_json()
255
+ data = body.get("data",[])
256
+ model_type = body.get("model","random_forest").lower()
257
+
258
+ # ── DEFAULT FALLBACK ──
259
+ using_default = not data
260
+ if using_default:
261
+ data = _def_health()
262
+ print("📊 /api/health/classify: using default health data")
263
+
264
+ if len(data) < 6:
265
+ return safe_jsonify({"error":"ต้องการข้อมูลอย่างน้อย 6 แถว"}), 400
266
+
267
+ feature_cols=["glucose_mg_dl","cholesterol_mg_dl","hdl_mg_dl","ldl_mg_dl",
268
+ "triglycerides_mg_dl","systolic_bp_mmhg","diastolic_bp_mmhg","bmi","hba1c_percent"]
269
+ df=pd.DataFrame(data)
270
+ missing=[c for c in feature_cols if c not in df.columns]
271
+ if missing: return safe_jsonify({"error":f"Missing columns: {missing}"}), 400
272
+
273
+ X=df[feature_cols].fillna(df[feature_cols].median())
274
+ if "risk_level" in df.columns:
275
+ y_raw=df["risk_level"].str.lower().str.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  else:
277
+ y_raw=pd.Series("low",index=df.index)
278
+ y_raw[(df["hba1c_percent"]>5.7)|(df["glucose_mg_dl"]>100)|(df["cholesterol_mg_dl"]>200)|(df["systolic_bp_mmhg"]>120)]="medium"
279
+ y_raw[(df["hba1c_percent"]>7.0)|(df["glucose_mg_dl"]>130)|(df["cholesterol_mg_dl"]>240)|(df["systolic_bp_mmhg"]>140)]="high"
280
+
281
+ le=LabelEncoder(); y=le.fit_transform(y_raw)
282
+ if model_type=="random_forest":
283
+ clf=RandomForestClassifier(n_estimators=100,random_state=42); model_name="Random Forest"
284
+ elif model_type in ["xgboost","gradient_boosting"]:
285
+ clf=GradientBoostingClassifier(n_estimators=100,random_state=42); model_name="Gradient Boosting"
286
+ else:
287
+ clf=Pipeline([("scaler",StandardScaler()),("clf",LogisticRegression(max_iter=1000,random_state=42))]); model_name="Logistic Regression"
288
+
289
+ clf.fit(X,y); y_pred=clf.predict(X); accuracy=round(accuracy_score(y,y_pred)*100,1)
290
+ proba=clf.predict_proba(X)
291
+
292
+ try:
293
+ explainer=shap.TreeExplainer(clf); shap_vals=explainer.shap_values(X)
294
+ mean_shap={col:round(float(np.mean([abs(shap_vals[c][:,i]).mean() for c in range(len(le.classes_))])),4)
295
+ for i,col in enumerate(feature_cols)}
296
+ except:
297
+ mean_shap=({col:round(float(clf.feature_importances_[i]),4) for i,col in enumerate(feature_cols)}
298
+ if hasattr(clf,"feature_importances_") else {col:0.0 for col in feature_cols})
299
+
300
+ sorted_shap=dict(sorted(mean_shap.items(),key=lambda x:x[1],reverse=True))
301
+ pred_labels=le.inverse_transform(y_pred)
302
+ results=[{"patient_id":str(row.get("patient_id",f"PT{i:04d}")),
303
+ "age":int(row.get("age",0)),"gender":str(row.get("gender","")),
304
+ "risk_level":le.inverse_transform([y_pred[i]])[0].capitalize(),
305
+ "confidence":round(float(proba[i].max()),3),
306
+ "key_values":{"hba1c_percent":round(float(row.get("hba1c_percent",0)),1),
307
+ "glucose_mg_dl":round(float(row.get("glucose_mg_dl",0)),1),
308
+ "bmi":round(float(row.get("bmi",0)),1)},
309
+ "shap_values":sorted_shap} for i,row in df.iterrows()]
310
+ alerts=[{"patient_id":r["patient_id"],"reasons":
311
+ (["HbA1c "+str(r["key_values"]["hba1c_percent"])+"%"] if r["key_values"]["hba1c_percent"]>8.0 else[])+
312
+ (["Glucose "+str(r["key_values"]["glucose_mg_dl"])+" mg/dL"] if r["key_values"]["glucose_mg_dl"]>180 else[])}
313
+ for r in results if r["risk_level"].lower()=="high" and
314
+ (r["key_values"]["hba1c_percent"]>8.0 or r["key_values"]["glucose_mg_dl"]>180)]
315
+
316
+ return safe_jsonify({"results":results,
317
+ "summary":{"high":int((pred_labels=="high").sum()),"medium":int((pred_labels=="medium").sum()),"low":int((pred_labels=="low").sum())},
318
+ "model_performance":{"accuracy":accuracy,"model_used":model_name,"n_patients":len(df)},
319
+ "feature_importance":sorted_shap,"alerts":alerts,
320
+ "using_default_data":using_default,"default_info":DEFAULT_INFO["health"] if using_default else None})
321
  except Exception as e:
322
  import traceback; print(traceback.format_exc())
323
+ return safe_jsonify({"error":str(e)}), 500
324
 
325
+ # ─────────────────────────────────────
326
+ # POST /api/coffee/forecast
327
+ # ─────────────────────────────────────
328
+ @app.route("/api/coffee/forecast", methods=["POST","OPTIONS"])
329
+ def coffee_forecast():
330
+ if request.method == "OPTIONS": return jsonify({}), 200
331
  try:
332
+ body=request.get_json(); data=body.get("data",[]); periods=body.get("periods",6)
333
+ model_type=body.get("model","prophet").lower(); branch=body.get("branch","ทุกสาขา")
334
+
335
+ using_default = not data
336
+ if using_default:
337
+ data = _def_coffee()
338
+ print("📊 /api/coffee/forecast: using default coffee data")
339
+
340
+ if len(data)<6: return safe_jsonify({"error":"ต้องการข้อมูลอย่างน้อย 6 เดือน"}), 400
341
+
342
+ df=pd.DataFrame(data); df.columns=["ds","y"]
343
+ df["ds"]=pd.to_datetime(df["ds"]); df=df.sort_values("ds").reset_index(drop=True)
344
+ y=df["y"].values; last_date=df["ds"].iloc[-1]
345
+ forecast_dates=pd.date_range(start=last_date,periods=periods+1,freq="MS")[1:]
346
+
347
+ if model_type=="prophet":
348
+ m=Prophet(yearly_seasonality=True,weekly_seasonality=False,daily_seasonality=False,
349
+ seasonality_mode="additive",changepoint_prior_scale=0.3,
350
+ seasonality_prior_scale=10,interval_width=0.90)
351
+ m.fit(df); future=m.make_future_dataframe(periods=periods,freq="MS"); result=m.predict(future)
352
+ fdf=result.tail(periods); preds=np.clip(fdf["yhat"].values,0,None)
353
+ lowers=np.clip(fdf["yhat_lower"].values,0,None); uppers=fdf["yhat_upper"].values; used_model="Prophet"
354
+ elif model_type in ["holt-winters","holtwinters"]:
355
+ from statsmodels.tsa.holtwinters import ExponentialSmoothing
356
+ sp=min(12,len(y)//2)
357
+ hw=ExponentialSmoothing(y,trend="add",seasonal="add",seasonal_periods=sp).fit()
358
+ preds=np.clip(hw.forecast(periods),0,None); std=np.std(y-hw.fittedvalues)*1.96
359
+ lowers=np.clip(preds-std,0,None); uppers=preds+std; used_model="Holt-Winters"
360
+ elif model_type=="arima":
361
+ from statsmodels.tsa.arima.model import ARIMA
362
+ fit=ARIMA(y,order=(2,1,2)).fit(); res=fit.get_forecast(steps=periods)
363
+ preds=np.clip(res.predicted_mean,0,None); ci=res.conf_int()
364
+ lowers=np.clip(ci.iloc[:,0].values,0,None); uppers=ci.iloc[:,1].values; used_model="ARIMA"
365
+ else:
366
+ return safe_jsonify({"error":f"ไม่รู้จัก model: {model_type}"}), 400
367
+
368
+ diff_pct=(preds[0]-y[-1])/y[-1]*100
369
+ trend="up" if diff_pct>2 else "down" if diff_pct<-2 else "stable"
370
+ avg_growth=round(float(np.mean(np.diff(preds)/preds[:-1]*100)),2) if len(preds)>1 else 0
371
+ mape=np.mean(np.abs((y-np.mean(y))/y))*100; confidence=max(60,min(95,int(100-mape)))
372
+ forecast_list=[{"period":d.strftime("%Y-%m"),"predicted":round(float(preds[i]),0),
373
+ "lower":round(float(lowers[i]),0),"upper":round(float(uppers[i]),0)}
374
+ for i,d in enumerate(forecast_dates)]
375
+ peak=forecast_list[int(np.argmax([f["predicted"] for f in forecast_list]))]["period"]
376
+
377
+ return safe_jsonify({"forecast":forecast_list,"trend":trend,"confidence":confidence,
378
+ "used_model":used_model,"branch":branch,"avg_monthly_growth_pct":avg_growth,
379
+ "peak_forecast_month":peak,"total_forecast_revenue":round(sum(f["predicted"] for f in forecast_list),0),
380
+ "historical_avg":round(float(np.mean(y)),0),
381
+ "using_default_data":using_default,"default_info":DEFAULT_INFO["coffee"] if using_default else None})
 
 
382
  except Exception as e:
383
  import traceback; print(traceback.format_exc())
384
+ return safe_jsonify({"error":str(e)}), 500
385
 
386
+ # ─────────────────────────────────────
387
+ # POST /api/coffee/menu-analysis
388
+ # ─────────────────────────────────────
389
+ @app.route("/api/coffee/menu-analysis", methods=["POST","OPTIONS"])
390
+ def coffee_menu_analysis():
391
+ if request.method == "OPTIONS": return jsonify({}), 200
392
  try:
393
+ body=request.get_json(); data=body.get("data",[]); branch=body.get("branch","ทุกสาขา")
394
+
395
+ using_default = not data
396
+ if using_default:
397
+ data = _def_menu()
398
+ print("📊 /api/coffee/menu-analysis: using default menu data")
399
+
400
+ df=pd.DataFrame(data)
401
+ if branch!="ทุกสาขา" and "branch" in df.columns: df=df[df["branch"]==branch]
402
+ required=["menu_name","total_qty","total_revenue","total_profit","margin_pct"]
403
+ missing=[c for c in required if c not in df.columns]
404
+ if missing: return safe_jsonify({"error":f"Missing columns: {missing}"}), 400
405
+
406
+ for col in ["total_qty","total_revenue","total_profit","margin_pct"]:
407
+ df[col]=pd.to_numeric(df[col],errors="coerce").fillna(0)
408
+ avg_qty=df["total_qty"].mean()
409
+ top_qty=df.nlargest(5,"total_qty")[["menu_name","total_qty","total_revenue","margin_pct"]].to_dict("records")
410
+ top_rev=df.nlargest(5,"total_revenue")[["menu_name","total_qty","total_revenue","margin_pct"]].to_dict("records")
411
+ low=df[df["total_qty"]<avg_qty*0.3].nsmallest(5,"total_qty")[["menu_name","total_qty","margin_pct"]].to_dict("records")
412
+ stars=df[(df["margin_pct"]>65)&(df["total_qty"]>avg_qty)][["menu_name","margin_pct","total_qty"]].to_dict("records")
413
+ recs=([{"menu":i["menu_name"],"action":"พิจารณาตัดเมนูหรือปรับราคา",
414
+ "reason":f"ขายได้ {i['total_qty']} ชิ้น — ต่ำกว่าค่าเฉลี่ย 70%"} for i in low]+
415
+ [{"menu":i["menu_name"],"action":"โปรโมตเพิ่ม — Margin ดี",
416
+ "reason":f"Margin {i['margin_pct']}% และขายดี"} for i in stars[:3]])
417
+
418
+ return safe_jsonify({"branch":branch,"total_menus":len(df),
419
+ "top_by_quantity":top_qty,"top_by_revenue":top_rev,"low_performers":low,
420
+ "high_margin_stars":stars,"recommendations":recs,
421
+ "summary":{"avg_margin_pct":round(float(df["margin_pct"].mean()),1),
422
+ "total_revenue":round(float(df["total_revenue"].sum()),0),
423
+ "total_profit":round(float(df["total_profit"].sum()),0),
424
+ "best_menu":df.loc[df["total_revenue"].idxmax(),"menu_name"]},
425
+ "using_default_data":using_default,"default_info":DEFAULT_INFO["menu"] if using_default else None})
 
 
 
 
 
 
 
 
426
  except Exception as e:
427
  import traceback; print(traceback.format_exc())
428
+ return safe_jsonify({"error":str(e)}), 500
429
 
430
+ # ─────────────────────────────────────
431
+ # POST /api/hr/classify
432
+ # ─────────────────────────────────────
433
+ @app.route("/api/hr/classify", methods=["POST","OPTIONS"])
434
+ def hr_classify():
435
+ if request.method == "OPTIONS": return jsonify({}), 200
436
+ try:
437
+ import time
438
+ t0=time.time()
439
+ body=request.get_json(); data=body.get("data",[]); model_type=body.get("model","xgboost").lower()
440
+
441
+ using_default = not data
442
+ if using_default:
443
+ data = _def_hr()
444
+ print("📊 /api/hr/classify: using default HR data")
445
+
446
+ if len(data)<10: return safe_jsonify({"error":"ต้องการข้อมูลอย่างน้อย 10 แถว"}), 400
447
+
448
+ from sklearn.model_selection import train_test_split
449
+ from sklearn.metrics import accuracy_score,recall_score,precision_score,f1_score,roc_auc_score
450
+
451
+ df=pd.DataFrame(data)
452
+ num_features=['age','years_at_company','years_in_current_role','salary_thb','salary_hike_pct',
453
+ 'distance_from_home_km','overtime_hours_monthly','monthly_absent_days',
454
+ 'satisfaction_score','performance_rating','num_projects','training_hours_yearly',
455
+ 'promotion_last_3years','work_life_balance','job_involvement','manager_rating']
456
+ cat_features=['department','gender','education']
457
+ missing=[c for c in num_features if c not in df.columns]
458
+ if missing: return safe_jsonify({"error":f"Missing columns: {missing}"}), 400
459
+ for c in cat_features:
460
+ if c not in df.columns: df[c]='Unknown'
461
+
462
+ df_enc=pd.get_dummies(df[num_features+cat_features+(['resigned'] if 'resigned' in df.columns else [])],columns=cat_features)
463
+ feature_cols=[c for c in df_enc.columns if c!='resigned']
464
+ has_label='resigned' in df.columns
465
+ X=df_enc[feature_cols].fillna(0)
466
+ y=df_enc['resigned'].astype(int) if has_label else None
467
+
468
+ if has_label and len(df)>=20:
469
+ X_train,X_test,y_train,y_test=train_test_split(X,y,test_size=0.2,random_state=42,
470
+ stratify=y if y.sum()>=2 else None)
471
+ else:
472
+ X_train,X_test=X,X; y_train=y if has_label else pd.Series([0]*len(X)); y_test=y_train
473
+
474
+ results={}; models_to_run=['logistic','random_forest','xgboost'] if model_type=='all' else [model_type]
475
+ for m_name in models_to_run:
476
+ if m_name=='logistic':
477
+ clf=Pipeline([('scaler',StandardScaler()),('clf',LogisticRegression(max_iter=1000,random_state=42))]); label='Logistic Regression'
478
+ elif m_name=='random_forest':
479
+ clf=RandomForestClassifier(n_estimators=100,random_state=42); label='Random Forest'
480
+ else:
481
+ clf=GradientBoostingClassifier(n_estimators=100,random_state=42); label='XGBoost'
482
+ clf.fit(X_train,y_train); y_pred=clf.predict(X_test); y_prob=clf.predict_proba(X_test)[:,1]
483
+ perf={} if not has_label else {
484
+ "accuracy_pct":round(accuracy_score(y_test,y_pred)*100,1),
485
+ "recall_pct":round(recall_score(y_test,y_pred,zero_division=0)*100,1),
486
+ "precision_pct":round(precision_score(y_test,y_pred,zero_division=0)*100,1),
487
+ "f1_pct":round(f1_score(y_test,y_pred,zero_division=0)*100,1),
488
+ "auc":round(roc_auc_score(y_test,y_prob) if len(y_test.unique())>1 else 0.5,3)}
489
+ if hasattr(clf,'feature_importances_'):
490
+ fi=sorted(zip(feature_cols,clf.feature_importances_),key=lambda x:x[1],reverse=True)[:10]
491
+ elif hasattr(clf,'named_steps'):
492
+ coef=clf.named_steps['clf'].coef_[0]
493
+ fi=sorted(zip(feature_cols,abs(coef)),key=lambda x:x[1],reverse=True)[:10]
494
+ else: fi=[]
495
+ results[m_name]={"model_name":label,"performance":perf,
496
+ "feature_importance":[{"feature":k,"importance":round(float(v),4)} for k,v in fi]}
497
+
498
+ threshold=float(body.get("threshold",0.5)); threshold=max(0.1,min(0.9,threshold))
499
+ best_model_name=model_type if model_type!='all' else 'xgboost'
500
+ if best_model_name not in results: best_model_name=list(results.keys())[0]
501
+
502
+ if best_model_name=='logistic':
503
+ best_clf=Pipeline([('scaler',StandardScaler()),('clf',LogisticRegression(max_iter=1000,random_state=42))])
504
+ elif best_model_name=='random_forest':
505
+ best_clf=RandomForestClassifier(n_estimators=100,random_state=42)
506
+ else:
507
+ best_clf=GradientBoostingClassifier(n_estimators=100,random_state=42)
508
 
509
+ best_clf.fit(X_train,y_train); all_probs=best_clf.predict_proba(X)[:,1]
510
+ all_preds=(all_probs>=threshold).astype(int)
511
+
512
+ predictions=[]
513
+ for i,(_,row) in enumerate(df.iterrows()):
514
+ prob=float(all_probs[i]); risk='High' if prob>=0.6 else 'Medium' if prob>=0.3 else 'Low'
515
+ predictions.append({"employee_id":str(row.get('employee_id',f'EMP{i:04d}')),
516
+ "department":str(row.get('department','')),"position":str(row.get('position','')),
517
+ "resign_probability":round(prob,3),"risk_level":risk,
518
+ "actual_resigned":int(row['resigned']) if 'resigned' in row else None,
519
+ "key_factors":{"satisfaction_score":float(row.get('satisfaction_score',0)),
520
+ "overtime_hours":float(row.get('overtime_hours_monthly',0)),
521
+ "salary_thb":float(row.get('salary_thb',0)),
522
+ "years_at_company":float(row.get('years_at_company',0))}})
523
+
524
+ high=sum(1 for p in predictions if p['risk_level']=='High')
525
+ medium=sum(1 for p in predictions if p['risk_level']=='Medium')
526
+ low=sum(1 for p in predictions if p['risk_level']=='Low')
527
+ dept_risk={}
528
+ for p in predictions:
529
+ dept=p['department']
530
+ if dept not in dept_risk: dept_risk[dept]={'count':0,'high_risk':0,'total_prob':0}
531
+ dept_risk[dept]['count']+=1; dept_risk[dept]['total_prob']+=p['resign_probability']
532
+ if p['risk_level']=='High': dept_risk[dept]['high_risk']+=1
533
+ dept_summary=[{"department":dept,"total":v['count'],"high_risk":v['high_risk'],
534
+ "avg_resign_prob":round(v['total_prob']/v['count']*100,1)}
535
+ for dept,v in sorted(dept_risk.items(),key=lambda x:x[1]['high_risk'],reverse=True)]
536
+ elapsed=round(time.time()-t0,1)
537
+
538
+ return safe_jsonify({"total_employees":len(df),"model_requested":model_type,
539
+ "processing_time_sec":elapsed,
540
+ "risk_summary":{"high_risk":high,"medium_risk":medium,"low_risk":low,
541
+ "high_risk_pct":round(high/len(df)*100,1)},
542
+ "department_summary":dept_summary,"model_results":results,"best_model":best_model_name,
543
+ "predictions":sorted(predictions,key=lambda x:x['resign_probability'],reverse=True),
544
+ "top_at_risk":sorted(predictions,key=lambda x:x['resign_probability'],reverse=True)[:10],
545
+ "threshold_used":threshold,"threshold_analysis":[],
546
+ "using_default_data":using_default,"default_info":DEFAULT_INFO["hr"] if using_default else None,
547
+ "disclaimer":"ผลการพยากรณ์ใช้เพื่อสนับสนุนการตัดสินใจ HR เท่านั้น"})
548
+ except Exception as e:
549
+ import traceback; print(traceback.format_exc())
550
+ return safe_jsonify({"error":str(e)}), 500
551
+
552
+ # ─────────────────────────────────────
553
+ # POST /api/lottery/analyze
554
+ # ─────────────────────────────────────
555
+ @app.route("/api/lottery/analyze", methods=["POST","OPTIONS"])
556
+ def lottery_analyze():
557
+ if request.method == "OPTIONS": return jsonify({}), 200
558
  try:
559
+ body=request.get_json(); data=body.get("data",[])
560
+
561
+ using_default = not data
562
+ if using_default:
563
+ data = _def_lottery()
564
+ print("📊 /api/lottery/analyze: using default lottery data")
565
+
566
+ if len(data)<10: return safe_jsonify({"error":"ต้องการข้อมูลอย่างน้อย 10 งวด"}), 400
567
+
568
+ df=pd.DataFrame(data)
569
+ if "last2_num" not in df.columns: return safe_jsonify({"error":"Missing column: last2_num"}), 400
570
+ sequence=df["last2_num"].astype(int).values; n=len(sequence)
571
+
572
+ counter={}
573
+ for v in sequence: k=f"{int(v):02d}"; counter[k]=counter.get(k,0)+1
574
+ sorted_freq=sorted(counter.items(),key=lambda x:x[1],reverse=True)
575
+ hot=[{"number":k,"count":v} for k,v in sorted_freq[:10]]
576
+ cold=[{"number":k,"count":v} for k,v in sorted_freq[-10:]]
577
+ never=[f"{i:02d}" for i in range(100) if f"{i:02d}" not in counter]
578
+
579
+ transitions={}
580
+ for i in range(len(sequence)-1):
581
+ c=int(sequence[i]); nx=int(sequence[i+1])
582
+ if c not in transitions: transitions[c]={}
583
+ transitions[c][nx]=transitions[c].get(nx,0)+1
584
+
585
+ last_num=int(sequence[-1])
586
+ if last_num in transitions:
587
+ mn=sorted(transitions[last_num].items(),key=lambda x:x[1],reverse=True)[:5]
588
+ markov_predictions=[{"number":f"{k:02d}","count":v,
589
+ "probability":round(v/sum(transitions[last_num].values())*100,1)} for k,v in mn]
590
+ else:
591
+ markov_predictions=[{"number":hot[0]["number"],"count":hot[0]["count"],
592
+ "probability":round(hot[0]["count"]/n*100,1)}]
593
+
594
+ tens_count={str(d):0 for d in range(10)}; units_count={str(d):0 for d in range(10)}
595
+ for v in sequence: tens_count[str(int(v)//10)]+=1; units_count[str(int(v)%10)]+=1
596
+
597
+ TRAIN=min(40,n//2); test_seq=sequence[TRAIN:]; correct=0
598
+ for i in range(len(test_seq)):
599
+ c2={}
600
+ for v in sequence[:TRAIN+i]: c2[int(v)]=c2.get(int(v),0)+1
601
+ if c2 and max(c2,key=c2.get)==int(test_seq[i]): correct+=1
602
+ freq_accuracy=round(correct/len(test_seq)*100,2) if test_seq.size>0 else 0
603
+
604
+ from scipy import stats as scipy_stats
605
+ obs=[counter.get(f"{i:02d}",0) for i in range(100)]; exp=[n/100]*100
606
+ chi2,p_value=scipy_stats.chisquare(obs,exp)
607
+
608
+ return safe_jsonify({"total_draws":n,
609
+ "frequency":{"hot_numbers":hot,"cold_numbers":cold,"never_appeared":never,"never_count":len(never)},
610
+ "digit_analysis":{"tens":tens_count,"units":units_count},
611
+ "markov":{"last_number":f"{last_num:02d}","predictions":markov_predictions},
612
+ "predictions":{"frequency_model":hot[0]["number"] if hot else "00",
613
+ "markov_model":markov_predictions[0]["number"] if markov_predictions else "00",
614
+ "disclaimer":"การพยากรณ์นี้ใช้เพื่อการศึกษาสถิติเท่านั้น"},
615
+ "model_performance":{"frequency_accuracy_pct":freq_accuracy,"random_baseline_pct":1.0,
616
+ "test_draws":len(test_seq),"note":"Accuracy ใกล้เคียง Random = ไม่มี pattern"},
617
+ "statistical_test":{"chi_square":round(float(chi2),2),"p_value":round(float(p_value),4),
618
+ "is_random":bool(p_value>0.05),
619
+ "interpretation":"สุ่มจริง ไม่มี pattern" if p_value>0.05 else "อาจมี pattern"},
620
+ "using_default_data":using_default,"default_info":DEFAULT_INFO["lottery"] if using_default else None})
621
+ except Exception as e:
622
+ import traceback; print(traceback.format_exc())
623
+ return safe_jsonify({"error":str(e)}), 500
624
+
625
+ # ─────────────────────────────────────
626
+ # POST /api/sentiment/analyze
627
+ # ─────────────────────────────────────
628
+ @app.route("/api/sentiment/analyze", methods=["POST","OPTIONS"])
629
+ def sentiment_analyze():
630
+ if request.method == "OPTIONS": return jsonify({}), 200
631
+ try:
632
+ import time; t0=time.time()
633
+ from sklearn.feature_extraction.text import TfidfVectorizer
634
+ from sklearn.model_selection import train_test_split
635
+ from sklearn.metrics import accuracy_score,f1_score,confusion_matrix
636
+ from collections import Counter
637
+
638
+ body=request.get_json(); data=body.get("data",[]); model_type=body.get("model","logistic").lower()
639
+
640
+ using_default = not data
641
+ if using_default:
642
+ data = _def_reviews()
643
+ print("📊 /api/sentiment/analyze: using default reviews data")
644
+
645
+ if len(data)<5: return safe_jsonify({"error":"ต้องการข้อมูลอย่างน้อย 5 reviews"}), 400
646
+
647
+ df=pd.DataFrame(data)
648
+ if "review_text" not in df.columns: return safe_jsonify({"error":"Missing column: review_text"}), 400
649
+ df["review_text"]=df["review_text"].fillna("").astype(str)
650
+ has_label="sentiment" in df.columns and df["sentiment"].notna().sum()>0
651
+ X=df["review_text"]; results={}; pipes={}
652
+ models_to_run=["logistic","random_forest","xgboost"] if model_type=="all" else [model_type]
653
+
654
+ if has_label:
655
+ y=df["sentiment"].fillna("Neutral")
656
+ if len(df)>=20:
657
+ X_tr,X_te,y_tr,y_te=train_test_split(X,y,test_size=0.2,random_state=42,
658
+ stratify=y if y.value_counts().min()>=2 else None)
659
+ else: X_tr,X_te,y_tr,y_te=X,X,y,y
660
  else:
661
+ def rule_label(text):
662
+ tl=text.lower()
663
+ pos_w=["ดี","เยี่ยม","ประทับใจ","แนะนำ","คุ้ม","ชอบ","excellent","great","perfect","love","good","satisfied"]
664
+ neg_w=["แย่","ผิดหวัง","ห่วย","ช้า","เสีย","ไม่ดี","terrible","worst","poor","bad","horrible","disappointed"]
665
+ p=sum(1 for w in pos_w if w in tl); n=sum(1 for w in neg_w if w in tl)
666
+ return "Positive" if p>n else "Negative" if n>p else "Neutral"
667
+ y=X.apply(rule_label); X_tr,X_te,y_tr,y_te=X,X,y,y
668
+
669
+ for m_name in models_to_run:
670
+ if m_name=="logistic": clf=LogisticRegression(max_iter=1000,C=0.5,random_state=42); label="Logistic + TF-IDF"
671
+ elif m_name=="random_forest": clf=RandomForestClassifier(n_estimators=100,random_state=42); label="Random Forest + TF-IDF"
672
+ else: clf=GradientBoostingClassifier(n_estimators=100,random_state=42); label="XGBoost + TF-IDF"
673
+ pipe=Pipeline([("tfidf",TfidfVectorizer(max_features=1500,ngram_range=(1,2),sublinear_tf=True,min_df=1)),("clf",clf)])
674
+ pipe.fit(X_tr,y_tr); y_pred=pipe.predict(X_te)
675
+ perf={}
676
+ if has_label and len(set(y_te))>1:
677
+ perf={"accuracy_pct":round(accuracy_score(y_te,y_pred)*100,1),
678
+ "f1_macro_pct":round(f1_score(y_te,y_pred,average="macro",zero_division=0)*100,1),
679
+ "f1_weighted_pct":round(f1_score(y_te,y_pred,average="weighted",zero_division=0)*100,1)}
680
+ tfidf_step=pipe.named_steps["tfidf"]; feature_names=tfidf_step.get_feature_names_out()
681
+ keywords={}
682
+ for cls in ["Positive","Negative","Neutral"]:
683
+ mask=y_tr==cls
684
+ if mask.sum()>0:
685
+ vecs=tfidf_step.transform(X_tr[mask]); mean_sc=vecs.mean(axis=0).A1
686
+ top_idx=mean_sc.argsort()[-10:][::-1]
687
+ keywords[cls]=[str(feature_names[i]) for i in top_idx if len(str(feature_names[i]))>1][:8]
688
+ results[m_name]={"model_name":label,"performance":perf,"keywords":keywords}
689
+ pipes[m_name]=pipe
690
+
691
+ best_name=model_type if model_type!="all" else "logistic"
692
+ if best_name not in pipes: best_name=list(pipes.keys())[0]
693
+ best_pipe=pipes[best_name]; all_preds=best_pipe.predict(X)
694
+ all_proba=best_pipe.predict_proba(X); all_classes=best_pipe.classes_
695
+
696
+ predictions=[]
697
+ for i,row in df.iterrows():
698
+ prob_arr=all_proba[i]; pred=all_preds[i]; conf=float(prob_arr.max())
699
+ prob_dict={c:round(float(p)*100,1) for c,p in zip(all_classes,prob_arr)}
700
+ predictions.append({"review_id":str(row.get("review_id",f"REV{i:04d}")),
701
+ "review_text":str(row.get("review_text",""))[:200],
702
+ "channel":str(row.get("channel","")),"branch":str(row.get("branch","")),
703
+ "category":str(row.get("category","")),"date":str(row.get("date","")),
704
+ "rating":int(row["rating"]) if "rating" in row and pd.notna(row.get("rating")) else None,
705
+ "actual_sentiment":str(row.get("sentiment","")) if has_label else None,
706
+ "predicted_sentiment":pred,"confidence":round(conf,3),"probabilities":prob_dict})
707
+
708
+ pred_counts=Counter(all_preds); total=len(predictions)
709
+ sentiment_summary={
710
+ "Positive":pred_counts.get("Positive",0),"Neutral":pred_counts.get("Neutral",0),"Negative":pred_counts.get("Negative",0),
711
+ "positive_pct":round(pred_counts.get("Positive",0)/total*100,1),
712
+ "neutral_pct":round(pred_counts.get("Neutral",0)/total*100,1),
713
+ "negative_pct":round(pred_counts.get("Negative",0)/total*100,1),
714
+ "net_sentiment_score":round((pred_counts.get("Positive",0)-pred_counts.get("Negative",0))/total*100,1)}
715
+
716
+ channel_breakdown={}
717
+ if "channel" in df.columns:
718
+ for ch in df["channel"].dropna().unique():
719
+ mask=df["channel"]==ch; ch_preds=[p["predicted_sentiment"] for p,m in zip(predictions,mask) if m]
720
+ c2=Counter(ch_preds); n2=len(ch_preds)
721
+ channel_breakdown[ch]={"total":n2,"Positive":c2.get("Positive",0),"Negative":c2.get("Negative",0),"Neutral":c2.get("Neutral",0),
722
+ "positive_pct":round(c2.get("Positive",0)/n2*100,1) if n2 else 0,
723
+ "negative_pct":round(c2.get("Negative",0)/n2*100,1) if n2 else 0}
724
+
725
+ monthly_trend={}
726
+ if "month" in df.columns:
727
+ for mo in sorted(df["month"].dropna().unique()):
728
+ mask=df["month"]==mo; mo_preds=[p["predicted_sentiment"] for p,m in zip(predictions,mask) if m]
729
+ c4=Counter(mo_preds)
730
+ monthly_trend[mo]={"Positive":c4.get("Positive",0),"Negative":c4.get("Negative",0),"Neutral":c4.get("Neutral",0)}
731
+
732
+ elapsed=round(time.time()-t0,1)
733
+ return safe_jsonify({"total_reviews":total,"model_requested":model_type,"best_model":best_name,
734
+ "processing_time_sec":elapsed,"has_label":has_label,"sentiment_summary":sentiment_summary,
735
+ "channel_breakdown":channel_breakdown,"monthly_trend":monthly_trend,
736
+ "model_results":results,"predictions":predictions,
737
+ "top_negative":sorted([p for p in predictions if p["predicted_sentiment"]=="Negative"],
738
+ key=lambda x:x["confidence"],reverse=True)[:10],
739
+ "top_positive":sorted([p for p in predictions if p["predicted_sentiment"]=="Positive"],
740
+ key=lambda x:x["confidence"],reverse=True)[:5],
741
+ "using_default_data":using_default,"default_info":DEFAULT_INFO["reviews"] if using_default else None})
742
  except Exception as e:
743
  import traceback; print(traceback.format_exc())
744
+ return safe_jsonify({"error":str(e)}), 500
745
 
746
  if __name__ == "__main__":
747
  port = int(os.environ.get("PORT", 7860))
748
+ app.run(host="0.0.0.0", port=port)