CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
137 lines
5.6 KiB
Python
137 lines
5.6 KiB
Python
"""Periodic reassessment scheduler (Qt layer).
|
|
|
|
No APScheduler dependency — this mirrors the app's existing ``TaskScheduler``:
|
|
a lightweight ``QTimer`` ticks periodically and, when the configured interval
|
|
has elapsed since the last assessment, launches a background reassess on a
|
|
daemon thread (so the UI never blocks). It also expires stale Manual-mode
|
|
pending switches on each tick.
|
|
|
|
Reassessment is expensive (it spends real tokens), so the cadence is
|
|
deliberately coarse — default every 24h, configurable via
|
|
``routing.reassess_interval_hours`` (0 disables the periodic run entirely).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional
|
|
|
|
from PySide6.QtCore import QObject, QTimer, Signal
|
|
|
|
logger = logging.getLogger("cowork_local.routing")
|
|
|
|
# How often the timer wakes to CHECK whether a reassess is due. The actual
|
|
# reassess cadence is governed by reassess_interval_hours; this is just the
|
|
# polling granularity (cheap — it only reads a timestamp).
|
|
_TICK_MS = 30 * 60 * 1000 # 30 minutes
|
|
|
|
|
|
class RoutingScheduler(QObject):
|
|
"""Drives periodic reassessment + pending-switch expiry for a service."""
|
|
|
|
reassess_started = Signal()
|
|
reassess_finished = Signal(int) # number of models assessed
|
|
|
|
def __init__(self, ctx: Any, service: Any, parent: Optional[QObject] = None) -> None:
|
|
"""Dựng bộ hẹn giờ chạy thăm dò định kỳ. Chưa chạy cho tới khi gọi ``start()``."""
|
|
super().__init__(parent)
|
|
self.ctx = ctx
|
|
self.service = service
|
|
self._timer = QTimer(self)
|
|
self._timer.setInterval(_TICK_MS)
|
|
self._timer.timeout.connect(self.tick)
|
|
|
|
# -- lifecycle ------------------------------------------------------ #
|
|
def start(self) -> None:
|
|
"""Begin periodic checks. Does NOT force an immediate reassess — the
|
|
first one happens when the interval is genuinely due (or never, if the
|
|
store is fresh), to avoid a burst of API calls at every app launch."""
|
|
self.tick()
|
|
self._timer.start()
|
|
|
|
def stop(self) -> None:
|
|
"""Dừng hẹn giờ."""
|
|
self._timer.stop()
|
|
|
|
# -- tick ----------------------------------------------------------- #
|
|
def _interval_hours(self) -> float:
|
|
"""Chu kỳ chấm điểm lại, tính bằng giờ; giá trị lạ thì coi như tắt."""
|
|
try:
|
|
return float(self.ctx.config.routing.get("reassess_interval_hours", 24) or 0)
|
|
except Exception: # noqa: BLE001
|
|
return 24.0
|
|
|
|
def _hours_since_last(self) -> Optional[float]:
|
|
"""Số giờ kể từ lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
|
|
last = self.service.store.last_updated()
|
|
if not last:
|
|
return None # never assessed
|
|
try:
|
|
dt = datetime.fromisoformat(last)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return (datetime.now(timezone.utc) - dt).total_seconds() / 3600.0
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
def _routing_enabled_anywhere(self) -> bool:
|
|
"""Is routing actually in use? True if the global mode is auto/manual OR
|
|
any chat surface overrides to auto/manual. When everything is Off, the
|
|
assessment scores would never be consulted — so we don't spend tokens
|
|
probing for them (no surprise cost on a fresh install)."""
|
|
try:
|
|
routing = self.ctx.config.routing
|
|
if (routing.get("switch_mode") or "off") in ("auto", "manual"):
|
|
return True
|
|
for m in (routing.get("surface_modes") or {}).values():
|
|
if m in ("auto", "manual"):
|
|
return True
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return False
|
|
|
|
def is_due(self) -> bool:
|
|
"""Đã đến lúc chấm điểm lại chưa.
|
|
|
|
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
|
|
không được tiêu tiền cho một tính năng người dùng đã tắt.
|
|
"""
|
|
if not self._routing_enabled_anywhere():
|
|
return False # routing off everywhere → don't probe (would be wasted cost)
|
|
interval = self._interval_hours()
|
|
if interval <= 0:
|
|
return False # periodic reassess disabled
|
|
since = self._hours_since_last()
|
|
if since is None:
|
|
return True # never assessed → due once routing is actually enabled
|
|
return since >= interval
|
|
|
|
def tick(self) -> None:
|
|
"""Expire stale pending switches; reassess if the interval is due."""
|
|
try:
|
|
self.service.sweep_pending()
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("routing.scheduler: sweep_pending failed")
|
|
|
|
if not self.is_due() or self.service.is_reassessing():
|
|
return
|
|
|
|
logger.info("routing.scheduler: reassess is due — starting background run")
|
|
self.reassess_started.emit()
|
|
|
|
def _done(result) -> None:
|
|
"""Chấm điểm xong: báo ra ngoài số model đã đánh giá."""
|
|
self.reassess_finished.emit(len(result or {}))
|
|
|
|
self.service.reassess_background(on_done=_done)
|
|
|
|
def trigger_now(self) -> None:
|
|
"""Force an out-of-band reassess (e.g. Settings' 'Reassess now' button)."""
|
|
if self.service.is_reassessing():
|
|
return
|
|
self.reassess_started.emit()
|
|
self.service.reassess_background(on_done=lambda r: self.reassess_finished.emit(len(r or {})))
|
|
|
|
|
|
__all__ = ["RoutingScheduler"]
|