LiamKhoaLe commited on
Commit
60369a6
·
1 Parent(s): 9b7bb28

Migrate to FastAPI service. Add db load and deletion endpoint

Browse files
Files changed (4) hide show
  1. .gitignore +2 -1
  2. Dockerfile +5 -8
  3. app.py +149 -209
  4. requirements.txt +2 -0
.gitignore CHANGED
@@ -1 +1,2 @@
1
- .env
 
 
1
+ .env
2
+ test
Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
  FROM python:3.11-slim
2
 
3
- # Create and switch to non-root user
4
  RUN useradd -m -u 1000 user
5
  USER user
6
 
@@ -8,15 +8,12 @@ USER user
8
  ENV HOME=/home/user
9
  WORKDIR $HOME/app
10
 
11
- # Install Python deps
12
  COPY --chown=user requirements.txt .
13
- RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
14
 
15
- # Create writable cache folders
16
- RUN mkdir -p $HOME/app/cache
17
-
18
- # Copy all source code
19
  COPY --chown=user . .
 
20
 
21
- # Run the app
22
  CMD ["python", "app.py"]
 
1
  FROM python:3.11-slim
2
 
3
+ # Create & use non-root user
4
  RUN useradd -m -u 1000 user
5
  USER user
6
 
 
8
  ENV HOME=/home/user
9
  WORKDIR $HOME/app
10
 
11
+ # Installation
12
  COPY --chown=user requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
 
15
+ # Caching
 
 
 
16
  COPY --chown=user . .
17
+ RUN mkdir -p cache
18
 
 
19
  CMD ["python", "app.py"]
app.py CHANGED
@@ -1,9 +1,11 @@
1
- import os
2
- import json
3
- import signal
4
- import logging
5
- import threading
6
- import time
 
 
7
  from datetime import datetime, timedelta
8
  from queue import Queue, Empty
9
 
@@ -14,267 +16,205 @@ import numpy as np
14
  from sklearn.impute import KNNImputer
15
  from sklearn.linear_model import LinearRegression
16
  from pymongo import MongoClient, errors as mongo_errors
17
-
18
- # ───────────────────────── ENV / CONFIG ──────────────────────────
19
- load_dotenv() # reads .env in container root
20
-
21
- # Topic and APIs
22
- BROKER = os.getenv("BROKER")
23
- PORT = int(os.getenv("PORT", 1883))
24
- USERNAME = os.getenv("USERNAME")
25
- PASSWORD = os.getenv("PASSWORD")
26
- MQTT_TOPIC = os.getenv("MQTT_TOPIC", "device/socket/reply/#")
27
-
28
- # Mongo string
29
- MONGO_URI = os.getenv("MONGO_URI")
30
- MONGO_DB = os.getenv("MONGO_DB", "poptech")
31
- MONGO_COL = os.getenv("MONGO_COLLECTION", "device_clean")
32
-
33
- # Prediction and cleaning prefixes
34
- BATCH_SECONDS = int(os.getenv("WINDOW_SECONDS", 3600)) # 1 h default (suggesting ~15-30m on session saver on deployment stage)
35
- EXPECTED_INTERVAL_SEC = int(os.getenv("EXPECTED_INTERVAL_SEC", 30))
36
- TOLERANCE_SEC = int(os.getenv("TOLERANCE_SEC", 2))
37
-
38
- # Write checkpoint file as cacheable
39
  RAW_CHECKPOINT_PATH = os.getenv("RAW_CHECKPOINT_PATH", "cache/checkpoint_raw.csv")
 
 
40
  os.makedirs(os.path.dirname(RAW_CHECKPOINT_PATH), exist_ok=True)
41
 
42
- # ───────────────────────── LOGGING ───────────────────────────────
43
  logging.basicConfig(
44
  level=logging.DEBUG,
45
  format="%(asctime)s — %(name)s — %(levelname)s — %(message)s",
46
- force=True,
47
  )
48
  logger = logging.getLogger("poptech-cleaner")
49
- for m in ["pymongo", "pymongo.server_selection",
50
- "pymongo.topology", "pymongo.connection"]:
51
  logging.getLogger(m).setLevel(logging.WARNING)
