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()),
|
||||
}
|
||||
|
||||
@@ -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<string[]>(['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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekend scheduling */}
|
||||
<div className="border border-slate-700/50 rounded-lg p-3 mb-4 bg-dark-700/30">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-slate-300">Cycles weekend</span>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">Sam/Dim — Globex ouvre dim ~18h UTC. Heures en UTC.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setWeekendEnabled(!weekendEnabled)}
|
||||
className={clsx('px-3 py-1.5 rounded border text-xs font-semibold transition-all', {
|
||||
'bg-amber-600/20 border-amber-500/50 text-amber-300': weekendEnabled,
|
||||
'bg-dark-700 border-slate-700 text-slate-500 hover:border-slate-500': !weekendEnabled,
|
||||
})}>
|
||||
{weekendEnabled ? '✓ Activé' : '○ Désactivé'}
|
||||
</button>
|
||||
</div>
|
||||
{weekendEnabled && (
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-500 mb-1.5 block">Horaires UTC (cliquer pour activer/désactiver)</label>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{['06:00', '08:00', '12:00', '18:00', '22:00', '00:00'].map(t => {
|
||||
const active = weekendTimes.includes(t)
|
||||
return (
|
||||
<button key={t}
|
||||
onClick={() => setWeekendTimes(prev =>
|
||||
active ? prev.filter(x => x !== t) : [...prev, t].sort()
|
||||
)}
|
||||
className={clsx('px-2.5 py-1 rounded text-xs font-mono transition-colors', {
|
||||
'bg-amber-600 text-white': active,
|
||||
'bg-dark-700 text-slate-500 hover:text-slate-300 border border-slate-700': !active,
|
||||
})}>
|
||||
{t}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{weekendTimes.length === 0 && (
|
||||
<p className="text-[10px] text-red-400 mt-1">Sélectionner au moins un horaire</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cs?.last_cycle && (
|
||||
<div className="card bg-dark-700/50 mb-2 text-xs space-y-2">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
@@ -847,7 +895,17 @@ export default function Config() {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => updateCycleConfig(
|
||||
{ enabled: cycleEnabled, interval_hours: cycleHours, similarity_threshold: cycleSimilarity, min_ev_threshold: minEv, min_score_threshold: minScore, journal_retention_days: retentionDays, maturity_threshold_pct: maturityThreshold },
|
||||
{
|
||||
enabled: cycleEnabled,
|
||||
interval_hours: cycleHours,
|
||||
similarity_threshold: cycleSimilarity,
|
||||
min_ev_threshold: minEv,
|
||||
min_score_threshold: minScore,
|
||||
journal_retention_days: retentionDays,
|
||||
maturity_threshold_pct: maturityThreshold,
|
||||
weekend_cycle_enabled: weekendEnabled,
|
||||
weekend_cycle_times: weekendTimes.length > 0 ? weekendTimes.join(',') : '08:00,22:00',
|
||||
},
|
||||
{ onSuccess: () => { refetchCycle(); setSavedMsg('Auto-cycle configuré'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||||
)}
|
||||
disabled={savingCycle}
|
||||
|
||||
Reference in New Issue
Block a user