## 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>
This commit was merged in pull request #7.
This commit is contained in:
@@ -73,6 +73,11 @@ LLMClassifier = Callable[[str], str]
|
||||
|
||||
|
||||
def _heuristic_scores(text: str) -> dict[TaskType, int]:
|
||||
"""Chấm điểm loại việc bằng từ khoá, không cần gọi model.
|
||||
|
||||
Bước lọc rẻ đứng trước bộ phân loại bằng AI: phần lớn câu hỏi phân loại được
|
||||
ngay tại đây mà không tốn lượt gọi nào.
|
||||
"""
|
||||
low = (text or "").lower()
|
||||
scores: dict[TaskType, int] = {tt: 0 for tt in TaskType}
|
||||
for tt, entries in _COMPILED.items():
|
||||
|
||||
@@ -25,6 +25,7 @@ class CompletionResult:
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Lượt dò có thành công không (không có lỗi)."""
|
||||
return self.error is None
|
||||
|
||||
|
||||
@@ -37,6 +38,7 @@ class ProbeClient(Protocol):
|
||||
model_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> CompletionResult:
|
||||
"""Gọi một model và trả về kết quả kèm số token, độ trễ và lỗi (nếu có)."""
|
||||
...
|
||||
|
||||
|
||||
@@ -59,6 +61,7 @@ class AppProbeClient:
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
"""Giữ ``AppContext`` để dựng provider lúc cần thăm dò."""
|
||||
self.ctx = ctx
|
||||
|
||||
def complete(
|
||||
@@ -67,6 +70,9 @@ class AppProbeClient:
|
||||
model_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> CompletionResult:
|
||||
"""Gọi model qua provider thật; lỗi được gói vào kết quả chứ không ném ra —
|
||||
một model hỏng không được làm dừng cả lượt chấm điểm danh mục.
|
||||
"""
|
||||
try:
|
||||
prov = self.ctx.build_provider_for(provider, model_id or None)
|
||||
# Non-streaming: no on_text/on_reasoning callbacks. cancel=None.
|
||||
|
||||
@@ -138,6 +138,7 @@ class ModelAssessment(BaseModel):
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""Khoá định danh của model được chấm điểm (provider + model id)."""
|
||||
return self.metadata.key
|
||||
|
||||
def fit_for(self, task_type: TaskType) -> float:
|
||||
|
||||
@@ -126,6 +126,9 @@ def check_and_update(
|
||||
call_count = {"n": 0}
|
||||
|
||||
def _tick() -> None:
|
||||
"""Một nhịp đếm trong lúc chờ người dùng xác nhận đổi model — đếm lùi và tự
|
||||
quyết định khi hết giờ.
|
||||
"""
|
||||
call_count["n"] += 1
|
||||
|
||||
pairs = [(p, m) for (p, m, _tier) in candidates]
|
||||
|
||||
@@ -70,6 +70,7 @@ _SCORE_RE = re.compile(r'"score"\s*:\s*([0-9]*\.?[0-9]+)')
|
||||
|
||||
|
||||
def _clamp01(x: float) -> float:
|
||||
"""Chặn một số về khoảng 0..1."""
|
||||
return min(1.0, max(0.0, float(x)))
|
||||
|
||||
|
||||
@@ -108,6 +109,10 @@ def make_judge(
|
||||
"""
|
||||
|
||||
def judge(task_type: TaskType, prompt: str, answer: str) -> float:
|
||||
"""Chấm điểm câu trả lời của một model theo rubric, trả về điểm 0..1.
|
||||
|
||||
Cắt câu trả lời ở 4000 ký tự để một lượt chấm không tự nó tràn ngữ cảnh.
|
||||
"""
|
||||
rubric = _JUDGE_RUBRIC.format(
|
||||
task=task_type.value, prompt=prompt, answer=(answer or "")[:4000]
|
||||
)
|
||||
@@ -160,11 +165,17 @@ class _PerProviderSemaphores:
|
||||
"""Lazily-created, per-provider bounded semaphores for rate-limit safety."""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
"""Giới hạn số lượt thăm dò song song TRÊN MỖI provider.
|
||||
|
||||
Đếm riêng từng provider chứ không đếm chung: một provider chậm không được
|
||||
phép chiếm hết suất của những provider còn lại.
|
||||
"""
|
||||
self._limit = max(1, int(limit))
|
||||
self._sems: Dict[str, threading.Semaphore] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, provider: str) -> threading.Semaphore:
|
||||
"""Semaphore của một provider, tạo lười ở lần dùng đầu."""
|
||||
with self._lock:
|
||||
sem = self._sems.get(provider)
|
||||
if sem is None:
|
||||
@@ -201,6 +212,7 @@ def probe_candidates(
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def _one(provider: str, model_id: str, task_type: TaskType) -> None:
|
||||
"""Dò một cặp (provider, model) cho một loại việc, tôn trọng giới hạn song song."""
|
||||
sem = sems.get(provider)
|
||||
with sem:
|
||||
if call_counter is not None:
|
||||
|
||||
@@ -33,6 +33,7 @@ class RoutingScheduler(QObject):
|
||||
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
|
||||
@@ -49,16 +50,19 @@ class RoutingScheduler(QObject):
|
||||
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
|
||||
@@ -87,6 +91,11 @@ class RoutingScheduler(QObject):
|
||||
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()
|
||||
@@ -111,6 +120,7 @@ class RoutingScheduler(QObject):
|
||||
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)
|
||||
|
||||
@@ -27,6 +27,7 @@ class RankedCandidate:
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""Khoá định danh của ứng viên (provider + model)."""
|
||||
return self.assessment.key
|
||||
|
||||
|
||||
@@ -40,6 +41,7 @@ class Ranking:
|
||||
|
||||
@property
|
||||
def best(self) -> Optional[RankedCandidate]:
|
||||
"""Ứng viên đứng đầu; ``None`` nếu không có ứng viên nào."""
|
||||
return self.ranked[0] if self.ranked else None
|
||||
|
||||
def score_of(self, key: str) -> float:
|
||||
@@ -66,6 +68,7 @@ class Ranking:
|
||||
|
||||
|
||||
def _has_capabilities(assessment: ModelAssessment, required: Set[str]) -> bool:
|
||||
"""Model này có đủ mọi năng lực mà lượt chạy đòi hỏi không."""
|
||||
return required.issubset(assessment.metadata.capabilities)
|
||||
|
||||
|
||||
@@ -99,6 +102,10 @@ def rank_models(
|
||||
scored.append(RankedCandidate(assessment=a, score=score))
|
||||
|
||||
def _sort_key(c: RankedCandidate):
|
||||
"""Khoá sắp xếp ứng viên: điểm cao trước, cùng điểm thì rẻ hơn trước.
|
||||
|
||||
Model chưa biết giá bị xếp cuối (coi như vô cùng đắt) chứ không phải miễn phí.
|
||||
"""
|
||||
cost = c.assessment.metadata.avg_cost_per_1k
|
||||
cost = cost if cost is not None else float("inf")
|
||||
# score desc, then cheaper, then model id for determinism.
|
||||
|
||||
@@ -64,6 +64,7 @@ class RouteResult:
|
||||
|
||||
@property
|
||||
def should_switch(self) -> bool:
|
||||
"""Có nên đổi sang model khác cho lượt này không."""
|
||||
return self.decision.should_switch
|
||||
|
||||
@property
|
||||
@@ -89,6 +90,9 @@ class RoutingService:
|
||||
client: Optional[ProbeClient] = None,
|
||||
clock: Optional[Callable[[], float]] = None,
|
||||
) -> None:
|
||||
"""``store``/``client``/``clock`` đều tiêm được: test thay đồng hồ để tua thời
|
||||
gian mà không phải chờ thật, và thay client để không gọi mạng.
|
||||
"""
|
||||
self.ctx = ctx
|
||||
self.store = store or AssessmentStore()
|
||||
self._client = client # None → lazily build AppProbeClient(ctx)
|
||||
@@ -100,6 +104,7 @@ class RoutingService:
|
||||
# -- config helpers ------------------------------------------------- #
|
||||
@property
|
||||
def _routing_cfg(self) -> Dict[str, Any]:
|
||||
"""Nhóm cấu hình định tuyến hiện tại."""
|
||||
return self.ctx.config.routing
|
||||
|
||||
def get_routing_config(self) -> Dict[str, Any]:
|
||||
@@ -125,6 +130,7 @@ class RoutingService:
|
||||
return dict(cfg)
|
||||
|
||||
def _policy(self) -> Policy:
|
||||
"""Chính sách chấm điểm đang chọn; giá trị lạ thì rơi về 'balanced'."""
|
||||
raw = (self._routing_cfg.get("policy") or "balanced").lower()
|
||||
try:
|
||||
return Policy(raw)
|
||||
@@ -132,6 +138,7 @@ class RoutingService:
|
||||
return Policy.BALANCED
|
||||
|
||||
def _client_or_build(self) -> ProbeClient:
|
||||
"""Client dò model, dựng lười để chưa bật định tuyến thì không tốn gì."""
|
||||
if self._client is None:
|
||||
self._client = AppProbeClient(self.ctx)
|
||||
return self._client
|
||||
@@ -160,6 +167,9 @@ class RoutingService:
|
||||
seen = set()
|
||||
|
||||
def _add(provider: str, model_id: str, tier: Optional[str]) -> None:
|
||||
"""Thêm một ứng viên (provider, model) vào danh sách, bỏ qua mục thiếu thông tin
|
||||
hoặc trùng.
|
||||
"""
|
||||
if not provider or not model_id:
|
||||
return
|
||||
key = candidate_key(provider, model_id)
|
||||
@@ -246,6 +256,11 @@ class RoutingService:
|
||||
) -> threading.Thread:
|
||||
"""Run :meth:`reassess` on a daemon thread (non-Qt, headless-safe)."""
|
||||
def _run() -> None:
|
||||
"""Chạy nền: chấm điểm lại danh mục model.
|
||||
|
||||
Nuốt mọi ngoại lệ có chủ ý — một lần chấm điểm hỏng không được phép làm
|
||||
chết ứng dụng, vì đây là việc chạy ngầm người dùng không yêu cầu.
|
||||
"""
|
||||
try:
|
||||
result = self.reassess(policy)
|
||||
except Exception: # noqa: BLE001 — never let a reassess crash the app
|
||||
@@ -262,10 +277,12 @@ class RoutingService:
|
||||
return t
|
||||
|
||||
def is_reassessing(self) -> bool:
|
||||
"""Có đang chấm điểm lại danh mục model hay không."""
|
||||
return self._reassessing
|
||||
|
||||
# -- query ---------------------------------------------------------- #
|
||||
def assessments(self) -> Dict[str, ModelAssessment]:
|
||||
"""Bảng điểm model đã lưu, đọc từ kho đánh giá."""
|
||||
return self.store.load()
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
@@ -347,6 +364,7 @@ class RoutingService:
|
||||
return self.pending.resolve(request_id, approve, run)
|
||||
|
||||
def get_pending(self, request_id: str) -> Optional[PendingSwitch]:
|
||||
"""Đề nghị đổi model đang chờ người dùng xác nhận; ``None`` nếu không có."""
|
||||
return self.pending.get(request_id)
|
||||
|
||||
def sweep_pending(self) -> List[str]:
|
||||
|
||||
@@ -68,6 +68,11 @@ class AssessmentStore:
|
||||
store_path: Optional[Path] = None,
|
||||
history_dir: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""``store_path`` để None thì dùng file mặc định trong thư mục cấu hình.
|
||||
|
||||
Import ``CONFIG_DIR`` muộn ngay trong thân hàm: nạp nó lúc import module sẽ
|
||||
kéo theo cả cây cấu hình vào mọi test dùng lớp này.
|
||||
"""
|
||||
if store_path is None:
|
||||
from ...config import CONFIG_DIR # lazy: avoids import cost in tests
|
||||
store_path = CONFIG_DIR / _DEFAULT_STORE_NAME
|
||||
@@ -107,9 +112,11 @@ class AssessmentStore:
|
||||
return out
|
||||
|
||||
def last_updated(self) -> Optional[str]:
|
||||
"""Mốc thời gian lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
|
||||
return self.load_raw().get("last_updated")
|
||||
|
||||
def policy(self) -> str:
|
||||
"""Chính sách chấm điểm đã lưu; chưa có thì mặc định 'balanced'."""
|
||||
return self.load_raw().get("policy") or Policy.BALANCED.value
|
||||
|
||||
# -- write ---------------------------------------------------------- #
|
||||
|
||||
@@ -141,6 +141,7 @@ class PendingSwitchRegistry:
|
||||
_RESOLVE_WAIT_SEC = 600.0
|
||||
|
||||
def __init__(self, clock: Callable[[], float] = time.time) -> None:
|
||||
"""``clock`` tiêm được để test kiểm hết hạn mà không phải chờ thật."""
|
||||
self._items: Dict[str, PendingSwitch] = {}
|
||||
self._events: Dict[str, threading.Event] = {}
|
||||
self._running: Set[str] = set()
|
||||
@@ -180,6 +181,7 @@ class PendingSwitchRegistry:
|
||||
return ps
|
||||
|
||||
def _maybe_expire_locked(self, ps: PendingSwitch) -> None:
|
||||
"""Đánh dấu hết hạn nếu đã quá hạn chờ. Gọi trong lúc đang giữ khoá."""
|
||||
if ps.status == SwitchStatus.PENDING and self._clock() >= ps.expires_at:
|
||||
ps.status = SwitchStatus.EXPIRED
|
||||
|
||||
@@ -270,6 +272,7 @@ class PendingSwitchRegistry:
|
||||
return removed
|
||||
|
||||
def pending_ids(self) -> List[str]:
|
||||
"""Id các đề nghị còn đang chờ (đã loại những cái vừa hết hạn)."""
|
||||
with self._lock:
|
||||
return [
|
||||
rid for rid, ps in self._items.items()
|
||||
|
||||
Reference in New Issue
Block a user