52
- logger.info("🚀 Starting PopTech Electrical Cleaning Pipeline...")
53
 
54
- # ───────────────────────── GLOBALS ───────────────────────────────
55
- queue_raw = Queue() # MQTT → queue → batch thread
56
- stop_event = threading.Event()
 
57
 
58
-
59
- # ──────────────────────── MQTT CALLBACKS ─────────────────────────
60
  def on_connect(client, userdata, flags, rc):
61
  if rc == 0:
62
  logger.info("✅ Connected to MQTT broker")
63
- client.subscribe(MQTT_TOPIC, qos=0)
64
  else:
65
- logger.error(f"❌ MQTT connection failed with code {rc}")
66
-
67
 
 
68
  def on_message(client, userdata, msg):
69
- """Push raw line (timestamp, topic, payload) onto in-memory queue + CSV checkpoint"""
70
  ts = datetime.utcnow().isoformat()
71
  payload = msg.payload.decode(errors="replace")
72
- row_dict = {"timestamp": ts, "topic": msg.topic, "payload": payload}
73
- queue_raw.put(row_dict)
74
 
75
- # Log every received message (even before parsing)
76
  try:
77
  data = json.loads(payload.replace('""', '"')).get("data", [])
78
- voltage = data[0] if len(data) > 0 else None
79
- current = data[1] if len(data) > 1 else None
80
- power = data[2] if len(data) > 2 else None
81
- consume = data[3] if len(data) > 3 else None
82
- logger.info(f"📩 MQTT received: timestamp: {ts}, voltage: {voltage}V, current: {current}A, power: {power}W, consume: {consume}mWh")
83
- except Exception as e:
84
- logger.warning(f"⚠️ Failed to parse MQTT message: {e} | payload: {payload}")
85
 
86
- # Append to cache CSV
87
  try:
88
  with open(RAW_CHECKPOINT_PATH, "a", encoding="utf-8") as f:
89
- f.write(f'{ts},{msg.topic},"{payload.replace(chr(34)*2, chr(34))}"\n')
90
  except Exception as e:
91
- logger.error(f"❌ Could not write checkpoint log: {e}")
92
 
93
-
94
- # ───────────────────────── CORE LOGIC ────────────────────────────
95
  def parse_and_filter(raw_rows):
96
- """
97
- raw_rows: list[dict] from MQTT queue
98
- returns: pd.DataFrame ready for cleaning
99
- """
100
- parsed_rows = []
101
-
102
  for r in raw_rows:
103
  try:
104
  payload = json.loads(r["payload"].replace('""', '"'))
105
- if not isinstance(payload, dict):
106
- continue
107
- # keep only valid socket rows
108
- if not r["topic"].startswith("device/socket/reply/"):
109
- continue
110
-
111
- data = payload.get("data", [])
112
- if not (isinstance(data, list) and len(data) >= 4):
113
- continue
114
-
115
- voltage, current, power, consume = data[:4]
116
- # drop idle frames where current, power, consume are 0 or None
117
- if all(v in (0, None) for v in (current, power, consume)):
118
- continue
119
-
120
- parsed_rows.append(
121
- {
122
- "timestamp": r["timestamp"],
123
- "id": payload.get("id"),
124
- "imei": payload.get("imei"),
125
- "type": payload.get("type"),
126
- "voltage": float(voltage),
127
- "current": float(current),
128
- "power": float(power),
129
- "consume": float(consume),
130
- }
131
- )
132
- except Exception as e:
133
- logger.debug(f"⚠️ Skipping malformed payload: {e}")
134
-
135
- return pd.DataFrame(parsed_rows)
136
-
137
-
138
- def fill_missing(df: pd.DataFrame) -> pd.DataFrame:
139
- """
140
- Detect >30 ± 2 s gaps; insert empty rows; impute/predict.
141
- """
142
- if df.empty:
143
- return df
144
 
 
 
 
145
  df["timestamp"] = pd.to_datetime(df["timestamp"])
146
- df = df.sort_values("timestamp").reset_index(drop=True)
147
-
148
  expected = timedelta(seconds=EXPECTED_INTERVAL_SEC)
