Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
7.4 KiB
Python
216 lines
7.4 KiB
Python
"""Pydantic v2 data models for Auto Model Assessment & Routing.
|
|
|
|
These are the provider-agnostic shapes shared by every routing module — the
|
|
enricher, prober, scorer, selector and switch-controller all speak in terms of
|
|
these. They serialize cleanly to/from JSON so the assessment store and the
|
|
app config (``~/.cowork_local/…``) can round-trip them.
|
|
|
|
Terminology
|
|
-----------
|
|
* A **candidate** is a ``(provider, model_id)`` pair the app can call.
|
|
* An **assessment** is what we learned about one candidate: its static
|
|
metadata, the dynamic probe results per task type, and the derived
|
|
``fit_scores`` per task type.
|
|
* A **task type** is the kind of work a message represents (qa / coding / …).
|
|
* A **policy** is how we weigh quality vs cost vs latency when scoring.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Enums
|
|
# --------------------------------------------------------------------------- #
|
|
class TaskType(str, Enum):
|
|
"""The kinds of work a chat/agent turn can represent.
|
|
|
|
A message is classified into exactly one of these before routing (see
|
|
``classifier.py``). ``BENCHMARK_TASKS`` in the prober has one fixed prompt
|
|
per value so every candidate model is compared on the same yardstick.
|
|
"""
|
|
|
|
QA = "qa"
|
|
CODING = "coding"
|
|
REASONING = "reasoning"
|
|
SUMMARIZATION = "summarization"
|
|
CREATIVE = "creative"
|
|
|
|
|
|
class Policy(str, Enum):
|
|
"""How to trade off quality, cost and latency when scoring a model."""
|
|
|
|
QUALITY = "quality"
|
|
COST = "cost"
|
|
LATENCY = "latency"
|
|
BALANCED = "balanced"
|
|
|
|
|
|
class SwitchMode(str, Enum):
|
|
"""Per-surface routing behaviour, driven by the Off/Auto/Manual toggle.
|
|
|
|
* ``OFF`` — routing disabled; always use the manually-selected model.
|
|
* ``AUTO`` — silently switch to the best model when it clears the gain
|
|
threshold, then run the task.
|
|
* ``MANUAL`` — propose the switch and wait for the user to confirm before
|
|
running with the new model.
|
|
"""
|
|
|
|
OFF = "off"
|
|
AUTO = "auto"
|
|
MANUAL = "manual"
|
|
|
|
|
|
class SwitchStatus(str, Enum):
|
|
"""Lifecycle of a :class:`PendingSwitch` awaiting user confirmation."""
|
|
|
|
PENDING = "pending"
|
|
CONFIRMED = "confirmed"
|
|
REJECTED = "rejected"
|
|
EXPIRED = "expired"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Static metadata + dynamic probe
|
|
# --------------------------------------------------------------------------- #
|
|
class ModelMetadata(BaseModel):
|
|
"""Static, mostly-price/capability facts about one candidate model.
|
|
|
|
``cost_per_1k_*`` are USD per 1,000 tokens. They are ``None`` — not a
|
|
guess — when the price is genuinely unknown; ``metadata_incomplete`` is
|
|
then set True so the scorer/UI can flag it rather than silently trusting a
|
|
fabricated number (see ``metadata.py``).
|
|
"""
|
|
|
|
provider: str
|
|
model_id: str
|
|
tier: Optional[str] = None # e.g. "fast" | "powerful" — free-form, user-supplied
|
|
cost_per_1k_input: Optional[float] = None
|
|
cost_per_1k_output: Optional[float] = None
|
|
max_context: Optional[int] = None
|
|
capabilities: Set[str] = Field(default_factory=set) # e.g. {"vision", "tools"}
|
|
available: bool = True
|
|
metadata_incomplete: bool = False
|
|
|
|
@property
|
|
def key(self) -> str:
|
|
"""Stable ``provider/model_id`` identity used as a dict key everywhere."""
|
|
return candidate_key(self.provider, self.model_id)
|
|
|
|
@property
|
|
def avg_cost_per_1k(self) -> Optional[float]:
|
|
"""Blended input/output price, or None if either side is unknown.
|
|
|
|
A rough 1:3 input:output ratio (typical chat workload) is used so a
|
|
single scalar can feed the cost term of the fit score.
|
|
"""
|
|
ci, co = self.cost_per_1k_input, self.cost_per_1k_output
|
|
if ci is None or co is None:
|
|
return None
|
|
return (ci + 3.0 * co) / 4.0
|
|
|
|
|
|
class ProbeResult(BaseModel):
|
|
"""Outcome of running one benchmark task against one model.
|
|
|
|
``success=False`` means the call itself failed (network/auth/model error);
|
|
``error`` then holds a human-readable reason and ``quality_score`` stays 0.
|
|
"""
|
|
|
|
latency_ms: float = 0.0
|
|
success: bool = False
|
|
quality_score: float = 0.0 # 0..1, from the judge model
|
|
tokens_out: int = 0
|
|
error: Optional[str] = None
|
|
|
|
|
|
class ModelAssessment(BaseModel):
|
|
"""Everything we know about one candidate after an assessment run."""
|
|
|
|
metadata: ModelMetadata
|
|
# Keyed by TaskType.value (JSON-friendly string keys).
|
|
probes: Dict[str, ProbeResult] = Field(default_factory=dict)
|
|
fit_scores: Dict[str, float] = Field(default_factory=dict)
|
|
assessed_at: Optional[str] = None # ISO-8601 UTC timestamp
|
|
|
|
@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:
|
|
"""Fit score for ``task_type`` (0.0 if this model was never scored for it)."""
|
|
return float(self.fit_scores.get(task_type.value, 0.0))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Switch decision + pending confirmation
|
|
# --------------------------------------------------------------------------- #
|
|
class SwitchDecision(BaseModel):
|
|
"""The verdict of comparing the current model against the selector's best.
|
|
|
|
``should_switch`` is False when routing is Off, when the best candidate IS
|
|
the current model, or when the score gain is below ``min_score_gain``.
|
|
"""
|
|
|
|
should_switch: bool
|
|
from_model: Optional[str] = None # candidate key, or None if nothing active yet
|
|
to_model: Optional[str] = None
|
|
from_score: float = 0.0
|
|
to_score: float = 0.0
|
|
score_gain: float = 0.0
|
|
reason: str = ""
|
|
mode: SwitchMode = SwitchMode.OFF
|
|
task_type: Optional[str] = None
|
|
|
|
|
|
class PendingSwitch(BaseModel):
|
|
"""A Manual-mode switch proposal held until the user confirms/rejects.
|
|
|
|
Stored in-memory with a TTL; ``result`` caches the executed task output so
|
|
a repeated confirm of the same ``request_id`` is idempotent (returns the
|
|
cached result instead of running the task twice).
|
|
"""
|
|
|
|
request_id: str
|
|
task_payload: Dict[str, Any] = Field(default_factory=dict)
|
|
decision: SwitchDecision
|
|
created_at: float # epoch seconds (monotonic wall clock at creation)
|
|
expires_at: float
|
|
status: SwitchStatus = SwitchStatus.PENDING
|
|
result: Optional[Dict[str, Any]] = None # cached task result once executed
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
def candidate_key(provider: str, model_id: str) -> str:
|
|
"""The canonical ``provider/model_id`` string used as a dict key."""
|
|
return f"{provider}/{model_id}"
|
|
|
|
|
|
def split_key(key: str) -> tuple[str, str]:
|
|
"""Inverse of :func:`candidate_key`. Splits on the first ``/`` only, so a
|
|
model id that itself contains ``/`` (some gateways use ``org/model``) is
|
|
preserved intact."""
|
|
provider, _, model_id = key.partition("/")
|
|
return provider, model_id
|
|
|
|
|
|
__all__ = [
|
|
"TaskType",
|
|
"Policy",
|
|
"SwitchMode",
|
|
"SwitchStatus",
|
|
"ModelMetadata",
|
|
"ProbeResult",
|
|
"ModelAssessment",
|
|
"SwitchDecision",
|
|
"PendingSwitch",
|
|
"candidate_key",
|
|
"split_key",
|
|
]
|