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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user