149
- tol = timedelta(seconds=TOLERANCE_SEC)
150
-
151
- filled_rows = [df.iloc[0]]
152
- missing_count = 0
153
-
154
  for i in range(1, len(df)):
155
- prev, cur = df.iloc[i - 1]["timestamp"], df.iloc[i]["timestamp"]
156
- delta = cur - prev
157
-
158
- filled_rows.append(df.iloc[i])
159
-
160
- if delta > expected + tol:
161
- gaps = int(round(delta / expected)) - 1
162
- missing_count += gaps
163
-
164
- for j in range(1, gaps + 1):
165
- ts_gap = prev + j * expected
166
- newrow = df.iloc[i - 1].copy()
167
- newrow["timestamp"] = ts_gap
168
  for col in ["voltage", "current", "power", "consume"]:
169
- newrow[col] = np.nan
170
- filled_rows.insert(-1, newrow)
171
-
172
- df_full = pd.DataFrame(filled_rows).sort_values("timestamp").reset_index(drop=True)
 
173
 
174
- # --- cleansing & model-based consume reconstruction -------------
175
- feature_cols = ["voltage", "current", "power"]
176
- target_col = "consume"
177
-
178
- df_full["consume_clean"] = df_full[target_col]
179
- diff = df_full["consume_clean"].diff()
180
- df_full.loc[(df_full["consume_clean"] < 0) | (diff < 0), "consume_clean"] = np.nan
181
-
182
- # KNN impute V, A, W
183
  imputer = KNNImputer(n_neighbors=3)
184
- X_raw = df_full[feature_cols]
185
- X_filled= pd.DataFrame(imputer.fit_transform(X_raw), columns=feature_cols)
186
- df_full[feature_cols] = X_filled
187
-
188
- # predict missing consume via linear regression
189
- train = df_full[df_full["consume_clean"].notna()]
190
- pred = df_full[df_full["consume_clean"].isna()]
191
- if not pred.empty and not train.empty:
192
- model = LinearRegression().fit(train[feature_cols], train["consume_clean"])
193
- df_full.loc[pred.index, "consume_clean"] = model.predict(df_full.loc[pred.index, feature_cols])
194
-
195
- df_full[target_col] = df_full["consume_clean"]
196
- df_full.drop(columns=["consume_clean"], inplace=True)
197
-
198
- logger.info(f"✨ Clean batch: {len(df)} ➜ {len(df_full)} rows "
199
- f"(filled {missing_count} gaps)")
200
- return df_full
201
-
202
-
203
- def insert_mongo(df: pd.DataFrame):
204
- """
205
- Upsert cleaned docs into MongoDB.
206
- """
207
- if df.empty:
208
- return
209
-
210
  try:
211
- client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000)
212
- collection = client[MONGO_DB][MONGO_COL]
213
-
214
- # ensure timestamp uniqueness to avoid duplicates
215
- collection.create_index("timestamp", unique=True)
216
-
217
  records = df.to_dict("records")
218
- for rec in records:
219
- rec["_id"] = rec["timestamp"] # quick natural key
220
- collection.bulk_write(
221
- [pymongo.UpdateOne({"_id": r["_id"]}, {"$set": r}, upsert=True) for r in records],
222
- ordered=False,
223
- )
224
- logger.info(f"📥 MongoDB: upserted {len(records)} docs")
225
- except mongo_errors.BulkWriteError as bwe:
226
- logger.debug("Duplicate records skipped")
227
  except Exception as e:
228
- logger.error(f"❌ Mongo insert failed: {e}")
229
-
230
 
 
231
  def batch_worker():
232
- """
233
- Every BATCH_SECONDS pull everything from queue, process, store.
234
- """
235
  while not stop_event.is_set():
236
  time.sleep(BATCH_SECONDS)
237
-
238
  bundle = []
239
- try:
240
- while True:
241
- bundle.append(queue_raw.get_nowait())
242
- except Empty:
243
- pass # queue drained
244
-
245
  if not bundle:
246
- logger.debug("⏱️ Batch tick – no new data")
247
  continue
248
-
249
- df_parsed = parse_and_filter(bundle)
250
- df_clean = fill_missing(df_parsed)
251
  insert_mongo(df_clean)
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
- # ───────────────────────── RUNTIME ───────────────────────────────
255
- def main():
256
- # start batch thread
257
- thr = threading.Thread(target=batch_worker, daemon=True)
258
- thr.start()
259
 
