Feature/delta team/epic r04 (#7)
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>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+5
View File
@@ -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():
+6
View File
@@ -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.
+1
View File
@@ -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:
+3
View File
@@ -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]
+12
View File
@@ -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:
+10
View File
@@ -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)
+7
View File
@@ -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.
+18
View File
@@ -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]:
+7
View File
@@ -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 ---------------------------------------------------------- #
+3
View File
@@ -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()