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:
OpenSquared
2026-06-21 20:00:06 +02:00
parent d4bc4e6624
commit 319ac35a26
3 changed files with 145 additions and 11 deletions

View File

@@ -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()

View File

@@ -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()),
}