260
- # MQTT client in main thread
 
 
261
  client = mqtt.Client()
262
  client.username_pw_set(USERNAME, PASSWORD)
263
  client.on_connect = on_connect
264
  client.on_message = on_message
265
  client.connect(BROKER, PORT, 60)
266
-
267
- # graceful SIGTERM/SIGINT (HF Space shutdown)
268
- def handle_exit(signum, frame):
269
- logger.info("🛑 Shutdown signal received")
270
- stop_event.set()
271
- client.disconnect()
272
- for sig in (signal.SIGTERM, signal.SIGINT):
273
- signal.signal(sig, handle_exit)
274
-
275
- # blocking loop
276
  client.loop_forever()
277
 
 
278
 
279
  if __name__ == "__main__":
280
- main()
 
1
+ # app.py
2
+ # Root API: https://binkhoale1812-poptech-cleaner.hf.space/
3
+ # Usages:
4
+ ## https://binkhoale1812-poptech-cleaner.hf.space/fetch?Password=...
5
+ ## https://binkhoale1812-poptech-cleaner.hf.space/load?Password=...
6
+ ## https://binkhoale1812-poptech-cleaner.hf.space/delete?Password=...
7
+
8
+ import os, json, signal, logging, threading, time
9
  from datetime import datetime, timedelta
10
  from queue import Queue, Empty
11
 
 
16
  from sklearn.impute import KNNImputer
17
  from sklearn.linear_model import LinearRegression
18
  from pymongo import MongoClient, errors as mongo_errors
19
+ from fastapi import FastAPI, HTTPException
20
+ from fastapi.responses import JSONResponse, FileResponse
21
+ import uvicorn
22
+
23
+ # ─────── ENV CONFIG ───────
24
+ load_dotenv()
25
+
26
+ BROKER = os.getenv("BROKER")
27
+ PORT = int(os.getenv("PORT", 1883))
28
+ USERNAME = os.getenv("USERNAME")
29
+ PASSWORD = os.getenv("PASSWORD")
30
+ MQTT_TOPIC = os.getenv("MQTT_TOPIC", "device/socket/reply/#")
31
+
32
+ MONGO_URI = os.getenv("MONGO_URI")
33
+ MONGO_DB = os.getenv("MONGO_DB", "poptech")
34
+ MONGO_COL = os.getenv("MONGO_COLLECTION", "device_clean")
35
+ FETCH_PASS = os.getenv("FETCH_PASSWORD")
36
+
37
+ BATCH_SECONDS = int(os.getenv("WINDOW_SECONDS", 1800))
38
+ EXPECTED_INTERVAL_SEC = int(os.getenv("EXPECTED_INTERVAL_SEC", 30))
39
+ TOLERANCE_SEC = int(os.getenv("TOLERANCE_SEC", 2))
 
40
  RAW_CHECKPOINT_PATH = os.getenv("RAW_CHECKPOINT_PATH", "cache/checkpoint_raw.csv")
41
+ EXPORT_CSV_PATH = "mongo_cleaned_export.csv"
42
+
43
  os.makedirs(os.path.dirname(RAW_CHECKPOINT_PATH), exist_ok=True)
44
 
45
+ # ─────── LOGGING ───────
46
  logging.basicConfig(
47
  level=logging.DEBUG,
48
  format="%(asctime)s — %(name)s — %(levelname)s — %(message)s",
49
+ force=True
50
  )
51
  logger = logging.getLogger("poptech-cleaner")
52
+ for m in ["pymongo", "pymongo.server_selection", "pymongo.topology", "pymongo.connection"]:
 
53
  logging.getLogger(m).setLevel(logging.WARNING)
54
+ logger.info("🚀 PopTech FastAPI Cleaning Server starting...")
55
 
56
+ # ──────────── GLOBALS ─────────────────
57
+ queue_raw = Queue()
58
+ stop_event = threading.Event()
59
+ app = FastAPI()
60
 
61
+ # ─────────── MQTT CALLBACKS ───────────
 
62
  def on_connect(client, userdata, flags, rc):
