From 319ac35a260ac64db383c7b7c9b9a0ac998e3127 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 21 Jun 2026 20:00:06 +0200 Subject: [PATCH] feat: weekend-aware scheduler with configurable cycle times - auto_cycle.py: _scheduler_loop now distinguishes weekday (interval_hours) from weekend (weekend_cycle_times UTC slots or sleep until Monday); _parse_weekend_times() and _next_weekend_slot() helpers; get_status() exposes weekend_cycle_enabled + weekend_cycle_times - cycle.py: CycleConfigRequest adds weekend_cycle_enabled + weekend_cycle_times; update_cycle_config validates HH:MM format and persists to config DB - Config.tsx: weekend scheduling section with enable toggle + time picker (06:00/08:00/12:00/18:00/22:00/00:00 UTC presets, multi-select); weekendEnabled + weekendTimes state synced from cycle status Default: enabled with 08:00 + 22:00 UTC (covers news scan + Globex open Sunday) Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/cycle.py | 10 ++++ backend/services/auto_cycle.py | 86 ++++++++++++++++++++++++++++++---- frontend/src/pages/Config.tsx | 60 +++++++++++++++++++++++- 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/backend/routers/cycle.py b/backend/routers/cycle.py index cdcb025..a1a2ff6 100644 --- a/backend/routers/cycle.py +++ b/backend/routers/cycle.py @@ -46,6 +46,8 @@ class CycleConfigRequest(BaseModel): min_score_threshold: Optional[int] = None journal_retention_days: Optional[int] = None maturity_threshold_pct: Optional[int] = None + weekend_cycle_enabled: Optional[bool] = None + weekend_cycle_times: Optional[str] = None # "HH:MM,HH:MM" in UTC @router.post("/config") @@ -77,6 +79,14 @@ def update_cycle_config(req: CycleConfigRequest): if not (5 <= req.maturity_threshold_pct <= 75): raise HTTPException(400, "maturity_threshold_pct must be between 5 and 75") set_config("maturity_threshold_pct", str(req.maturity_threshold_pct)) + if req.weekend_cycle_enabled is not None: + set_config("weekend_cycle_enabled", "true" if req.weekend_cycle_enabled else "false") + if req.weekend_cycle_times is not None: + # Validate format: comma-separated HH:MM values + import re + if not re.match(r'^(\d{2}:\d{2})(,\d{2}:\d{2})*$', req.weekend_cycle_times.strip()): + raise HTTPException(400, "weekend_cycle_times must be 'HH:MM' or 'HH:MM,HH:MM,...'") + set_config("weekend_cycle_times", req.weekend_cycle_times.strip()) # Restart scheduler to pick up changes restart_scheduler() diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 9aa4298..d186572 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -1654,25 +1654,83 @@ def _auto_synthesize_knowledge(ai_key: str) -> None: # ── Scheduler ───────────────────────────────────────────────────────────────── +def _parse_weekend_times(times_str: str): + """Parse 'HH:MM,HH:MM' into list of (hour, minute) tuples.""" + result = [] + for t in times_str.split(","): + t = t.strip() + try: + parts = t.split(":") + result.append((int(parts[0]), int(parts[1]))) + except Exception: + pass + return result or [(8, 0), (22, 0)] + + +def _next_weekend_slot(now_utc: datetime, weekend_times): + """Return (next_dt, wait_secs) for the next weekend cycle time (UTC).""" + from datetime import timedelta + today = now_utc.date() + candidates = [] + for day_offset in range(3): + for h, m in weekend_times: + candidate = datetime(today.year, today.month, today.day, h, m) + timedelta(days=day_offset) + if candidate > now_utc + timedelta(minutes=5): + candidates.append(candidate) + if not candidates: + fallback = now_utc + timedelta(hours=12) + return fallback, 12 * 3600 + next_slot = min(candidates) + return next_slot, max(60, (next_slot - now_utc).total_seconds()) + + def _scheduler_loop(stop_event: threading.Event): - """Background loop that runs the cycle at the configured interval.""" - import time + """Background loop that runs the cycle at the configured interval. + + Weekday: fires every auto_cycle_hours. + Weekend: fires only at weekend_cycle_times (UTC HH:MM list). + If weekend_cycle_enabled=false, sleeps until Monday. + """ from services.database import get_config + from datetime import timedelta while not stop_event.is_set(): try: interval_hours = float(get_config("auto_cycle_hours") or "3") + weekend_enabled = (get_config("weekend_cycle_enabled") or "true").lower() == "true" + weekend_times_str = get_config("weekend_cycle_times") or "08:00,22:00" except Exception: - interval_hours = 3.0 + interval_hours, weekend_enabled, weekend_times_str = 3.0, True, "08:00,22:00" - _current_status["interval_hours"] = interval_hours - from datetime import timedelta - next_run = (datetime.utcnow() + timedelta(hours=interval_hours)).isoformat() - _current_status["next_run_at"] = next_run - logger.info(f"[Scheduler] Next cycle in {interval_hours}h (at {next_run[:16]} UTC)") + weekend_times = _parse_weekend_times(weekend_times_str) + now_utc = datetime.utcnow() + weekday = now_utc.weekday() + is_weekend = weekday >= 5 - # Wait for the interval (or until stop is signalled) - stop_event.wait(timeout=interval_hours * 3600) + if is_weekend and not weekend_enabled: + # Sleep until Monday 00:05 UTC + days_to_monday = 7 - weekday # Sat→2, Sun→1 + next_monday = datetime(now_utc.year, now_utc.month, now_utc.day, 0, 5) + timedelta(days=days_to_monday) + wait_secs = max(60, (next_monday - now_utc).total_seconds()) + _current_status["next_run_at"] = next_monday.isoformat() + _current_status["interval_hours"] = interval_hours + logger.info(f"[Scheduler] Weekend — cycles désactivés. Reprise lundi {next_monday.strftime('%Y-%m-%d %H:%M')} UTC") + stop_event.wait(timeout=wait_secs) + continue # don't run a cycle, loop back + + if is_weekend: + next_slot, wait_secs = _next_weekend_slot(now_utc, weekend_times) + _current_status["next_run_at"] = next_slot.isoformat() + _current_status["interval_hours"] = interval_hours + logger.info(f"[Scheduler] Weekend — prochain cycle à {next_slot.strftime('%H:%M')} UTC ({wait_secs/3600:.1f}h)") + else: + wait_secs = interval_hours * 3600 + next_run = now_utc + timedelta(hours=interval_hours) + _current_status["next_run_at"] = next_run.isoformat() + _current_status["interval_hours"] = interval_hours + logger.info(f"[Scheduler] Prochain cycle dans {interval_hours}h ({next_run.strftime('%H:%M')} UTC)") + + stop_event.wait(timeout=wait_secs) if stop_event.is_set(): break @@ -1778,6 +1836,12 @@ def get_status() -> Dict[str, Any]: except Exception: pass + try: + weekend_enabled = (get_config("weekend_cycle_enabled") or "true").lower() == "true" + weekend_cycle_times = get_config("weekend_cycle_times") or "08:00,22:00" + except Exception: + weekend_enabled, weekend_cycle_times = True, "08:00,22:00" + return { **_current_status, "enabled": enabled, @@ -1787,6 +1851,8 @@ def get_status() -> Dict[str, Any]: "min_score_threshold": min_score, "journal_retention_days": retention_days, "maturity_threshold_pct": maturity_pct, + "weekend_cycle_enabled": weekend_enabled, + "weekend_cycle_times": weekend_cycle_times, "last_cycle": last, "scheduler_alive": bool(_cycle_thread and _cycle_thread.is_alive()), } diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index f28aa45..a2db1a3 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -432,6 +432,8 @@ export default function Config() { const [minScore, setMinScore] = useState(0) const [retentionDays, setRetentionDays] = useState(90) const [maturityThreshold, setMaturityThreshold] = useState(35) + const [weekendEnabled, setWeekendEnabled] = useState(true) + const [weekendTimes, setWeekendTimes] = useState(['08:00', '22:00']) useEffect(() => { if (cs) { setCycleEnabled(cs.enabled ?? false) @@ -441,6 +443,9 @@ export default function Config() { setMinScore(cs.min_score_threshold ?? 0) setRetentionDays(cs.journal_retention_days ?? 90) setMaturityThreshold(cs.maturity_threshold_pct ?? 35) + setWeekendEnabled(cs.weekend_cycle_enabled ?? true) + const times = (cs.weekend_cycle_times || '08:00,22:00').split(',').map((t: string) => t.trim()).filter(Boolean) + setWeekendTimes(times) } }, [cs]) @@ -823,6 +828,49 @@ export default function Config() { + {/* Weekend scheduling */} +
+
+
+ Cycles weekend +

Sam/Dim — Globex ouvre dim ~18h UTC. Heures en UTC.

+
+ +
+ {weekendEnabled && ( +
+ +
+ {['06:00', '08:00', '12:00', '18:00', '22:00', '00:00'].map(t => { + const active = weekendTimes.includes(t) + return ( + + ) + })} +
+ {weekendTimes.length === 0 && ( +

Sélectionner au moins un horaire

+ )} +
+ )} +
+ {cs?.last_cycle && (
@@ -847,7 +895,17 @@ export default function Config() {