anoderb commited on
Commit
2860429
Β·
1 Parent(s): c995585

feat: integrate machine learning forecasting pipeline and unify settings & dashboard under Sikomo theme

Browse files
Dockerfile CHANGED
@@ -23,7 +23,12 @@ WORKDIR /app
23
 
24
  # Install minimal OS build dependencies
25
  RUN apt-get update && apt-get install -y \
 
26
  gcc \
 
 
 
 
27
  && rm -rf /var/lib/apt/lists/*
28
 
29
  # Install python dependencies as user
@@ -34,8 +39,8 @@ RUN pip install --no-cache-dir --upgrade pip \
34
  # Copy backend python packages
35
  COPY --chown=user backend/ ./
36
 
37
- # Establish web serving static destination directory
38
- RUN mkdir -p /app/static && chown -R user:user /app/static
39
  COPY --chown=user --from=builder /app/frontend/out /app/static
40
 
41
  # Enforce secure runner execution ownership context
 
23
 
24
  # Install minimal OS build dependencies
25
  RUN apt-get update && apt-get install -y \
26
+ libgomp1 \
27
  gcc \
28
+ g++ \
29
+ make \
30
+ python3-dev \
31
+ curl \
32
  && rm -rf /var/lib/apt/lists/*
33
 
34
  # Install python dependencies as user
 
39
  # Copy backend python packages
40
  COPY --chown=user backend/ ./
41
 
42
+ # Establish web serving static destination directory and model directory
43
+ RUN mkdir -p /app/static /app/models && chown -R user:user /app/static /app/models
44
  COPY --chown=user --from=builder /app/frontend/out /app/static
45
 
46
  # Enforce secure runner execution ownership context
backend/app/config.py CHANGED
@@ -1,33 +1,79 @@
 
1
  from pydantic_settings import BaseSettings
2
  from functools import lru_cache
 
 
3
 
4
  class Settings(BaseSettings):
5
- # Database
6
- MYSQL_HOST: str
7
- MYSQL_PORT: int = 3306
8
- MYSQL_DATABASE: str
9
- MYSQL_USER: str
10
- MYSQL_PASSWORD: str
11
-
12
- # API
 
 
 
 
 
 
13
  API_PORT: int = 8000
14
  API_HOST: str = "0.0.0.0"
15
-
16
- # Scheduler
 
 
17
  DEFAULT_SCHEDULE: str = "0 8 * * *"
18
- DEFAULT_SCHEDULES: str = "0 8 * * *" # comma-separated if multiple
19
-
20
  # CORS
21
  FRONTEND_URL: str = "http://localhost:3000"
22
-
23
  @property
24
  def DATABASE_URL(self) -> str:
25
- return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}"
26
-
 
 
 
 
 
 
27
  class Config:
28
  env_file = ".env"
29
  extra = "ignore"
30
 
31
  @lru_cache()
32
- def get_settings():
33
- return Settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # config.py β€” Centralized Configuration using pydantic-settings
2
  from pydantic_settings import BaseSettings
3
  from functools import lru_cache
4
+ from typing import Optional
5
+ import os
6
 
7
  class Settings(BaseSettings):
8
+ # Database (support both MYSQL_ prefix and DB_ prefix)
9
+ MYSQL_HOST: Optional[str] = None
10
+ MYSQL_PORT: Optional[int] = None
11
+ MYSQL_DATABASE: Optional[str] = None
12
+ MYSQL_USER: Optional[str] = None
13
+ MYSQL_PASSWORD: Optional[str] = None
14
+
15
+ DB_HOST: Optional[str] = None
16
+ DB_PORT: Optional[int] = None
17
+ DB_NAME: Optional[str] = None
18
+ DB_USER: Optional[str] = None
19
+ DB_PASSWORD: Optional[str] = None
20
+
21
+ # API Auth & Configuration
22
  API_PORT: int = 8000
23
  API_HOST: str = "0.0.0.0"
24
+ API_SECRET_KEY: str = "your-random-secret"
25
+ DASHBOARD_PASSWORD: str = "Bandulan112"
26
+
27
+ # Default Schedules
28
  DEFAULT_SCHEDULE: str = "0 8 * * *"
29
+ DEFAULT_SCHEDULES: str = "0 8 * * *"
30
+
31
  # CORS
32
  FRONTEND_URL: str = "http://localhost:3000"
33
+
34
  @property
35
  def DATABASE_URL(self) -> str:
36
+ # Fallback mappings
37
+ host = self.MYSQL_HOST or self.DB_HOST or os.getenv("MYSQL_HOST") or os.getenv("DB_HOST") or "localhost"
38
+ port = self.MYSQL_PORT or self.DB_PORT or os.getenv("MYSQL_PORT") or os.getenv("DB_PORT") or 3306
39
+ db = self.MYSQL_DATABASE or self.DB_NAME or os.getenv("MYSQL_DATABASE") or os.getenv("DB_NAME") or "sikomo_db"
40
+ user = self.MYSQL_USER or self.DB_USER or os.getenv("MYSQL_USER") or os.getenv("DB_USER") or "root"
41
+ password = self.MYSQL_PASSWORD or self.DB_PASSWORD or os.getenv("MYSQL_PASSWORD") or os.getenv("DB_PASSWORD") or ""
42
+ return f"mysql+pymysql://{user}:{password}@{host}:{port}/{db}"
43
+
44
  class Config:
45
  env_file = ".env"
46
  extra = "ignore"
47
 
48
  @lru_cache()
49
+ def get_settings() -> Settings:
50
+ return Settings()
51
+
52
+ class RuntimeConfig:
53
+ """Runtime ML Configuration editable via dashboard without server restart."""
54
+ def __init__(self):
55
+ self.optuna_trials: int = int(os.getenv("OPTUNA_TRIALS", "30"))
56
+ self.model_dir: str = os.getenv("MODEL_DIR", "models")
57
+ self.forecast_days: int = int(os.getenv("FORECAST_DAYS", "7"))
58
+ self.default_schedules: str = os.getenv("DEFAULT_FORECAST_SCHEDULES", "0 1 * * *")
59
+
60
+ def to_dict(self) -> dict:
61
+ return {
62
+ "optuna_trials": self.optuna_trials,
63
+ "model_dir": self.model_dir,
64
+ "forecast_days": self.forecast_days,
65
+ "default_schedules": self.default_schedules
66
+ }
67
+
68
+ def update(self, key: str, value: str):
69
+ allowed = {
70
+ "optuna_trials": lambda v: setattr(self, "optuna_trials", int(v)),
71
+ "model_dir": lambda v: setattr(self, "model_dir", v),
72
+ "forecast_days": lambda v: setattr(self, "forecast_days", int(v)),
73
+ "default_schedules": lambda v: setattr(self, "default_schedules", v),
74
+ }
75
+ if key not in allowed:
76
+ raise ValueError(f"Key '{key}' tidak diizinkan untuk diubah.")
77
+ allowed[key](value)
78
+
79
+ runtime_config = RuntimeConfig()
backend/app/database.py CHANGED
@@ -1,8 +1,12 @@
1
- from sqlalchemy import create_engine
 
 
 
2
  from app.config import get_settings
3
 
4
  settings = get_settings()
5
 
 
6
  engine = create_engine(
7
  settings.DATABASE_URL,
8
  pool_pre_ping=True,
@@ -10,4 +14,294 @@ engine = create_engine(
10
  )
11
 
12
  def get_db_engine():
13
- return engine
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # database.py β€” MySQL Connection & CRUD Operations for Scraper and Forecast
2
+ import pandas as pd
3
+ import sqlalchemy
4
+ from sqlalchemy import create_engine, text
5
  from app.config import get_settings
6
 
7
  settings = get_settings()
8
 
9
+ # Global Singleton DB Engine
10
  engine = create_engine(
11
  settings.DATABASE_URL,
12
  pool_pre_ping=True,
 
14
  )
15
 
16
  def get_db_engine():
17
+ return engine
18
+
19
+ def get_engine():
20
+ """Compatibility alias for forecast operations."""
21
+ return engine
22
+
23
+ # ── READ OPERATIONS ───────────────────────────────────────────────────────────
24
+
25
+ def get_all_komoditas():
26
+ """Ambil semua komoditas aktif."""
27
+ return pd.read_sql(
28
+ "SELECT id, nama, slug, unit, volatile, volatilitas_skor FROM komoditas WHERE is_active=1",
29
+ engine
30
+ )
31
+
32
+ def get_harga_harian(komoditas_id: int, days: int = 730):
33
+ """Ambil data harga harian untuk satu komoditas (default 2 tahun terakhir)."""
34
+ query = f"""
35
+ SELECT hh.tanggal AS date, p.nama AS pasar_nama, hh.harga
36
+ FROM harga_harian hh
37
+ JOIN pasar p ON p.id = hh.pasar_id
38
+ WHERE hh.komoditas_id = {komoditas_id}
39
+ AND hh.tanggal >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)
40
+ ORDER BY hh.tanggal ASC
41
+ """
42
+ df_raw = pd.read_sql(query, engine)
43
+ if df_raw.empty:
44
+ return pd.DataFrame()
45
+ # Pivot β†’ wide format
46
+ df = df_raw.pivot_table(
47
+ index='date', columns='pasar_nama', values='harga'
48
+ ).reset_index()
49
+ df.columns.name = None
50
+ df['date'] = pd.to_datetime(df['date'])
51
+ df = df.sort_values('date').reset_index(drop=True)
52
+ return df
53
+
54
+ def get_pasar_list(komoditas_id: int):
55
+ """Ambil daftar pasar yang punya data untuk komoditas ini."""
56
+ return pd.read_sql(f"""
57
+ SELECT DISTINCT p.id, p.nama
58
+ FROM harga_harian hh
59
+ JOIN pasar p ON p.id = hh.pasar_id
60
+ WHERE hh.komoditas_id = {komoditas_id}
61
+ """, engine)
62
+
63
+ # ── WRITE OPERATIONS ──────────────────────────────────────────────────────────
64
+
65
+ def save_model_ml(data: dict):
66
+ """Upsert ke tabel model_ml."""
67
+ with engine.begin() as conn:
68
+ conn.execute(text("""
69
+ INSERT INTO model_ml (
70
+ komoditas_id, nama_model, versi, deskripsi, file_path,
71
+ mape, rmse, mae, r2_score, confidence_level,
72
+ stabilitas, status_validasi, catatan_validasi,
73
+ tanggal_training, tanggal_evaluasi, is_active,
74
+ created_at, updated_at
75
+ ) VALUES (
76
+ :komoditas_id, :nama_model, :versi, :deskripsi, :file_path,
77
+ :mape, :rmse, :mae, :r2_score, :confidence_level,
78
+ :stabilitas, :status_validasi, :catatan_validasi,
79
+ :tanggal_training, :tanggal_evaluasi, 1, NOW(), NOW()
80
+ )
81
+ ON DUPLICATE KEY UPDATE
82
+ nama_model = VALUES(nama_model),
83
+ versi = VALUES(versi),
84
+ deskripsi = VALUES(deskripsi),
85
+ file_path = VALUES(file_path),
86
+ mape = VALUES(mape),
87
+ rmse = VALUES(rmse),
88
+ mae = VALUES(mae),
89
+ r2_score = VALUES(r2_score),
90
+ confidence_level = VALUES(confidence_level),
91
+ stabilitas = VALUES(stabilitas),
92
+ status_validasi = VALUES(status_validasi),
93
+ catatan_validasi = VALUES(catatan_validasi),
94
+ tanggal_evaluasi = VALUES(tanggal_evaluasi),
95
+ updated_at = NOW()
96
+ """), data)
97
+
98
+ with engine.connect() as conn:
99
+ return conn.execute(
100
+ text("SELECT id FROM model_ml WHERE komoditas_id=:kid ORDER BY updated_at DESC LIMIT 1"),
101
+ {'kid': data['komoditas_id']}
102
+ ).scalar()
103
+
104
+ def save_hasil_prediksi(predictions: list, komoditas_id: int, pasar_id, model_id: int, meta: dict):
105
+ """Hapus prediksi lama & insert 7 baris baru."""
106
+ with engine.begin() as conn:
107
+ conn.execute(text("""
108
+ DELETE FROM hasil_prediksi
109
+ WHERE komoditas_id = :kid AND pasar_id = :pid
110
+ AND tanggal_target >= CURDATE()
111
+ """), {'kid': komoditas_id, 'pid': pasar_id})
112
+
113
+ for p in predictions:
114
+ conn.execute(text("""
115
+ INSERT INTO hasil_prediksi (
116
+ komoditas_id, pasar_id, model_id,
117
+ tanggal_prediksi, tanggal_target,
118
+ harga_prediksi, confidence_level,
119
+ model_name, mape, rmse, created_at
120
+ ) VALUES (
121
+ :kid, :pid, :model_id,
122
+ CURDATE(), :tanggal_target,
123
+ :harga_prediksi, :confidence_level,
124
+ :model_name, :mape, :rmse, NOW()
125
+ )
126
+ """), {
127
+ 'kid' : komoditas_id,
128
+ 'pid' : pasar_id,
129
+ 'model_id' : model_id,
130
+ 'tanggal_target' : p['tanggal'],
131
+ 'harga_prediksi' : p['harga_prediksi'],
132
+ 'confidence_level': meta['confidence_level'],
133
+ 'model_name' : meta['nama_model'],
134
+ 'mape' : meta['mape'],
135
+ 'rmse' : meta['rmse'],
136
+ })
137
+
138
+ def save_ringkasan_prediksi(data: dict, komoditas_id: int, model_id: int):
139
+ """Upsert ringkasan prediksi."""
140
+ with engine.begin() as conn:
141
+ conn.execute(text("""
142
+ INSERT INTO ringkasan_prediksi (
143
+ komoditas_id, model_id,
144
+ harga_min, harga_max, tren, confidence_level,
145
+ status_analisis, deskripsi_status,
146
+ tanggal_mulai, tanggal_akhir, created_at
147
+ ) VALUES (
148
+ :komoditas_id, :model_id,
149
+ :harga_min, :harga_max, :tren, :confidence_level,
150
+ :status_analisis, :deskripsi_status,
151
+ :tanggal_mulai, :tanggal_akhir, NOW()
152
+ )
153
+ ON DUPLICATE KEY UPDATE
154
+ harga_min = VALUES(harga_min),
155
+ harga_max = VALUES(harga_max),
156
+ tren = VALUES(tren),
157
+ confidence_level = VALUES(confidence_level),
158
+ status_analisis = VALUES(status_analisis),
159
+ deskripsi_status = VALUES(deskripsi_status),
160
+ tanggal_mulai = VALUES(tanggal_mulai),
161
+ tanggal_akhir = VALUES(tanggal_akhir)
162
+ """), {**data, 'komoditas_id': komoditas_id, 'model_id': model_id})
163
+
164
+ def save_insight_prediksi(insights: list, komoditas_id: int, model_id: int):
165
+ """Hapus insight lama & insert baru."""
166
+ with engine.begin() as conn:
167
+ conn.execute(text(
168
+ "DELETE FROM insight_prediksi WHERE komoditas_id = :kid"
169
+ ), {'kid': komoditas_id})
170
+ for ins in insights:
171
+ conn.execute(text("""
172
+ INSERT INTO insight_prediksi (
173
+ komoditas_id, model_id, konten, tipe, ikon,
174
+ urutan, is_active, created_at, updated_at
175
+ ) VALUES (
176
+ :kid, :model_id, :konten, :tipe, :ikon,
177
+ :urutan, 1, NOW(), NOW()
178
+ )
179
+ """), {
180
+ 'kid' : komoditas_id,
181
+ 'model_id': model_id,
182
+ 'konten' : ins['konten'],
183
+ 'tipe' : ins['tipe'],
184
+ 'ikon' : ins['ikon'],
185
+ 'urutan' : ins['urutan'],
186
+ })
187
+
188
+ def update_komoditas_volatilitas(komoditas_id: int, volatile: int, skor: float):
189
+ with engine.begin() as conn:
190
+ conn.execute(text("""
191
+ UPDATE komoditas SET volatile=:v, volatilitas_skor=:s, updated_at=NOW()
192
+ WHERE id=:kid
193
+ """), {'v': volatile, 's': skor, 'kid': komoditas_id})
194
+
195
+ def save_all_model_results(all_results: dict, best_meta: dict, komoditas_id: int, pasar_id: int):
196
+ """Save ALL model results to model_ml (not just best). Best gets is_active=1."""
197
+ best_name = best_meta.get('nama_model', '')
198
+ with engine.begin() as conn:
199
+ # Deactivate old models for this komoditas
200
+ conn.execute(text(
201
+ "UPDATE model_ml SET is_active=0 WHERE komoditas_id=:kid"
202
+ ), {'kid': komoditas_id})
203
+ # Insert each model result
204
+ for model_name, metrics in all_results.items():
205
+ is_best = 1 if model_name == best_name or f"{model_name}_Tuned" == best_name else 0
206
+
207
+ # Hitung stabilitas sederhana untuk kolom ENUM agar tidak error
208
+ mape_val = metrics.get('mape', 0)
209
+ stabilitas_label = (
210
+ 'optimal' if mape_val < 2 else
211
+ 'baik' if mape_val < 5 else
212
+ 'cukup' if mape_val < 10 else
213
+ 'perlu_retrain'
214
+ )
215
+
216
+ conn.execute(text("""
217
+ INSERT INTO model_ml (
218
+ komoditas_id, nama_model, versi, deskripsi, file_path,
219
+ mape, rmse, mae, r2_score, confidence_level,
220
+ stabilitas, status_validasi, catatan_validasi,
221
+ tanggal_training, tanggal_evaluasi, is_active,
222
+ created_at, updated_at
223
+ ) VALUES (
224
+ :komoditas_id, :nama_model, '1.0', :deskripsi, '',
225
+ :mape, :rmse, :mae, :r2_score, 0,
226
+ :stabilitas, 'terverifikasi', '',
227
+ CURDATE(), CURDATE(), :is_active, NOW(), NOW()
228
+ )
229
+ """), {
230
+ 'komoditas_id': komoditas_id,
231
+ 'nama_model': model_name,
232
+ 'deskripsi': f"Model {model_name} untuk komoditas ID {komoditas_id}",
233
+ 'mape': round(mape_val, 4),
234
+ 'rmse': round(metrics.get('rmse', 0), 4),
235
+ 'mae': round(metrics.get('mae', 0), 4),
236
+ 'r2_score': round(metrics.get('r2', 0), 4),
237
+ 'stabilitas': stabilitas_label,
238
+ 'is_active': is_best,
239
+ })
240
+
241
+ # ── READ OPERATIONS FOR FRONTEND API ──────────────────────────────────────────
242
+
243
+ def get_prediksi_data(komoditas_id: int):
244
+ """Ambil semua data yang dibutuhkan frontend untuk satu komoditas."""
245
+ komoditas = pd.read_sql(f"""
246
+ SELECT k.*, kk.nama as kategori_nama
247
+ FROM komoditas k
248
+ JOIN kategori_komoditas kk ON kk.id = k.kategori_id
249
+ WHERE k.id = {komoditas_id}
250
+ """, engine).to_dict('records')
251
+ komoditas = komoditas[0] if komoditas else None
252
+
253
+ harga_terkini = pd.read_sql(f"""
254
+ SELECT hh.harga, hh.tanggal, p.nama as pasar_nama, p.id as pasar_id
255
+ FROM harga_harian hh
256
+ JOIN pasar p ON p.id = hh.pasar_id
257
+ WHERE hh.komoditas_id = {komoditas_id}
258
+ ORDER BY hh.tanggal DESC LIMIT 1
259
+ """, engine).to_dict('records')
260
+ harga_terkini = harga_terkini[0] if harga_terkini else None
261
+
262
+ # Best (active) model
263
+ model = pd.read_sql(f"""
264
+ SELECT * FROM model_ml WHERE komoditas_id={komoditas_id} AND is_active=1
265
+ ORDER BY updated_at DESC LIMIT 1
266
+ """, engine).to_dict('records')
267
+ model = model[0] if model else None
268
+
269
+ # ALL models for comparison (latest run)
270
+ all_models = pd.read_sql(f"""
271
+ SELECT nama_model, mape, rmse, mae, r2_score, is_active,
272
+ tanggal_training, created_at
273
+ FROM model_ml WHERE komoditas_id={komoditas_id}
274
+ AND DATE(created_at) = (
275
+ SELECT DATE(MAX(created_at)) FROM model_ml WHERE komoditas_id={komoditas_id}
276
+ )
277
+ ORDER BY rmse ASC
278
+ """, engine).to_dict('records')
279
+
280
+ ringkasan = pd.read_sql(f"""
281
+ SELECT * FROM ringkasan_prediksi WHERE komoditas_id={komoditas_id}
282
+ ORDER BY created_at DESC LIMIT 1
283
+ """, engine).to_dict('records')
284
+ ringkasan = ringkasan[0] if ringkasan else None
285
+
286
+ hasil = pd.read_sql(f"""
287
+ SELECT tanggal_target, harga_prediksi, confidence_level
288
+ FROM hasil_prediksi
289
+ WHERE komoditas_id={komoditas_id} AND tanggal_target >= CURDATE()
290
+ ORDER BY tanggal_target ASC
291
+ """, engine).to_dict('records')
292
+
293
+ insights = pd.read_sql(f"""
294
+ SELECT * FROM insight_prediksi
295
+ WHERE komoditas_id={komoditas_id} AND is_active=1
296
+ ORDER BY urutan ASC
297
+ """, engine).to_dict('records')
298
+
299
+ return {
300
+ 'komoditas' : komoditas,
301
+ 'harga_terkini' : harga_terkini,
302
+ 'model' : model,
303
+ 'all_models' : all_models,
304
+ 'ringkasan' : ringkasan,
305
+ 'prediksi_7hari': hasil,
306
+ 'insights' : insights,
307
+ }
backend/app/logger.py CHANGED
@@ -1,8 +1,10 @@
 
1
  import logging
2
  from datetime import datetime
3
  from typing import List, Dict, Optional
4
 
5
  class MemoryLogHandler(logging.Handler):
 
6
  def __init__(self, capacity: int = 500):
7
  super().__init__()
8
  self.capacity = capacity
@@ -10,7 +12,6 @@ class MemoryLogHandler(logging.Handler):
10
 
11
  def emit(self, record):
12
  try:
13
- # We want just the message or formatted message
14
  msg = record.getMessage()
15
  log_entry = {
16
  "timestamp": datetime.fromtimestamp(record.created).isoformat(),
@@ -33,12 +34,42 @@ class MemoryLogHandler(logging.Handler):
33
  def clear_logs(self):
34
  self.logs.clear()
35
 
36
- memory_handler = MemoryLogHandler()
 
 
37
 
38
- def get_app_logger(name: str):
39
- logger = logging.getLogger(f"app.{name}")
 
40
  logger.setLevel(logging.INFO)
41
- # Ensure memory handler is added
42
- if not any(isinstance(h, MemoryLogHandler) for h in logger.handlers):
43
- logger.addHandler(memory_handler)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  return logger
 
1
+ # logger.py β€” In-memory log handler for scraper and forecast log separation
2
  import logging
3
  from datetime import datetime
4
  from typing import List, Dict, Optional
5
 
6
  class MemoryLogHandler(logging.Handler):
7
+ """Stores log entries in memory for API log streaming."""
8
  def __init__(self, capacity: int = 500):
9
  super().__init__()
10
  self.capacity = capacity
 
12
 
13
  def emit(self, record):
14
  try:
 
15
  msg = record.getMessage()
16
  log_entry = {
17
  "timestamp": datetime.fromtimestamp(record.created).isoformat(),
 
34
  def clear_logs(self):
35
  self.logs.clear()
36
 
37
+ # Separated handlers
38
+ scraper_memory_handler = MemoryLogHandler(capacity=500)
39
+ forecast_memory_handler = MemoryLogHandler(capacity=500)
40
 
41
+ def get_app_logger(name: str) -> logging.Logger:
42
+ """Default app logger for scraping operations."""
43
+ logger = logging.getLogger(f"app.scraper.{name}")
44
  logger.setLevel(logging.INFO)
45
+
46
+ # Add memory handler
47
+ if not any(h is scraper_memory_handler for h in logger.handlers):
48
+ logger.addHandler(scraper_memory_handler)
49
+
50
+ # Add console stream handler
51
+ if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, MemoryLogHandler) for h in logger.handlers):
52
+ console = logging.StreamHandler()
53
+ console.setLevel(logging.INFO)
54
+ console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] scraper.%(name)s: %(message)s"))
55
+ logger.addHandler(console)
56
+
57
+ return logger
58
+
59
+ def get_forecast_logger(name: str) -> logging.Logger:
60
+ """Dedicated app logger for forecasting operations."""
61
+ logger = logging.getLogger(f"app.forecast.{name}")
62
+ logger.setLevel(logging.INFO)
63
+
64
+ # Add memory handler
65
+ if not any(h is forecast_memory_handler for h in logger.handlers):
66
+ logger.addHandler(forecast_memory_handler)
67
+
68
+ # Add console stream handler
69
+ if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, MemoryLogHandler) for h in logger.handlers):
70
+ console = logging.StreamHandler()
71
+ console.setLevel(logging.INFO)
72
+ console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] forecast.%(name)s: %(message)s"))
73
+ logger.addHandler(console)
74
+
75
  return logger
backend/app/main.py CHANGED
@@ -1,20 +1,41 @@
1
- from fastapi import FastAPI, HTTPException, Query
2
- from fastapi.middleware.cors import CORSMiddleware
 
 
 
 
3
  from fastapi.staticfiles import StaticFiles
 
4
  from fastapi.responses import FileResponse
 
5
  from pydantic import BaseModel
6
- from typing import Optional
7
- from app.scheduler import scraper_scheduler
8
- from app.scraper import scraping_hari_ini, upload_to_mysql
9
- from app.database import get_db_engine
10
- from app.config import get_settings
11
- from app.logger import get_app_logger, memory_handler
12
- import os
13
 
14
- logger = get_app_logger("main")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  settings = get_settings()
 
 
16
 
17
- app = FastAPI(title="Sikomo Scraper API", version="1.3.0")
18
 
19
  app.add_middleware(
20
  CORSMiddleware,
@@ -24,85 +45,200 @@ app.add_middleware(
24
  allow_headers=["*"],
25
  )
26
 
27
- class ScheduleAdd(BaseModel):
28
- cron_expression: str
29
- label: Optional[str] = ""
30
 
31
- class ScheduleUpdate(BaseModel):
32
- cron_expression: str
 
 
33
 
34
- class CustomScrapeRequest(BaseModel):
35
- date: str # Format YYYY-MM-DD
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  @app.on_event("startup")
38
  async def startup_event():
39
- logger.info("πŸŽ‰ Memulai Sikomo Scraper API Server...")
 
40
  scraper_scheduler.start_all()
 
41
  logger.info("πŸš€ API Server siap menerima permintaan.")
42
 
43
  @app.on_event("shutdown")
44
  async def shutdown_event():
45
  scraper_scheduler.stop_all()
 
46
  logger.info("πŸ›‘ API Server dimatikan.")
47
 
48
- # --- API Endpoints ---
 
 
 
49
 
50
- @app.get("/status")
51
- async def get_status():
52
- return scraper_scheduler.get_status()
53
 
54
- @app.get("/debug/static")
55
- def debug_static():
56
- import os
57
- files = []
58
- for root, dirs, filenames in os.walk("/app/static"):
59
- for f in filenames:
60
- files.append(os.path.relpath(os.path.join(root, f), "/app/static"))
61
- return {"files": sorted(files)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
- @app.get("/logs")
64
- async def get_logs(limit: int = Query(100, le=500), level: Optional[str] = None):
65
- return memory_handler.get_logs(limit=limit, level=level)
 
 
 
66
 
67
- @app.get("/schedules")
68
- async def list_schedules():
 
 
 
 
 
 
 
 
 
69
  return scraper_scheduler.list_schedules()
70
 
71
- @app.post("/schedules/add")
72
- async def add_schedule(schedule: ScheduleAdd):
73
  try:
74
  job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label=schedule.label)
75
  return {
76
  "success": True,
77
  "job_id": job_id,
78
- "message": f"Jadwal berhasil ditambahkan: {schedule.cron_expression}"
79
  }
80
  except Exception as e:
81
  raise HTTPException(status_code=400, detail=str(e))
82
 
83
- @app.delete("/schedules/{job_id}/remove")
84
- async def remove_schedule(job_id: str):
85
  try:
86
  scraper_scheduler.remove_schedule(job_id)
87
- return {"success": True, "message": "Jadwal berhasil dihapus"}
88
  except KeyError:
89
  raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
90
  except Exception as e:
91
  raise HTTPException(status_code=400, detail=str(e))
92
 
93
- # Backward compatibility for single schedule update if UI still uses it
94
- @app.post("/schedule/update")
95
- async def update_schedule(schedule: ScheduleUpdate):
96
- try:
97
- # Clear existing and add new
98
- scraper_scheduler.stop_all()
99
- scraper_scheduler.start_all()
100
- job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label="Jadwal Utama")
101
- return {"success": True, "message": f"Jadwal diupdate ke: {schedule.cron_expression}"}
102
- except Exception as e:
103
- raise HTTPException(status_code=400, detail=str(e))
104
-
105
- @app.post("/scrape/manual")
106
  def manual_scrape():
107
  logger.info("⚑ Permintaan manual scraping dipicu via API...")
108
  try:
@@ -111,12 +247,12 @@ def manual_scrape():
111
  stats = upload_to_mysql(df, engine)
112
  return {"success": True, "stats": stats}
113
  except Exception as e:
114
- logger.error(f"❌ Manual scrape gagal: {e}")
115
  raise HTTPException(status_code=500, detail=str(e))
116
 
117
- @app.post("/scrape/custom")
118
  def custom_scrape(req: CustomScrapeRequest):
119
- logger.info(f"⚑ Permintaan manual scraping custom untuk tanggal {req.date} dipicu via API...")
120
  try:
121
  from datetime import datetime
122
  target_date = datetime.strptime(req.date, "%Y-%m-%d")
@@ -127,28 +263,212 @@ def custom_scrape(req: CustomScrapeRequest):
127
  except ValueError:
128
  raise HTTPException(status_code=400, detail="Format tanggal salah. Gunakan YYYY-MM-DD.")
129
  except Exception as e:
130
- logger.error(f"❌ Custom scrape gagal: {e}")
131
  raise HTTPException(status_code=500, detail=str(e))
132
 
133
- @app.post("/scrape/stop")
134
  def stop_manual_scrape():
135
- from app.scraper import stop_event
136
  logger.warning("πŸ›‘ Menerima permintaan penghentian scraping manual...")
137
- stop_event.set()
138
- return {"success": True, "message": "Permintaan penghentian dikirim"}
139
 
140
- @app.post("/schedule/start")
141
- async def start_scheduler():
142
  scraper_scheduler.start_all()
143
- return {"success": True, "message": "Scheduler otomatis diaktifkan"}
144
 
145
- @app.post("/schedule/stop")
146
- async def stop_scheduler():
147
  scraper_scheduler.stop_all()
148
- return {"success": True, "message": "Scheduler otomatis dimatikan"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
- # --- Static Files / SPA Serving ---
151
- # Mount static folder if exists (in Docker production or local test)
152
  static_dirs = [
153
  "/app/static",
154
  os.path.join(os.path.dirname(os.path.dirname(__file__)), "static"),
@@ -166,6 +486,6 @@ if not mounted:
166
  @app.get("/")
167
  async def root_fallback():
168
  return {
169
- "message": "Sikomo Scraper API Server beroperasi normal.",
170
  "note": "Frontend static files belum di-build/mount ke direktori static."
171
  }
 
1
+ # main.py β€” FastAPI: Refactored with modular config, logger, scheduler
2
+ import os
3
+ import threading
4
+ from datetime import datetime
5
+ from typing import Optional, List
6
+ from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, Query
7
  from fastapi.staticfiles import StaticFiles
8
+ from fastapi.middleware.cors import CORSMiddleware
9
  from fastapi.responses import FileResponse
10
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
11
  from pydantic import BaseModel
 
 
 
 
 
 
 
12
 
13
+ # Modular App Imports
14
+ from app.config import get_settings, runtime_config
15
+ from app.logger import get_app_logger, get_forecast_logger, scraper_memory_handler, forecast_memory_handler
16
+ from app.scheduler import scraper_scheduler, forecast_scheduler
17
+ from app.scraper import scraping_hari_ini, upload_to_mysql, stop_event as scraper_stop_event
18
+ from app.database import (
19
+ get_db_engine,
20
+ get_all_komoditas,
21
+ get_harga_harian,
22
+ get_pasar_list,
23
+ get_prediksi_data,
24
+ save_model_ml,
25
+ save_hasil_prediksi,
26
+ save_ringkasan_prediksi,
27
+ save_insight_prediksi,
28
+ update_komoditas_volatilitas,
29
+ save_all_model_results,
30
+ )
31
+ from app.ml_pipeline import run_pipeline
32
+ from sqlalchemy import text
33
+
34
  settings = get_settings()
35
+ logger = get_app_logger("main")
36
+ forecast_logger = get_forecast_logger("main")
37
 
38
+ app = FastAPI(title="Sikomo Scraper & ML Portal API", version="2.0.0")
39
 
40
  app.add_middleware(
41
  CORSMiddleware,
 
45
  allow_headers=["*"],
46
  )
47
 
48
+ # ── Authentication Dependency ──────────────────────────────────────────────────
49
+ security = HTTPBearer()
 
50
 
51
+ def verify_token(creds: HTTPAuthorizationCredentials = Depends(security)):
52
+ if creds.credentials != settings.API_SECRET_KEY:
53
+ raise HTTPException(status_code=401, detail="Unauthorized")
54
+ return creds.credentials
55
 
56
+ # ── ML Forecasting State ──────────────────────────────────────────────────────
57
+ running_forecast_tasks: dict = {} # komoditas_id -> True/False
58
+ forecast_stop_event = threading.Event() # For aborting forecast
59
+
60
+ def run_forecast_for_komoditas(komoditas_id: int, komoditas_nama: str):
61
+ """Run forecast for a single komoditas. Tracks running state."""
62
+ if running_forecast_tasks.get(komoditas_id):
63
+ forecast_logger.warning(f"⚠️ {komoditas_nama} sudah sedang berjalan, skip.")
64
+ return
65
+
66
+ running_forecast_tasks[komoditas_id] = True
67
+ try:
68
+ if forecast_stop_event.is_set():
69
+ forecast_logger.warning(f"β›” Forecast dihentikan sebelum {komoditas_nama}.")
70
+ return
71
+
72
+ df = get_harga_harian(komoditas_id)
73
+ if df.empty or len(df) < 60:
74
+ forecast_logger.warning(f"⚠️ {komoditas_nama}: data kurang ({len(df)} baris)")
75
+ return
76
+
77
+ price_cols = [c for c in df.columns if c != 'date']
78
+ target_col = price_cols[0]
79
+
80
+ pasar_df = get_pasar_list(komoditas_id)
81
+ pasar_id = int(pasar_df.iloc[0]['id']) if not pasar_df.empty else 1
82
+
83
+ def log_cb(msg):
84
+ forecast_logger.info(f"[{komoditas_nama}] {msg}")
85
+
86
+ result = run_pipeline(
87
+ komoditas_id=komoditas_id,
88
+ komoditas_nama=komoditas_nama,
89
+ df=df,
90
+ target_market=target_col,
91
+ pasar_id=pasar_id,
92
+ n_trials=runtime_config.optuna_trials,
93
+ forecast_days=runtime_config.forecast_days,
94
+ log_cb=log_cb,
95
+ )
96
+
97
+ # Save results to DB
98
+ meta = result['metadata']
99
+ labels = result['labels']
100
+ preds = result['predictions']
101
+ all_model_results = result.get('model_comparison', {})
102
+
103
+ # Save all model results
104
+ save_all_model_results(all_model_results, meta, komoditas_id, pasar_id)
105
+
106
+ # Save best model specifically
107
+ model_id = save_model_ml(meta)
108
+ save_hasil_prediksi(preds, komoditas_id, pasar_id, model_id, meta)
109
+ save_ringkasan_prediksi({
110
+ 'harga_min': labels['harga_min'],
111
+ 'harga_max': labels['harga_max'],
112
+ 'tren': labels['tren'],
113
+ 'confidence_level': labels['confidence_level'],
114
+ 'status_analisis': labels['status_analisis']['judul'],
115
+ 'deskripsi_status': labels['status_analisis']['deskripsi'],
116
+ 'tanggal_mulai': preds[0]['tanggal'],
117
+ 'tanggal_akhir': preds[-1]['tanggal'],
118
+ }, komoditas_id, model_id)
119
+ save_insight_prediksi(labels['insights'], komoditas_id, model_id)
120
+ cv = meta.get('data_quality', {}).get('cv', 0)
121
+ update_komoditas_volatilitas(komoditas_id, 1 if cv >= 5 else 0, cv)
122
 
123
+ forecast_logger.info(f"βœ… {komoditas_nama} selesai! Best: {meta['nama_model']} MAPE={meta['mape']:.2f}%")
124
+
125
+ except Exception as e:
126
+ forecast_logger.error(f"❌ {komoditas_nama} gagal: {e}")
127
+ finally:
128
+ running_forecast_tasks[komoditas_id] = False
129
+
130
+ def auto_forecast_all():
131
+ """Run forecast for ALL komoditas (scheduled or manual run-all)."""
132
+ forecast_logger.info("πŸš€ Auto forecast semua komoditas dimulai...")
133
+ forecast_stop_event.clear()
134
+ try:
135
+ komoditas_list = get_all_komoditas()
136
+ total = len(komoditas_list)
137
+ for idx, (_, row) in enumerate(komoditas_list.iterrows()):
138
+ if forecast_stop_event.is_set():
139
+ forecast_logger.warning("β›” Forecast dihentikan oleh user.")
140
+ break
141
+ forecast_logger.info(f"πŸ“¦ [{idx+1}/{total}] Memproses {row['nama']}...")
142
+ run_forecast_for_komoditas(int(row['id']), row['nama'])
143
+ forecast_logger.info("🏁 Auto forecast selesai.")
144
+ except Exception as e:
145
+ forecast_logger.error(f"❌ Auto forecast error: {e}")
146
+
147
+ # Register forecast job with scheduler
148
+ forecast_scheduler.set_forecast_job(auto_forecast_all)
149
+
150
+ # ── API Startup & Shutdown ─────────────────────────────────────────────────────
151
  @app.on_event("startup")
152
  async def startup_event():
153
+ logger.info("πŸŽ‰ Memulai Sikomo Scraper & Forecast API Server...")
154
+ os.makedirs(runtime_config.model_dir, exist_ok=True)
155
  scraper_scheduler.start_all()
156
+ forecast_scheduler.start_all(default_schedules=runtime_config.default_schedules)
157
  logger.info("πŸš€ API Server siap menerima permintaan.")
158
 
159
  @app.on_event("shutdown")
160
  async def shutdown_event():
161
  scraper_scheduler.stop_all()
162
+ forecast_scheduler.stop_all()
163
  logger.info("πŸ›‘ API Server dimatikan.")
164
 
165
+ # ── Request Validation Models ──────────────────────────────────────────────────
166
+ class ScheduleAdd(BaseModel):
167
+ cron_expression: str
168
+ label: Optional[str] = ""
169
 
170
+ class CustomScrapeRequest(BaseModel):
171
+ date: str # Format YYYY-MM-DD
 
172
 
173
+ class ForecastRequest(BaseModel):
174
+ komoditas_id: int
175
+
176
+ class InsightUpdate(BaseModel):
177
+ konten: str
178
+ tipe: str
179
+ ikon: str
180
+ urutan: int
181
+
182
+ class RingkasanUpdate(BaseModel):
183
+ status_analisis: Optional[str] = None
184
+ deskripsi_status: Optional[str] = None
185
+ tren: Optional[str] = None
186
+
187
+ class ModelUpdate(BaseModel):
188
+ deskripsi: Optional[str] = None
189
+ catatan_validasi: Optional[str] = None
190
+
191
+ class RuntimeConfigUpdate(BaseModel):
192
+ key: str
193
+ value: str
194
+
195
+ # ══════════════════════════════════════════════════════════════════════════════
196
+ # ENDPOINTS
197
+ # ══════════════════════════════════════════════════════════════════════════════
198
 
199
+ # ── Auth ──────────────────────────────────────────────────────────────────────
200
+ @app.post("/auth/login")
201
+ def login(body: dict):
202
+ if body.get('password') != settings.DASHBOARD_PASSWORD:
203
+ raise HTTPException(status_code=401, detail="Password salah")
204
+ return {"token": settings.API_SECRET_KEY}
205
 
206
+ # ── Portal Scraper API ────────────────────────────────────────────────────────
207
+ @app.get("/scraper/status", dependencies=[Depends(verify_token)])
208
+ async def get_scraper_status():
209
+ return scraper_scheduler.get_status()
210
+
211
+ @app.get("/scraper/logs", dependencies=[Depends(verify_token)])
212
+ async def get_scraper_logs(limit: int = Query(100, le=500), level: Optional[str] = None):
213
+ return scraper_memory_handler.get_logs(limit=limit, level=level)
214
+
215
+ @app.get("/scraper/schedules", dependencies=[Depends(verify_token)])
216
+ async def get_scraper_schedules():
217
  return scraper_scheduler.list_schedules()
218
 
219
+ @app.post("/scraper/schedules/add", dependencies=[Depends(verify_token)])
220
+ async def add_scraper_schedule(schedule: ScheduleAdd):
221
  try:
222
  job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label=schedule.label)
223
  return {
224
  "success": True,
225
  "job_id": job_id,
226
+ "message": f"Jadwal scraper berhasil ditambahkan: {schedule.cron_expression}"
227
  }
228
  except Exception as e:
229
  raise HTTPException(status_code=400, detail=str(e))
230
 
231
+ @app.delete("/scraper/schedules/{job_id}/remove", dependencies=[Depends(verify_token)])
232
+ async def remove_scraper_schedule(job_id: str):
233
  try:
234
  scraper_scheduler.remove_schedule(job_id)
235
+ return {"success": True, "message": "Jadwal scraper berhasil dihapus"}
236
  except KeyError:
237
  raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
238
  except Exception as e:
239
  raise HTTPException(status_code=400, detail=str(e))
240
 
241
+ @app.post("/scraper/run/manual", dependencies=[Depends(verify_token)])
 
 
 
 
 
 
 
 
 
 
 
 
242
  def manual_scrape():
243
  logger.info("⚑ Permintaan manual scraping dipicu via API...")
244
  try:
 
247
  stats = upload_to_mysql(df, engine)
248
  return {"success": True, "stats": stats}
249
  except Exception as e:
250
+ logger.error(f"❌ Manual scraping gagal: {e}")
251
  raise HTTPException(status_code=500, detail=str(e))
252
 
253
+ @app.post("/scraper/run/custom", dependencies=[Depends(verify_token)])
254
  def custom_scrape(req: CustomScrapeRequest):
255
+ logger.info(f"⚑ Permintaan scraping custom untuk tanggal {req.date} dipicu via API...")
256
  try:
257
  from datetime import datetime
258
  target_date = datetime.strptime(req.date, "%Y-%m-%d")
 
263
  except ValueError:
264
  raise HTTPException(status_code=400, detail="Format tanggal salah. Gunakan YYYY-MM-DD.")
265
  except Exception as e:
266
+ logger.error(f"❌ Scraping custom gagal: {e}")
267
  raise HTTPException(status_code=500, detail=str(e))
268
 
269
+ @app.post("/scraper/run/stop", dependencies=[Depends(verify_token)])
270
  def stop_manual_scrape():
 
271
  logger.warning("πŸ›‘ Menerima permintaan penghentian scraping manual...")
272
+ scraper_stop_event.set()
273
+ return {"success": True, "message": "Permintaan penghentian scraper dikirim"}
274
 
275
+ @app.post("/scraper/schedule/start", dependencies=[Depends(verify_token)])
276
+ async def start_scraper_scheduler():
277
  scraper_scheduler.start_all()
278
+ return {"success": True, "message": "Scheduler scraper diaktifkan"}
279
 
280
+ @app.post("/scraper/schedule/stop", dependencies=[Depends(verify_token)])
281
+ async def stop_scraper_scheduler():
282
  scraper_scheduler.stop_all()
283
+ return {"success": True, "message": "Scheduler scraper dimatikan"}
284
+
285
+
286
+ # ── Portal Forecast API ───────────────────────────────────────────────────────
287
+ @app.get("/forecast/status", dependencies=[Depends(verify_token)])
288
+ def get_forecast_status():
289
+ sched = forecast_scheduler.get_status()
290
+ return {
291
+ **sched,
292
+ "running_tasks": {k: v for k, v in running_forecast_tasks.items() if v},
293
+ "any_forecast_running": any(running_forecast_tasks.values()),
294
+ }
295
+
296
+ @app.get("/forecast/logs", dependencies=[Depends(verify_token)])
297
+ def get_forecast_logs(limit: int = Query(200, le=500), level: Optional[str] = None):
298
+ return forecast_memory_handler.get_logs(limit=limit, level=level)
299
+
300
+ @app.post("/forecast/clear-logs", dependencies=[Depends(verify_token)])
301
+ def clear_forecast_logs():
302
+ forecast_memory_handler.clear_logs()
303
+ return {"success": True, "message": "Log forecast berhasil dihapus."}
304
+
305
+ @app.get("/forecast/schedules", dependencies=[Depends(verify_token)])
306
+ def list_forecast_schedules():
307
+ return forecast_scheduler.list_schedules()
308
+
309
+ @app.post("/forecast/schedules/add", dependencies=[Depends(verify_token)])
310
+ def add_forecast_schedule(schedule: ScheduleAdd):
311
+ try:
312
+ job_id = forecast_scheduler.add_schedule(
313
+ schedule.cron_expression, label=schedule.label
314
+ )
315
+ return {
316
+ "success": True,
317
+ "job_id": job_id,
318
+ "message": f"Jadwal forecast berhasil ditambahkan: {schedule.cron_expression}",
319
+ }
320
+ except Exception as e:
321
+ raise HTTPException(status_code=400, detail=str(e))
322
+
323
+ @app.delete("/forecast/schedules/{job_id}/remove", dependencies=[Depends(verify_token)])
324
+ def remove_forecast_schedule(job_id: str):
325
+ try:
326
+ forecast_scheduler.remove_schedule(job_id)
327
+ return {"success": True, "message": "Jadwal forecast berhasil dihapus."}
328
+ except KeyError:
329
+ raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
330
+ except Exception as e:
331
+ raise HTTPException(status_code=400, detail=str(e))
332
+
333
+ @app.post("/forecast/schedule/start", dependencies=[Depends(verify_token)])
334
+ def start_forecast_scheduler():
335
+ forecast_scheduler.start_all(default_schedules=runtime_config.default_schedules)
336
+ return {"success": True, "message": "Scheduler forecast diaktifkan."}
337
+
338
+ @app.post("/forecast/schedule/stop", dependencies=[Depends(verify_token)])
339
+ def stop_forecast_scheduler():
340
+ forecast_scheduler.stop_all()
341
+ return {"success": True, "message": "Scheduler forecast dimatikan."}
342
+
343
+ @app.get("/forecast/komoditas", dependencies=[Depends(verify_token)])
344
+ def list_forecast_komoditas():
345
+ df = get_all_komoditas()
346
+ return df.to_dict('records')
347
+
348
+ @app.get("/forecast/prediksi/{komoditas_id}", dependencies=[Depends(verify_token)])
349
+ def get_forecast_prediksi(komoditas_id: int):
350
+ return get_prediksi_data(komoditas_id)
351
+
352
+ @app.post("/forecast/run", dependencies=[Depends(verify_token)])
353
+ def run_forecast_manual(req: ForecastRequest, bg: BackgroundTasks):
354
+ """Run forecast for a SINGLE komoditas."""
355
+ if running_forecast_tasks.get(req.komoditas_id):
356
+ raise HTTPException(409, f"Komoditas ID {req.komoditas_id} sedang diproses.")
357
+
358
+ komoditas_list = get_all_komoditas()
359
+ row = komoditas_list[komoditas_list['id'] == req.komoditas_id]
360
+ if row.empty:
361
+ raise HTTPException(404, "Komoditas tidak ditemukan")
362
+ nama = row.iloc[0]['nama']
363
+ forecast_stop_event.clear()
364
+ bg.add_task(run_forecast_for_komoditas, req.komoditas_id, nama)
365
+ return {"message": f"Forecast untuk {nama} dimulai di background."}
366
+
367
+ @app.post("/forecast/run-all", dependencies=[Depends(verify_token)])
368
+ def run_forecast_all(bg: BackgroundTasks):
369
+ """Run forecast for ALL komoditas."""
370
+ if any(running_forecast_tasks.values()):
371
+ raise HTTPException(409, "Ada forecast yang sedang berjalan.")
372
+ forecast_stop_event.clear()
373
+ bg.add_task(auto_forecast_all)
374
+ return {"message": "Forecast semua komoditas dimulai."}
375
+
376
+ @app.post("/forecast/stop", dependencies=[Depends(verify_token)])
377
+ def stop_forecast():
378
+ """Abort running forecast."""
379
+ forecast_logger.warning("πŸ›‘ Menerima permintaan penghentian forecast...")
380
+ forecast_stop_event.set()
381
+ return {"success": True, "message": "Permintaan penghentian dikirim."}
382
+
383
+ @app.get("/forecast/settings", dependencies=[Depends(verify_token)])
384
+ def get_runtime_settings():
385
+ return runtime_config.to_dict()
386
+
387
+ @app.post("/forecast/settings", dependencies=[Depends(verify_token)])
388
+ def update_runtime_settings(body: RuntimeConfigUpdate):
389
+ try:
390
+ runtime_config.update(body.key, body.value)
391
+ return {"message": f"{body.key} berhasil diperbarui ke '{body.value}'."}
392
+ except ValueError as e:
393
+ raise HTTPException(400, str(e))
394
+
395
+ # ── CRUD Forecast Insights, Ringkasan, Model ──────────────────────────────────
396
+ @app.get("/forecast/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
397
+ def get_insights(komoditas_id: int):
398
+ engine = get_db_engine()
399
+ with engine.connect() as conn:
400
+ rows = conn.execute(
401
+ text("SELECT * FROM insight_prediksi WHERE komoditas_id=:kid ORDER BY urutan"),
402
+ {'kid': komoditas_id}
403
+ ).fetchall()
404
+ return [dict(r._mapping) for r in rows]
405
+
406
+ @app.put("/forecast/insight/{insight_id}", dependencies=[Depends(verify_token)])
407
+ def update_insight(insight_id: int, body: InsightUpdate):
408
+ engine = get_db_engine()
409
+ with engine.begin() as conn:
410
+ conn.execute(text("""
411
+ UPDATE insight_prediksi
412
+ SET konten=:konten, tipe=:tipe, ikon=:ikon, urutan=:urutan, updated_at=NOW()
413
+ WHERE id=:id
414
+ """), {**body.dict(), 'id': insight_id})
415
+ return {"message": "Insight diperbarui."}
416
+
417
+ @app.post("/forecast/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
418
+ def add_insight(komoditas_id: int, body: InsightUpdate):
419
+ engine = get_db_engine()
420
+ with engine.begin() as conn:
421
+ conn.execute(text("""
422
+ INSERT INTO insight_prediksi (komoditas_id, konten, tipe, ikon, urutan, is_active, created_at, updated_at)
423
+ VALUES (:kid, :konten, :tipe, :ikon, :urutan, 1, NOW(), NOW())
424
+ """), {**body.dict(), 'kid': komoditas_id})
425
+ return {"message": "Insight ditambahkan."}
426
+
427
+ @app.delete("/forecast/insight/{insight_id}", dependencies=[Depends(verify_token)])
428
+ def delete_insight(insight_id: int):
429
+ engine = get_db_engine()
430
+ with engine.begin() as conn:
431
+ conn.execute(text("DELETE FROM insight_prediksi WHERE id=:id"), {'id': insight_id})
432
+ return {"message": "Insight dihapus."}
433
+
434
+ @app.put("/forecast/ringkasan/{komoditas_id}", dependencies=[Depends(verify_token)])
435
+ def update_ringkasan(komoditas_id: int, body: RingkasanUpdate):
436
+ engine = get_db_engine()
437
+ updates = {k: v for k, v in body.dict().items() if v is not None}
438
+ if not updates:
439
+ raise HTTPException(400, "Tidak ada field yang diupdate.")
440
+ set_clause = ', '.join([f"{k}=:{k}" for k in updates])
441
+ with engine.begin() as conn:
442
+ conn.execute(
443
+ text(f"UPDATE ringkasan_prediksi SET {set_clause} WHERE komoditas_id=:kid ORDER BY created_at DESC LIMIT 1"),
444
+ {**updates, 'kid': komoditas_id}
445
+ )
446
+ return {"message": "Ringkasan diperbarui."}
447
+
448
+ @app.put("/forecast/model-ml/{komoditas_id}", dependencies=[Depends(verify_token)])
449
+ def update_model_ml(komoditas_id: int, body: ModelUpdate):
450
+ engine = get_db_engine()
451
+ updates = {k: v for k, v in body.dict().items() if v is not None}
452
+ if not updates:
453
+ raise HTTPException(400, "Tidak ada field yang diupdate.")
454
+ set_clause = ', '.join([f"{k}=:{k}" for k in updates])
455
+ with engine.begin() as conn:
456
+ conn.execute(
457
+ text(f"UPDATE model_ml SET {set_clause}, updated_at=NOW() WHERE komoditas_id=:kid AND is_active=1"),
458
+ {**updates, 'kid': komoditas_id}
459
+ )
460
+ return {"message": "Model ML diperbarui."}
461
+
462
+ # ── Debug Endpoints ───────────────────────────────────────────────────────────
463
+ @app.get("/debug/static", dependencies=[Depends(verify_token)])
464
+ def debug_static():
465
+ files = []
466
+ for root, dirs, filenames in os.walk("/app/static"):
467
+ for f in filenames:
468
+ files.append(os.path.relpath(os.path.join(root, f), "/app/static"))
469
+ return {"files": sorted(files)}
470
 
471
+ # ── SPA Serving fallback ──────────────────────────────────────────────────────
 
472
  static_dirs = [
473
  "/app/static",
474
  os.path.join(os.path.dirname(os.path.dirname(__file__)), "static"),
 
486
  @app.get("/")
487
  async def root_fallback():
488
  return {
489
+ "message": "Sikomo Scraper & Forecast API Server beroperasi normal.",
490
  "note": "Frontend static files belum di-build/mount ke direktori static."
491
  }
backend/app/ml_pipeline.py ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ml_pipeline.py β€” Full ML pipeline: cleaning β†’ training β†’ forecasting β†’ labeling
2
+ import os, json, pickle, warnings, logging
3
+ import pandas as pd
4
+ import numpy as np
5
+ from datetime import date
6
+ from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
7
+ from sklearn.model_selection import TimeSeriesSplit
8
+ import lightgbm as lgb
9
+ import xgboost as xgb
10
+ from statsmodels.tsa.arima.model import ARIMA
11
+ from statsmodels.tsa.statespace.sarimax import SARIMAX
12
+ import optuna
13
+ optuna.logging.set_verbosity(optuna.logging.WARNING)
14
+ warnings.filterwarnings('ignore')
15
+ logging.getLogger('cmdstanpy').setLevel(logging.WARNING)
16
+ logging.getLogger('prophet').setLevel(logging.WARNING)
17
+
18
+ MODEL_DIR = os.getenv('MODEL_DIR', 'models')
19
+ os.makedirs(MODEL_DIR, exist_ok=True)
20
+
21
+ # ══════════════════════════════════════════════════════════════════════════════
22
+ # 1. DATA QUALITY & CLEANING
23
+ # ══════════════════════════════════════════════════════════════════════════════
24
+
25
+ def analyze_data_quality(df, col):
26
+ total, missing = len(df), df[col].isna().sum()
27
+ gap_lengths, cur = [], 0
28
+ for v in df[col].isna():
29
+ if v: cur += 1
30
+ else:
31
+ if cur > 0: gap_lengths.append(cur)
32
+ cur = 0
33
+ max_gap = max(gap_lengths) if gap_lengths else 0
34
+ outliers = (df[col].pct_change().abs() > 0.5).sum()
35
+ mean, std = df[col].mean(), df[col].std()
36
+ cv = (std / mean * 100) if mean > 0 else 0
37
+ return {
38
+ 'total_records': total,
39
+ 'missing' : int(missing),
40
+ 'missing_pct' : round(missing / total * 100, 2),
41
+ 'max_gap_days' : max_gap,
42
+ 'outliers' : int(outliers),
43
+ 'cv' : round(cv, 2),
44
+ 'volatility' : 'rendah' if cv < 2 else ('sedang' if cv < 5 else 'tinggi'),
45
+ 'mean_price' : round(mean, 2),
46
+ 'std_price' : round(std, 2),
47
+ }
48
+
49
+ def clean_price_data(df, col, quality_info):
50
+ df = df.copy()
51
+ df[f'{col}_was_missing'] = df[col].isna().astype(int)
52
+ temp = df[col].copy()
53
+ mask = temp.isna()
54
+ gap_id = (mask != mask.shift()).cumsum()
55
+ gap_sizes = mask.groupby(gap_id).transform('sum')
56
+
57
+ temp = temp.fillna(method='ffill', limit=3)
58
+ med_mask = mask & (gap_sizes > 3) & (gap_sizes <= 7)
59
+ ti = temp.interpolate(method='linear', limit=7)
60
+ temp[med_mask] = ti[med_mask]
61
+
62
+ long_mask = mask & (gap_sizes > 7)
63
+ if long_mask.any():
64
+ rolling_med = temp.rolling(30, min_periods=3, center=True).median()
65
+ ti2 = temp.interpolate(method='linear')
66
+ temp[long_mask] = ti2.clip(lower=rolling_med*0.85, upper=rolling_med*1.15)[long_mask]
67
+
68
+ df[col] = temp
69
+ outlier_mask = df[col].pct_change().abs() > 0.4
70
+ if outlier_mask.any():
71
+ df.loc[outlier_mask, col] = np.nan
72
+ df[col] = df[col].interpolate(method='linear', limit=5)
73
+ return df
74
+
75
+ def recommend_models(quality_info):
76
+ models = ['lightgbm', 'xgboost']
77
+ if quality_info['missing_pct'] < 30 and quality_info['max_gap_days'] < 14:
78
+ models.append('prophet')
79
+ if quality_info['cv'] < 5 and quality_info['missing_pct'] < 20:
80
+ models.append('arima')
81
+ if quality_info['total_records'] >= 90 and quality_info['missing_pct'] < 25:
82
+ models.append('sarima')
83
+ return models
84
+
85
+ # ══════════════════════════════════════════════════════════════════════════════
86
+ # 2. FEATURE ENGINEERING
87
+ # ══════════════════════════════════════════════════════════════════════════════
88
+
89
+ def create_features(df, target_col, other_cols=None):
90
+ df = df.copy()
91
+ df['year'] = df['date'].dt.year
92
+ df['month'] = df['date'].dt.month
93
+ df['day'] = df['date'].dt.day
94
+ df['day_of_week'] = df['date'].dt.dayofweek
95
+ df['day_of_year'] = df['date'].dt.dayofyear
96
+ df['week_of_year'] = df['date'].dt.isocalendar().week.astype(int)
97
+ df['quarter'] = df['date'].dt.quarter
98
+ df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
99
+ df['is_month_start']= df['date'].dt.is_month_start.astype(int)
100
+ df['is_month_end'] = df['date'].dt.is_month_end.astype(int)
101
+ for lag in [1, 2, 3, 7, 14, 30]:
102
+ df[f'price_lag_{lag}'] = df[target_col].shift(lag)
103
+ for w in [7, 14, 30]:
104
+ df[f'price_rolling_mean_{w}'] = df[target_col].rolling(w).mean()
105
+ df[f'price_rolling_std_{w}'] = df[target_col].rolling(w).std()
106
+ df[f'price_rolling_min_{w}'] = df[target_col].rolling(w).min()
107
+ df[f'price_rolling_max_{w}'] = df[target_col].rolling(w).max()
108
+ df['price_diff_1'] = df[target_col].diff(1)
109
+ df['price_diff_7'] = df[target_col].diff(7)
110
+ df['price_pct_change_1'] = df[target_col].pct_change(1) * 100
111
+ df['price_pct_change_7'] = df[target_col].pct_change(7) * 100
112
+ df['sma_7'] = df[target_col].rolling(7).mean()
113
+ df['sma_30'] = df[target_col].rolling(30).mean()
114
+ df['sma_diff'] = df['sma_7'] - df['sma_30']
115
+ df['volatility_7'] = df[target_col].rolling(7).std()
116
+ df['volatility_30'] = df[target_col].rolling(30).std()
117
+ if other_cols:
118
+ for i, oc in enumerate(other_cols):
119
+ if oc in df.columns:
120
+ df[f'other_price_{i}'] = df[oc]
121
+ df[f'price_spread_{i}'] = df[target_col] - df[oc]
122
+ df[f'price_ratio_{i}'] = df[target_col] / (df[oc] + 1e-6)
123
+ return df
124
+
125
+ # ══════════════════════════════════════════════════════════════════════════════
126
+ # 3. TRAINING
127
+ # ══════════════════════════════════════════════════════════════════════════════
128
+
129
+ def evaluate_model(y_true, y_pred, model_name=""):
130
+ rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
131
+ return {
132
+ 'model' : model_name,
133
+ 'rmse' : rmse,
134
+ 'mae' : float(mean_absolute_error(y_true, y_pred)),
135
+ 'r2' : float(r2_score(y_true, y_pred)),
136
+ 'mape' : float(np.mean(np.abs((y_true - y_pred) / (np.abs(y_true)+1e-6))) * 100),
137
+ 'median_ae': float(np.median(np.abs(y_true - y_pred))),
138
+ }
139
+
140
+ def train_lightgbm(X_train, y_train, X_test, y_test):
141
+ m = lgb.LGBMRegressor(
142
+ objective='regression', metric='rmse', num_leaves=31,
143
+ max_depth=7, learning_rate=0.05, n_estimators=300,
144
+ min_child_samples=20, subsample=0.8, colsample_bytree=0.8,
145
+ reg_alpha=0.1, reg_lambda=0.1, verbose=-1,
146
+ random_state=42, force_col_wise=True,
147
+ )
148
+ m.fit(X_train, y_train, eval_set=[(X_test, y_test)],
149
+ eval_metric='rmse', callbacks=[lgb.early_stopping(50, verbose=False)])
150
+ return m, evaluate_model(y_test, m.predict(X_test), 'LightGBM')
151
+
152
+ def train_xgboost(X_train, y_train, X_test, y_test):
153
+ m = xgb.XGBRegressor(
154
+ objective='reg:squarederror', n_estimators=300, max_depth=6,
155
+ learning_rate=0.05, subsample=0.8, colsample_bytree=0.8,
156
+ reg_alpha=0.1, reg_lambda=0.1, random_state=42, verbosity=0,
157
+ )
158
+ m.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
159
+ return m, evaluate_model(y_test, m.predict(X_test), 'XGBoost')
160
+
161
+ def train_prophet(dates_train, y_train, dates_test, y_test):
162
+ """Train Prophet with robust error handling and data validation."""
163
+ try:
164
+ from prophet import Prophet
165
+ except ImportError:
166
+ raise RuntimeError("Prophet not installed")
167
+ # Ensure clean datetime + float data
168
+ ds_train = pd.to_datetime(dates_train).reset_index(drop=True)
169
+ y_tr = y_train.reset_index(drop=True).astype(float)
170
+ ds_test = pd.to_datetime(dates_test).reset_index(drop=True)
171
+ y_te = y_test.reset_index(drop=True).astype(float)
172
+ # Remove NaN rows
173
+ mask = y_tr.notna()
174
+ ds_train = ds_train[mask]
175
+ y_tr = y_tr[mask]
176
+ if len(y_tr) < 30:
177
+ raise ValueError("Prophet: data training terlalu sedikit setelah cleaning")
178
+ df_train = pd.DataFrame({'ds': ds_train.values, 'y': y_tr.values})
179
+ df_test = pd.DataFrame({'ds': ds_test.values})
180
+ m = Prophet(
181
+ yearly_seasonality=True, weekly_seasonality=True,
182
+ daily_seasonality=False, changepoint_prior_scale=0.05,
183
+ )
184
+
185
+ # Matikan log dengan cara yang lebih aman
186
+ logging.getLogger('prophet').setLevel(logging.ERROR)
187
+ logging.getLogger('cmdstanpy').setLevel(logging.ERROR)
188
+
189
+ m.fit(df_train)
190
+ fc = m.predict(df_test)
191
+ pred = np.clip(fc['yhat'].values, 0, None)
192
+ return m, evaluate_model(y_te.values, pred, 'Prophet')
193
+
194
+ def train_arima(y_train, y_test):
195
+ m = ARIMA(y_train.values, order=(5, 1, 0)).fit()
196
+ fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
197
+ return m, evaluate_model(y_test.values, fc, 'ARIMA')
198
+
199
+ def train_sarima(y_train, y_test):
200
+ """Train SARIMA with seasonal_order for weekly patterns."""
201
+ m = SARIMAX(
202
+ y_train.values, order=(1, 1, 1), seasonal_order=(1, 1, 1, 7),
203
+ enforce_stationarity=False, enforce_invertibility=False,
204
+ ).fit(disp=False, maxiter=200)
205
+ fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
206
+ return m, evaluate_model(y_test.values, fc, 'SARIMA')
207
+
208
+ def forecast_sarima(model, n_days=7):
209
+ """Forecast n_days ahead using fitted SARIMA model."""
210
+ fc = np.clip(model.forecast(steps=n_days), 0, None)
211
+ last_date = pd.Timestamp.now().normalize()
212
+ return [
213
+ {'tanggal': str((last_date + pd.Timedelta(days=i+1)).date()),
214
+ 'harga_prediksi': round(float(fc[i]), 2)}
215
+ for i in range(n_days)
216
+ ]
217
+
218
+ def tune_with_optuna(model_name, X_train, y_train, n_trials=50):
219
+ def objective(trial):
220
+ if model_name == 'LightGBM':
221
+ params = {
222
+ 'objective':'regression','metric':'rmse','verbose':-1,
223
+ 'random_state':42,'force_col_wise':True,
224
+ 'num_leaves' : trial.suggest_int('num_leaves', 20, 100),
225
+ 'max_depth' : trial.suggest_int('max_depth', 4, 12),
226
+ 'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.3),
227
+ 'n_estimators' : trial.suggest_int('n_estimators', 100, 500),
228
+ 'min_child_samples': trial.suggest_int('min_child_samples', 5, 50),
229
+ 'subsample' : trial.suggest_float('subsample', 0.5, 1.0),
230
+ 'colsample_bytree' : trial.suggest_float('colsample_bytree', 0.5, 1.0),
231
+ 'reg_alpha' : trial.suggest_float('reg_alpha', 0.0, 1.0),
232
+ 'reg_lambda' : trial.suggest_float('reg_lambda', 0.0, 1.0),
233
+ }
234
+ Cls = lgb.LGBMRegressor
235
+ else:
236
+ params = {
237
+ 'objective':'reg:squarederror','random_state':42,'verbosity':0,
238
+ 'n_estimators' : trial.suggest_int('n_estimators', 100, 500),
239
+ 'max_depth' : trial.suggest_int('max_depth', 3, 10),
240
+ 'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.3),
241
+ 'subsample' : trial.suggest_float('subsample', 0.5, 1.0),
242
+ 'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
243
+ 'reg_alpha' : trial.suggest_float('reg_alpha', 0.0, 1.0),
244
+ 'reg_lambda' : trial.suggest_float('reg_lambda', 0.0, 1.0),
245
+ }
246
+ Cls = xgb.XGBRegressor
247
+ scores = []
248
+ for tr, val in TimeSeriesSplit(n_splits=5).split(X_train):
249
+ m = Cls(**params)
250
+ m.fit(X_train.iloc[tr], y_train.iloc[tr])
251
+ scores.append(np.sqrt(mean_squared_error(y_train.iloc[val], m.predict(X_train.iloc[val]))))
252
+ return np.mean(scores)
253
+
254
+ study = optuna.create_study(direction='minimize')
255
+ study.optimize(objective, n_trials=n_trials)
256
+ return study.best_params
257
+
258
+ # ══════════════════════════════════════════════════════════════════════════════
259
+ # 4. FORECASTING
260
+ # ══════════════════════════════════════════════════════════════════════════════
261
+
262
+ def forecast_tree(model, df_hist, target_col, other_cols, feature_cols, n_days=7):
263
+ df_temp = df_hist.copy()
264
+ preds = []
265
+ last_date = df_temp['date'].max()
266
+ for i in range(1, n_days + 1):
267
+ next_date = last_date + pd.Timedelta(days=i)
268
+ new_row = {'date': next_date, target_col: np.nan}
269
+ for oc in (other_cols or []):
270
+ if oc in df_temp.columns:
271
+ lo = df_temp[oc].dropna()
272
+ new_row[oc] = float(lo.iloc[-1]) if len(lo) else np.nan
273
+ df_temp = pd.concat([df_temp, pd.DataFrame([new_row])], ignore_index=True)
274
+ df_feat = create_features(df_temp, target_col, other_cols)
275
+ last_row = df_feat.iloc[[-1]].copy()
276
+ for col in feature_cols:
277
+ if col not in last_row.columns:
278
+ last_row[col] = 0.0
279
+ pred = max(0, float(model.predict(last_row[feature_cols])[0]))
280
+ preds.append({'tanggal': str(next_date.date()), 'harga_prediksi': round(pred, 2)})
281
+ df_temp.loc[df_temp['date'] == next_date, target_col] = pred
282
+ return preds
283
+
284
+ def forecast_arima(model, n_days=7):
285
+ """Forecast n_days ahead using fitted ARIMA model."""
286
+ fc = np.clip(model.forecast(steps=n_days), 0, None)
287
+ last_date = pd.Timestamp.now().normalize()
288
+ return [
289
+ {'tanggal': str((last_date + pd.Timedelta(days=i+1)).date()),
290
+ 'harga_prediksi': round(float(fc[i]), 2)}
291
+ for i in range(n_days)
292
+ ]
293
+
294
+ def forecast_prophet(model, last_date, n_days=7):
295
+ future = pd.DataFrame({'ds': pd.date_range(start=last_date + pd.Timedelta(days=1), periods=n_days)})
296
+ fc = model.predict(future)
297
+ return [{'tanggal': str(r.ds.date()), 'harga_prediksi': round(max(0, r.yhat), 2)}
298
+ for _, r in fc.iterrows()]
299
+
300
+ # ══════════════════════════════════════════════════════════════════════════════
301
+ # 5. LABEL GENERATOR (untuk frontend)
302
+ # ═════════════════════════════════════��════════════════════════════════════════
303
+
304
+ def get_tren(preds):
305
+ pct = (preds[-1]['harga_prediksi'] - preds[0]['harga_prediksi']) / preds[0]['harga_prediksi'] * 100
306
+ if pct > 2: return 'naik_tajam', pct
307
+ elif pct > 0.5: return 'naik_terkendali', pct
308
+ elif pct < -2: return 'turun_tajam', pct
309
+ elif pct < -0.5: return 'turun_terkendali', pct
310
+ else: return 'stabil', pct
311
+
312
+ def get_labels(mape, rmse, mean_price, cv, preds, komoditas_nama):
313
+ harga_vals = [p['harga_prediksi'] for p in preds]
314
+ tren, pct = get_tren(preds)
315
+ confidence = round(max(0, min(100, 100 - mape * 2)), 2)
316
+ stabilitas = ('optimal' if mape < 2 else 'baik' if mape < 5
317
+ else 'cukup' if mape < 10 else 'perlu_retrain')
318
+ mape_label = ('Sangat Rendah' if mape < 5 else 'Rendah' if mape < 10
319
+ else 'Sedang' if mape < 15 else 'Tinggi' if mape < 25 else 'Sangat Tinggi')
320
+ rmse_pct = rmse / mean_price * 100
321
+ rmse_label = ('Presisi Sangat Tinggi' if rmse_pct < 1 else 'Presisi Tinggi' if rmse_pct < 2
322
+ else 'Presisi Sedang' if rmse_pct < 5 else 'Presisi Rendah')
323
+ cl_label = ('Sangat Tinggi' if confidence >= 90 else 'Tinggi' if confidence >= 80
324
+ else 'Sedang' if confidence >= 65 else 'Rendah')
325
+ vol_label = ('Rendah' if cv < 2 else 'Sedang' if cv < 5 else 'Tinggi')
326
+
327
+ if mape < 10:
328
+ status = {'judul': 'Akurasi Terverifikasi',
329
+ 'deskripsi': f'Prediksi divalidasi, deviasi rata-rata di bawah {mape:.1f}%.'}
330
+ elif mape < 20:
331
+ status = {'judul': 'Akurasi Cukup',
332
+ 'deskripsi': f'Deviasi rata-rata {mape:.1f}%. Gunakan sebagai referensi.'}
333
+ else:
334
+ status = {'judul': 'Perlu Perhatian',
335
+ 'deskripsi': f'Deviasi {mape:.1f}%. Disarankan retraining.'}
336
+
337
+ tren_map = {
338
+ 'stabil' : ('positif', 'check-circle', f'Harga {komoditas_nama} diprediksi stabil 7 hari ke depan.'),
339
+ 'naik_terkendali' : ('negatif', 'trending-up', f'Harga {komoditas_nama} diprediksi naik {abs(pct):.1f}%.'),
340
+ 'naik_tajam' : ('negatif', 'alert-triangle', f'Harga {komoditas_nama} diprediksi naik tajam {abs(pct):.1f}%.'),
341
+ 'turun_terkendali': ('positif', 'trending-down', f'Harga {komoditas_nama} diprediksi turun {abs(pct):.1f}%.'),
342
+ 'turun_tajam' : ('netral', 'alert-triangle', f'Harga {komoditas_nama} diprediksi turun tajam {abs(pct):.1f}%.'),
343
+ }
344
+ t1, ikon1, k1 = tren_map.get(tren, ('netral', 'info', f'Tren: {tren}'))
345
+ peak = max(preds, key=lambda x: x['harga_prediksi'])
346
+ trough = min(preds, key=lambda x: x['harga_prediksi'])
347
+
348
+ insights = [
349
+ {'tipe': t1, 'ikon': ikon1, 'konten': k1, 'urutan': 1},
350
+ {
351
+ 'tipe' : 'positif' if cv < 2 else ('netral' if cv < 5 else 'negatif'),
352
+ 'ikon' : 'check-circle' if cv < 2 else 'info',
353
+ 'konten': ('Pasokan terpantau mencukupi.' if cv < 2
354
+ else f'Volatilitas sedang (CV={cv:.1f}%).' if cv < 5
355
+ else f'Harga sangat fluktuatif (CV={cv:.1f}%).'),
356
+ 'urutan': 2,
357
+ },
358
+ {
359
+ 'tipe' : 'netral',
360
+ 'ikon' : 'calendar',
361
+ 'konten': (f"Harga tertinggi Rp {peak['harga_prediksi']:,.0f}, "
362
+ f"terendah Rp {trough['harga_prediksi']:,.0f} dalam 7 hari."),
363
+ 'urutan': 3,
364
+ },
365
+ ]
366
+
367
+ return {
368
+ 'tren' : tren,
369
+ 'pct_change' : round(pct, 2),
370
+ 'confidence_level': confidence,
371
+ 'confidence_label': cl_label,
372
+ 'stabilitas' : stabilitas,
373
+ 'mape_label' : mape_label,
374
+ 'rmse_label' : rmse_label,
375
+ 'volatilitas_label': vol_label,
376
+ 'status_analisis' : status,
377
+ 'insights' : insights,
378
+ 'harga_min' : min(harga_vals),
379
+ 'harga_max' : max(harga_vals),
380
+ }
381
+
382
+ # ══════════════════════════════════════════════════════════════════════════════
383
+ # 6. MAIN PIPELINE β€” dipanggil oleh API
384
+ # ══════════════════════════════════════════════════════════════════════════════
385
+
386
+ def run_pipeline(komoditas_id: int, komoditas_nama: str, df: pd.DataFrame,
387
+ target_market: str, pasar_id: int, n_trials: int = 50,
388
+ forecast_days: int = 7, log_cb=None):
389
+ """
390
+ Full pipeline: cleaning β†’ training β†’ tuning β†’ forecasting β†’ labeling β†’ save.
391
+ log_cb: callback function(msg: str) untuk streaming log ke frontend.
392
+ """
393
+ def log(msg):
394
+ if log_cb: log_cb(msg)
395
+
396
+ other_cols = [c for c in df.columns if c not in ['date', target_market]
397
+ and not c.endswith('_was_missing')]
398
+
399
+ # ── Cleaning ──
400
+ log("🧹 Cleaning data...")
401
+ all_cols = [target_market] + other_cols
402
+ quality_reports = {}
403
+ for col in all_cols:
404
+ if col in df.columns:
405
+ q = analyze_data_quality(df, col)
406
+ quality_reports[col] = q
407
+ df = clean_price_data(df, col, q)
408
+
409
+ models_to_run = recommend_models(quality_reports.get(target_market, {}))
410
+ log(f"πŸ€– Model yang akan dijalankan: {models_to_run}")
411
+
412
+ # ── Prepare dataset ──
413
+ exclude = ['date'] + [c for c in df.columns if c.endswith('_was_missing')]
414
+ df_feat = create_features(df, target_market, other_cols)
415
+ df_clean = df_feat.dropna(subset=[target_market]).reset_index(drop=True)
416
+ feature_cols = [c for c in df_clean.columns if c not in exclude + all_cols]
417
+
418
+ X = df_clean[feature_cols]
419
+ y = df_clean[target_market]
420
+ dates = df_clean['date']
421
+ split = int(len(df_clean) * 0.7)
422
+ X_train, X_test = X.iloc[:split], X.iloc[split:]
423
+ y_train, y_test = y.iloc[:split], y.iloc[split:]
424
+ mean_price = float(y.mean())
425
+
426
+ # ── Train ──
427
+ all_results = {}
428
+ lgb_model = xgb_model = prophet_model = None
429
+
430
+ if 'lightgbm' in models_to_run:
431
+ log("Training LightGBM...")
432
+ lgb_model, res = train_lightgbm(X_train, y_train, X_test, y_test)
433
+ all_results['LightGBM'] = {**res, 'obj': lgb_model, 'type': 'tree'}
434
+
435
+ if 'xgboost' in models_to_run:
436
+ log("Training XGBoost...")
437
+ xgb_model, res = train_xgboost(X_train, y_train, X_test, y_test)
438
+ all_results['XGBoost'] = {**res, 'obj': xgb_model, 'type': 'tree'}
439
+
440
+ if 'prophet' in models_to_run:
441
+ log("Training Prophet...")
442
+ try:
443
+ prophet_model, res = train_prophet(dates.iloc[:split], y_train, dates.iloc[split:], y_test)
444
+ all_results['Prophet'] = {**res, 'obj': prophet_model, 'type': 'prophet'}
445
+ except Exception as e:
446
+ log(f"⚠️ Prophet gagal: {e}")
447
+
448
+ if 'arima' in models_to_run:
449
+ log("Training ARIMA...")
450
+ try:
451
+ arima_model, res = train_arima(y_train, y_test)
452
+ all_results['ARIMA'] = {**res, 'obj': arima_model, 'type': 'arima'}
453
+ except Exception as e:
454
+ log(f"⚠️ ARIMA gagal: {e}")
455
+
456
+ if 'sarima' in models_to_run:
457
+ log("Training SARIMA...")
458
+ try:
459
+ sarima_model, res = train_sarima(y_train, y_test)
460
+ all_results['SARIMA'] = {**res, 'obj': sarima_model, 'type': 'sarima'}
461
+ except Exception as e:
462
+ log(f"⚠️ SARIMA gagal: {e}")
463
+
464
+ best_name = min(all_results, key=lambda k: all_results[k]['rmse'])
465
+ best = all_results[best_name]
466
+ log(f"πŸ† Best model: {best_name} (RMSE={best['rmse']:.2f})")
467
+
468
+ # ── Tuning ──
469
+ final_model = best['obj']
470
+ final_result = best
471
+ final_name = best_name
472
+
473
+ if best['type'] == 'tree':
474
+ log(f"πŸ”§ Tuning {best_name} dengan Optuna ({n_trials} trials)...")
475
+ best_params = tune_with_optuna(best_name, X_train, y_train, n_trials)
476
+ if best_name == 'LightGBM':
477
+ tuned = lgb.LGBMRegressor(**best_params, objective='regression',
478
+ verbose=-1, random_state=42, force_col_wise=True)
479
+ else:
480
+ tuned = xgb.XGBRegressor(**best_params, objective='reg:squarederror',
481
+ random_state=42, verbosity=0)
482
+ tuned.fit(X_train, y_train)
483
+ tuned_res = evaluate_model(y_test, tuned.predict(X_test), f"{best_name}_Tuned")
484
+ if tuned_res['rmse'] < best['rmse']:
485
+ final_model = tuned
486
+ final_result = tuned_res
487
+ final_name = f"{best_name}_Tuned"
488
+ log(f"βœ… Tuned lebih baik, improvement={(best['rmse']-tuned_res['rmse'])/best['rmse']*100:.1f}%")
489
+
490
+ # ── Forecast ──
491
+ n_fc = forecast_days
492
+ log(f"πŸ“… Forecasting {n_fc} hari...")
493
+ df_hist = df[['date', target_market] + [c for c in other_cols if c in df.columns]].copy()
494
+ f_type = final_result.get('type', '')
495
+ if f_type == 'prophet' or final_name == 'Prophet':
496
+ predictions = forecast_prophet(final_model, df_hist['date'].max(), n_fc)
497
+ elif f_type == 'arima':
498
+ predictions = forecast_arima(final_model, n_fc)
499
+ elif f_type == 'sarima':
500
+ predictions = forecast_sarima(final_model, n_fc)
501
+ else:
502
+ predictions = forecast_tree(final_model, df_hist, target_market, other_cols, feature_cols, n_fc)
503
+
504
+ # ── Labels ──
505
+ cv = quality_reports.get(target_market, {}).get('cv', 0)
506
+ labels = get_labels(
507
+ final_result['mape'], final_result['rmse'],
508
+ mean_price, cv, predictions, komoditas_nama
509
+ )
510
+
511
+ # ── Simpan model ──
512
+ prefix = os.path.join(MODEL_DIR, f"model_{komoditas_id}_{pasar_id}")
513
+ model_path= f"{prefix}.pkl"
514
+ with open(model_path, 'wb') as f:
515
+ pickle.dump(final_model, f)
516
+ with open(f"{prefix}_features.json", 'w') as f:
517
+ json.dump(feature_cols, f)
518
+
519
+ metadata = {
520
+ 'komoditas_id' : komoditas_id,
521
+ 'komoditas_nama' : komoditas_nama,
522
+ 'pasar_id' : pasar_id,
523
+ 'target_market' : target_market,
524
+ 'other_markets' : other_cols,
525
+ 'nama_model' : final_name,
526
+ 'versi' : '1.0',
527
+ 'file_path' : model_path,
528
+ 'mape' : round(final_result['mape'], 4),
529
+ 'rmse' : round(final_result['rmse'], 4),
530
+ 'mae' : round(final_result['mae'], 4),
531
+ 'r2_score' : round(final_result['r2'], 4),
532
+ 'confidence_level': labels['confidence_level'],
533
+ 'stabilitas' : labels['stabilitas'],
534
+ 'status_validasi' : 'terverifikasi',
535
+ 'catatan_validasi': labels['status_analisis']['deskripsi'],
536
+ 'tanggal_training': date.today().isoformat(),
537
+ 'tanggal_evaluasi': date.today().isoformat(),
538
+ 'deskripsi' : f"Model {final_name} untuk prediksi harga {komoditas_nama}.",
539
+ 'models_tried' : list(all_results.keys()),
540
+ 'data_quality' : quality_reports.get(target_market, {}),
541
+ }
542
+ with open(f"{prefix}_metadata.json", 'w') as f:
543
+ json.dump(metadata, f, indent=2)
544
+
545
+ log(f"βœ… Pipeline selesai! MAPE={final_result['mape']:.2f}%")
546
+
547
+ return {
548
+ 'predictions' : predictions,
549
+ 'labels' : labels,
550
+ 'metadata' : metadata,
551
+ 'model_comparison': {k: {
552
+ 'rmse': round(v['rmse'], 4),
553
+ 'mape': round(v['mape'], 4),
554
+ 'mae': round(v['mae'], 4),
555
+ 'r2': round(v['r2'], 4)
556
+ } for k, v in all_results.items()},
557
+ }
backend/app/scheduler.py CHANGED
@@ -1,16 +1,29 @@
 
1
  from apscheduler.schedulers.background import BackgroundScheduler
2
  from apscheduler.triggers.cron import CronTrigger
3
  from app.scraper import scraping_hari_ini, upload_to_mysql
4
  from app.database import get_db_engine
5
  from app.config import get_settings
6
- from app.logger import get_app_logger
7
  from datetime import datetime
8
  import uuid
9
- from pytz import timezone
10
 
11
- logger = get_app_logger("scheduler")
 
 
 
 
 
 
 
 
 
 
12
  settings = get_settings()
13
- wib_tz = timezone("Asia/Jakarta")
 
 
 
14
 
15
  class ScraperScheduler:
16
  def __init__(self):
@@ -21,7 +34,7 @@ class ScraperScheduler:
21
  self.schedules = {} # id -> {"cron": str, "label": str}
22
 
23
  def run_scraping(self):
24
- logger.info("⏰ Menjalankan tugas scraping terjadwal otomatis...")
25
  try:
26
  df = scraping_hari_ini()
27
  engine = get_db_engine()
@@ -32,21 +45,32 @@ class ScraperScheduler:
32
  "stats": stats,
33
  "timestamp": self.last_run.isoformat()
34
  }
35
- logger.info("βœ… Tugas scraping otomatis selesai dijalankan.")
36
  except Exception as e:
37
  self.last_result = {
38
  "success": False,
39
  "error": str(e),
40
  "timestamp": datetime.now().isoformat()
41
  }
42
- logger.error(f"❌ Tugas scraping otomatis gagal: {e}")
43
 
44
  def start_all(self):
45
  if not self.is_running:
 
46
  self.scheduler.start()
47
  self.is_running = True
48
- logger.info("▢️ Mesin Scheduler utama telah diaktifkan.")
49
 
 
 
 
 
 
 
 
 
 
 
50
  # Load default schedules if empty
51
  if not self.schedules:
52
  defaults = [s.strip() for s in settings.DEFAULT_SCHEDULES.split(",") if s.strip()]
@@ -55,15 +79,16 @@ class ScraperScheduler:
55
 
56
  def stop_all(self):
57
  if self.is_running:
58
- self.scheduler.shutdown(wait=False)
59
- self.scheduler = BackgroundScheduler() # reset
 
 
 
60
  self.is_running = False
61
  self.schedules.clear()
62
- logger.info("⏹️ Mesin Scheduler utama telah dimatikan.")
63
 
64
  def add_schedule(self, cron_expression: str, label: str = "") -> str:
65
- # Check if we already have this exact cron to avoid exact duplicates if desired,
66
- # but let's allow unique IDs
67
  job_id = str(uuid.uuid4())[:8]
68
  try:
69
  trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
@@ -76,35 +101,32 @@ class ScraperScheduler:
76
  if not label:
77
  label = f"Jadwal {cron_expression}"
78
  self.schedules[job_id] = {"cron": cron_expression, "label": label}
79
- logger.info(f"πŸ“… Jadwal baru ditambahkan: {cron_expression} ({label})")
80
 
81
- # Ensure scheduler is running if we add a job
82
  if not self.is_running:
83
  self.scheduler.start()
84
  self.is_running = True
85
- logger.info("▢️ Mesin Scheduler otomatis menyala karena ada jadwal baru.")
86
 
87
  return job_id
88
  except Exception as e:
89
- logger.error(f"❌ Gagal menambahkan jadwal cron '{cron_expression}': {e}")
90
  raise ValueError(f"Format Cron tidak valid: {e}")
91
 
92
  def remove_schedule(self, job_id: str):
93
  if job_id in self.schedules:
94
  try:
95
  self.scheduler.remove_job(job_id)
96
- except:
97
  pass
98
  deleted = self.schedules.pop(job_id)
99
- logger.info(f"πŸ—‘οΈ Jadwal dihapus: {deleted['cron']} ({deleted['label']})")
100
  else:
101
  raise KeyError("ID Jadwal tidak ditemukan")
102
 
103
  def list_schedules(self):
104
  result = []
105
- # Get active jobs from APScheduler to find next_run_time
106
  jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
107
-
108
  for jid, info in self.schedules.items():
109
  job_obj = jobs_map.get(jid)
110
  next_run = job_obj.next_run_time.isoformat() if (job_obj and job_obj.next_run_time) else None
@@ -117,8 +139,7 @@ class ScraperScheduler:
117
  return result
118
 
119
  def get_status(self):
120
- # Let's find the absolute next run among all active jobs
121
- jobs = self.scheduler.get_jobs()
122
  next_runs = [j.next_run_time for j in jobs if j.next_run_time]
123
  next_run_overall = min(next_runs).isoformat() if next_runs else None
124
 
@@ -130,4 +151,138 @@ class ScraperScheduler:
130
  "active_schedules_count": len(self.schedules)
131
  }
132
 
133
- scraper_scheduler = ScraperScheduler()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # scheduler.py β€” Multiple-schedule Scraping and Forecasting Manager
2
  from apscheduler.schedulers.background import BackgroundScheduler
3
  from apscheduler.triggers.cron import CronTrigger
4
  from app.scraper import scraping_hari_ini, upload_to_mysql
5
  from app.database import get_db_engine
6
  from app.config import get_settings
7
+ from app.logger import get_app_logger, get_forecast_logger
8
  from datetime import datetime
9
  import uuid
 
10
 
11
+ try:
12
+ from pytz import timezone as pytz_timezone
13
+ wib_tz = pytz_timezone("Asia/Jakarta")
14
+ except ImportError:
15
+ import zoneinfo
16
+ wib_tz = zoneinfo.ZoneInfo("Asia/Jakarta")
17
+
18
+ # Loggers
19
+ scraper_log = get_app_logger("scheduler")
20
+ forecast_log = get_forecast_logger("scheduler")
21
+
22
  settings = get_settings()
23
+
24
+ # ══════════════════════════════════════════════════════════════════════════════
25
+ # 1. SCRAPER SCHEDULER
26
+ # ══════════════════════════════════════════════════════════════════════════════
27
 
28
  class ScraperScheduler:
29
  def __init__(self):
 
34
  self.schedules = {} # id -> {"cron": str, "label": str}
35
 
36
  def run_scraping(self):
37
+ scraper_log.info("⏰ Menjalankan tugas scraping terjadwal otomatis...")
38
  try:
39
  df = scraping_hari_ini()
40
  engine = get_db_engine()
 
45
  "stats": stats,
46
  "timestamp": self.last_run.isoformat()
47
  }
48
+ scraper_log.info("βœ… Tugas scraping otomatis selesai dijalankan.")
49
  except Exception as e:
50
  self.last_result = {
51
  "success": False,
52
  "error": str(e),
53
  "timestamp": datetime.now().isoformat()
54
  }
55
+ scraper_log.error(f"❌ Tugas scraping otomatis gagal: {e}")
56
 
57
  def start_all(self):
58
  if not self.is_running:
59
+ self.scheduler = BackgroundScheduler(timezone=wib_tz)
60
  self.scheduler.start()
61
  self.is_running = True
62
+ scraper_log.info("▢️ Mesin Scheduler Scraper telah diaktifkan.")
63
 
64
+ # Re-register existing schedules
65
+ for jid, info in list(self.schedules.items()):
66
+ try:
67
+ trigger = CronTrigger.from_crontab(info["cron"], timezone=wib_tz)
68
+ self.scheduler.add_job(
69
+ self.run_scraping, trigger, id=jid, replace_existing=True
70
+ )
71
+ except Exception:
72
+ pass
73
+
74
  # Load default schedules if empty
75
  if not self.schedules:
76
  defaults = [s.strip() for s in settings.DEFAULT_SCHEDULES.split(",") if s.strip()]
 
79
 
80
  def stop_all(self):
81
  if self.is_running:
82
+ try:
83
+ self.scheduler.shutdown(wait=False)
84
+ except Exception:
85
+ pass
86
+ self.scheduler = BackgroundScheduler(timezone=wib_tz)
87
  self.is_running = False
88
  self.schedules.clear()
89
+ scraper_log.info("⏹️ Mesin Scheduler Scraper telah dimatikan.")
90
 
91
  def add_schedule(self, cron_expression: str, label: str = "") -> str:
 
 
92
  job_id = str(uuid.uuid4())[:8]
93
  try:
94
  trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
 
101
  if not label:
102
  label = f"Jadwal {cron_expression}"
103
  self.schedules[job_id] = {"cron": cron_expression, "label": label}
104
+ scraper_log.info(f"πŸ“… Jadwal baru ditambahkan: {cron_expression} ({label})")
105
 
 
106
  if not self.is_running:
107
  self.scheduler.start()
108
  self.is_running = True
109
+ scraper_log.info("▢️ Mesin Scheduler Scraper menyala karena ada jadwal baru.")
110
 
111
  return job_id
112
  except Exception as e:
113
+ scraper_log.error(f"❌ Gagal menambahkan jadwal cron '{cron_expression}': {e}")
114
  raise ValueError(f"Format Cron tidak valid: {e}")
115
 
116
  def remove_schedule(self, job_id: str):
117
  if job_id in self.schedules:
118
  try:
119
  self.scheduler.remove_job(job_id)
120
+ except Exception:
121
  pass
122
  deleted = self.schedules.pop(job_id)
123
+ scraper_log.info(f"πŸ—‘οΈ Jadwal dihapus: {deleted['cron']} ({deleted['label']})")
124
  else:
125
  raise KeyError("ID Jadwal tidak ditemukan")
126
 
127
  def list_schedules(self):
128
  result = []
 
129
  jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
 
130
  for jid, info in self.schedules.items():
131
  job_obj = jobs_map.get(jid)
132
  next_run = job_obj.next_run_time.isoformat() if (job_obj and job_obj.next_run_time) else None
 
139
  return result
140
 
141
  def get_status(self):
142
+ jobs = self.scheduler.get_jobs() if self.is_running else []
 
143
  next_runs = [j.next_run_time for j in jobs if j.next_run_time]
144
  next_run_overall = min(next_runs).isoformat() if next_runs else None
145
 
 
151
  "active_schedules_count": len(self.schedules)
152
  }
153
 
154
+ # ══════════════════════════════════════════════════════════════════════════════
155
+ # 2. FORECAST SCHEDULER
156
+ # ══════════════════════════════════════════════════════════════════════════════
157
+
158
+ class ForecastScheduler:
159
+ def __init__(self):
160
+ self.scheduler = BackgroundScheduler(timezone=wib_tz)
161
+ self.is_running = False
162
+ self.last_run = None
163
+ self.last_result = None
164
+ self.schedules = {} # id -> {"cron": str, "label": str}
165
+ self._forecast_job_fn = None
166
+
167
+ def set_forecast_job(self, fn):
168
+ self._forecast_job_fn = fn
169
+
170
+ def _run_job(self):
171
+ forecast_log.info("⏰ Menjalankan forecast terjadwal otomatis...")
172
+ try:
173
+ if self._forecast_job_fn:
174
+ self._forecast_job_fn()
175
+ self.last_run = datetime.now()
176
+ self.last_result = {
177
+ "success": True,
178
+ "timestamp": self.last_run.isoformat(),
179
+ }
180
+ forecast_log.info("βœ… Forecast terjadwal selesai.")
181
+ except Exception as e:
182
+ self.last_result = {
183
+ "success": False,
184
+ "error": str(e),
185
+ "timestamp": datetime.now().isoformat(),
186
+ }
187
+ forecast_log.error(f"❌ Forecast terjadwal gagal: {e}")
188
+
189
+ def start_all(self, default_schedules: str = ""):
190
+ if not self.is_running:
191
+ self.scheduler = BackgroundScheduler(timezone=wib_tz)
192
+ self.scheduler.start()
193
+ self.is_running = True
194
+ forecast_log.info("▢️ Mesin Scheduler Forecast telah diaktifkan.")
195
+
196
+ # Re-register existing schedules
197
+ for jid, info in list(self.schedules.items()):
198
+ try:
199
+ trigger = CronTrigger.from_crontab(info["cron"], timezone=wib_tz)
200
+ self.scheduler.add_job(
201
+ self._run_job, trigger, id=jid, replace_existing=True
202
+ )
203
+ except Exception:
204
+ pass
205
+
206
+ # Load defaults if empty
207
+ if not self.schedules and default_schedules:
208
+ for cron in [s.strip() for s in default_schedules.split(",") if s.strip()]:
209
+ self.add_schedule(cron, label="Jadwal Default")
210
+
211
+ def stop_all(self):
212
+ if self.is_running:
213
+ try:
214
+ self.scheduler.shutdown(wait=False)
215
+ except Exception:
216
+ pass
217
+ self.scheduler = BackgroundScheduler(timezone=wib_tz)
218
+ self.is_running = False
219
+ self.schedules.clear()
220
+ forecast_log.info("⏹️ Mesin Scheduler Forecast telah dimatikan.")
221
+
222
+ def add_schedule(self, cron_expression: str, label: str = "") -> str:
223
+ job_id = str(uuid.uuid4())[:8]
224
+ try:
225
+ trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
226
+ self.scheduler.add_job(
227
+ self._run_job, trigger, id=job_id, replace_existing=True
228
+ )
229
+ if not label:
230
+ label = f"Jadwal {cron_expression}"
231
+ self.schedules[job_id] = {"cron": cron_expression, "label": label}
232
+ forecast_log.info(f"πŸ“… Jadwal baru ditambahkan: {cron_expression} ({label})")
233
+
234
+ if not self.is_running:
235
+ self.scheduler.start()
236
+ self.is_running = True
237
+ forecast_log.info("▢️ Scheduler otomatis menyala karena ada jadwal baru.")
238
+
239
+ return job_id
240
+ except Exception as e:
241
+ forecast_log.error(f"❌ Gagal menambahkan jadwal cron '{cron_expression}': {e}")
242
+ raise ValueError(f"Format Cron tidak valid: {e}")
243
+
244
+ def remove_schedule(self, job_id: str):
245
+ if job_id in self.schedules:
246
+ try:
247
+ self.scheduler.remove_job(job_id)
248
+ except Exception:
249
+ pass
250
+ deleted = self.schedules.pop(job_id)
251
+ forecast_log.info(f"πŸ—‘οΈ Jadwal dihapus: {deleted['cron']} ({deleted['label']})")
252
+ else:
253
+ raise KeyError("ID Jadwal tidak ditemukan")
254
+
255
+ def list_schedules(self):
256
+ result = []
257
+ jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
258
+ for jid, info in self.schedules.items():
259
+ job_obj = jobs_map.get(jid)
260
+ next_run = (
261
+ job_obj.next_run_time.isoformat()
262
+ if (job_obj and job_obj.next_run_time)
263
+ else None
264
+ )
265
+ result.append({
266
+ "id": jid,
267
+ "cron_expression": info["cron"],
268
+ "label": info["label"],
269
+ "next_run": next_run,
270
+ })
271
+ return result
272
+
273
+ def get_status(self):
274
+ jobs = self.scheduler.get_jobs() if self.is_running else []
275
+ next_runs = [j.next_run_time for j in jobs if j.next_run_time]
276
+ next_run_overall = min(next_runs).isoformat() if next_runs else None
277
+
278
+ return {
279
+ "is_running": self.is_running,
280
+ "last_run": self.last_run.isoformat() if self.last_run else None,
281
+ "last_result": self.last_result,
282
+ "next_run": next_run_overall,
283
+ "active_schedules_count": len(self.schedules),
284
+ }
285
+
286
+ # Singleton instances
287
+ scraper_scheduler = ScraperScheduler()
288
+ forecast_scheduler = ForecastScheduler()
backend/requirements.txt CHANGED
@@ -10,4 +10,11 @@ apscheduler==3.10.4
10
  pydantic==2.5.3
11
  python-multipart==0.0.6
12
  pydantic-settings==2.1.0
13
- aiofiles==23.2.1
 
 
 
 
 
 
 
 
10
  pydantic==2.5.3
11
  python-multipart==0.0.6
12
  pydantic-settings==2.1.0
13
+ aiofiles==23.2.1
14
+ scikit-learn==1.4.2
15
+ lightgbm==4.3.0
16
+ xgboost==2.0.3
17
+ prophet==1.1.5
18
+ statsmodels==0.14.2
19
+ optuna==3.6.1
20
+ pytz==2024.1
frontend/app/page.tsx CHANGED
The diff for this file is too large to render. See raw diff