63
  if rc == 0:
64
  logger.info("✅ Connected to MQTT broker")
65
+ client.subscribe(MQTT_TOPIC)
66
  else:
67
+ logger.error(f"❌ MQTT connection failed: {rc}")
 
68
 
69
+ # ─ DEBUG MESSENGER & CHECKPOINT WRITER ─
70
  def on_message(client, userdata, msg):
 
71
  ts = datetime.utcnow().isoformat()
72
  payload = msg.payload.decode(errors="replace")
73
+ queue_raw.put({"timestamp": ts, "topic": msg.topic, "payload": payload})
 
74
 
 
75
  try:
76
  data = json.loads(payload.replace('""', '"')).get("data", [])
77
+ logger.info(f"📩 MQTT: {ts} | V={data[0] if len(data)>0 else None}V, A={data[1] if len(data)>1 else None}A, W={data[2] if len(data)>2 else None}W, mWh={data[3] if len(data)>3 else None}")
78
+ except Exception:
79
+ pass
 
 
 
 
80
 
 
81
  try:
82
  with open(RAW_CHECKPOINT_PATH, "a", encoding="utf-8") as f:
83
+ f.write(f'{ts},{msg.topic},"{payload}"\n')
84
  except Exception as e:
85
+ logger.error(f"❌ Failed to write checkpoint: {e}")
86
 
87
+ # ───────────── PIPELINE ─────────────
88
+ ## Filter
89
  def parse_and_filter(raw_rows):
90
+ rows = []
 
 
 
 
 
91
  for r in raw_rows:
92
  try:
93
  payload = json.loads(r["payload"].replace('""', '"'))
94
+ if r["topic"].startswith("device/socket/reply/") and isinstance(payload.get("data", []), list):
95
+ v, a, w, c = (payload["data"] + [None]*4)[:4]
96
+ if any(x not in (0, None) for x in (a, w, c)):
97
+ rows.append({
98
+ "timestamp": r["timestamp"],
99
+ "id": payload.get("id"),
100
+ "imei": payload.get("imei"),
101
+ "type": payload.get("type"),
102
+ "voltage": float(v),
103
+ "current": float(a),
104
+ "power": float(w),
105
+ "consume": float(c),
106
+ })
107
+ except:
108
+ continue
109
+ return pd.DataFrame(rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+ ## Detect and fill missing
112
+ def fill_missing(df):
113
+ if df.empty: return df
114
  df["timestamp"] = pd.to_datetime(df["timestamp"])
115
+ df.sort_values("timestamp", inplace=True)
 
116
  expected = timedelta(seconds=EXPECTED_INTERVAL_SEC)
117
+ tol = timedelta(seconds=TOLERANCE_SEC)
118
+ rows = [df.iloc[0]]
 
 
 
119
  for i in range(1, len(df)):
120
+ prev, curr = df.iloc[i-1]["timestamp"], df.iloc[i]["timestamp"]
121
+ rows.append(df.iloc[i])
122
+ if curr - prev > expected + tol:
123
+ for j in range(1, int(round((curr - prev) / expected))):
124
+ new_ts = prev + j * expected
125
+ gap_row = df.iloc[i-1].copy()
126
+ gap_row["timestamp"] = new_ts
 
 
 
 
 
 
127
  for col in ["voltage", "current", "power", "consume"]:
128
+ gap_row[col] = np.nan
129
+ rows.insert(-1, gap_row)
130
+ df = pd.DataFrame(rows).sort_values("timestamp")
131
+ df["consume_clean"] = df["consume"]
132
+ df.loc[(df["consume"] < 0) | (df["consume"].diff() < 0), "consume_clean"] = np.nan
133
 
 
 
 
 
 
 
 
 
 
134
  imputer = KNNImputer(n_neighbors=3)
135
+ df[["voltage", "current", "power"]] = imputer.fit_transform(df[["voltage", "current", "power"]])
136
+
137
+ train = df[df["consume_clean"].notna()]
138
+ pred = df[df["consume_clean"].isna()]
139
+ if not train.empty and not pred.empty:
140
+ model = LinearRegression().fit(train[["voltage", "current", "power"]], train["consume_clean"])
141
+ df.loc[pred.index, "consume_clean"] = model.predict(pred[["voltage", "current", "power"]])
142
+ df["consume"] = df["consume_clean"]
143
+ return df.drop(columns=["consume_clean"])
144
+
145
+ ## Final MongoDB Saver
146
+ def insert_mongo(df):
147
+ if df.empty: return
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  try:
149
+ client = MongoClient(MONGO_URI)
150
+ col = client[MONGO_DB][MONGO_COL]
151
+ col.create_index("timestamp", unique=True)
 
 
 
152
  records = df.to_dict("records")
153
+ for r in records: r["_id"] = r["timestamp"]
154
+ col.bulk_write([mongo_errors.UpdateOne({"_id": r["_id"]}, {"$set": r}, upsert=True) for r in records], ordered=False)
155
+ logger.info(f"📥 Inserted {len(records)} rows to MongoDB.")
 
 
 
 
 
 
156
  except Exception as e:
157
+ logger.error(f"❌ Mongo insert error: {e}")
 
158
 
159
+ ## Batch worker looper
160
  def batch_worker():
 
 
 
161
  while not stop_event.is_set():
162
  time.sleep(BATCH_SECONDS)
 
163
  bundle = []
164
+ while True:
165
+ try: bundle.append(queue_raw.get_nowait())
166
+ except Empty: break
 
 
 
167
  if not bundle:
168
+ logger.debug("⏱️ No new data this cycle")
169
  continue
170
+ df_clean = fill_missing(parse_and_filter(bundle))
 
 
171
  insert_mongo(df_clean)
172
 
173
+ # ─────── FASTAPI ENDPOINTS ───────
174
+ @app.get("/fetch")
175
+ def fetch(Password: str):
176
+ if Password != FETCH_PASS:
177
+ raise HTTPException(status_code=401)
178
+ client = MongoClient(MONGO_URI)
179
+ data = list(client[MONGO_DB][MONGO_COL].find({}, {"_id": 0}))
180
+ return JSONResponse(data)
181
+
182
+ @app.get("/delete")
183
+ def delete(Password: str):
184
+ if Password != FETCH_PASS:
185
+ raise HTTPException(status_code=401)
186
+ client = MongoClient(MONGO_URI)
187
+ count = client[MONGO_DB][MONGO_COL].delete_many({}).deleted_count
188
+ return {"message": f"🧨 Deleted {count} rows from MongoDB."}
189
+
190
+ @app.get("/load")
191
+ def load(Password: str):
192
+ if Password != FETCH_PASS:
193
+ raise HTTPException(status_code=401)
194
+ client = MongoClient(MONGO_URI)
195
+ df = pd.DataFrame(list(client[MONGO_DB][MONGO_COL].find({}, {"_id": 0})))
196
+ if df.empty:
197
+ raise HTTPException(status_code=404, detail="No data found.")
198
+ df.to_csv(EXPORT_CSV_PATH, index=False)
199
+ return FileResponse(EXPORT_CSV_PATH, filename="poptech_cleaned_data.csv", media_type="text/csv")
200
 
201
+ @app.get("/healthz")
202
+ def health():
203
+ return {"status": "ok"}
 
 
204
 
205
+ # ─────── BOOTSTRAP ───────
206
+ def mqtt_main():
207
+ threading.Thread(target=batch_worker, daemon=True).start()
208
  client = mqtt.Client()
209
  client.username_pw_set(USERNAME, PASSWORD)
210
  client.on_connect = on_connect
211
  client.on_message = on_message
212
  client.connect(BROKER, PORT, 60)
213
+ def handle_exit(sig, _): stop_event.set(); client.disconnect()
214
+ for s in [signal.SIGINT, signal.SIGTERM]: signal.signal(s, handle_exit)
 
 
 
 
 
 
 
 
215
  client.loop_forever()
216
 
217
+ threading.Thread(target=mqtt_main, daemon=True).start()
218
 
219
  if __name__ == "__main__":
220
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt CHANGED
@@ -4,3 +4,5 @@ numpy
4
  scikit-learn
5
  pymongo
6
  python-dotenv
 
 
 
4
  scikit-learn
5
  pymongo
6
  python-dotenv
7
+ fastapi
8
+ uvicorn