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>
219 lines
8.2 KiB
Python
219 lines
8.2 KiB
Python
"""Persistence for model assessments — the routing feature's "loader/writer".
|
|
|
|
Assessments can be large and are rewritten wholesale on every reassess, so they
|
|
live in their OWN file (``~/.cowork_local/assessments.json`` by default) rather
|
|
than bloating the main ``config.json``. Two robustness guarantees the task
|
|
requires:
|
|
|
|
* **Atomic write** — results are written to a temp file in the same directory
|
|
and then ``os.replace``'d over the target, so an interrupted reassess can
|
|
never leave a half-written / corrupt store behind.
|
|
* **Versioned history** — before each overwrite, the previous store is copied
|
|
to ``assessments_history/<timestamp>.json`` so a model that *degrades*
|
|
between runs can be detected after the fact.
|
|
|
|
On-disk shape (JSON, mirrors the task's YAML ``assessments`` block)::
|
|
|
|
{
|
|
"last_updated": "2026-07-22T09:30:00+00:00",
|
|
"policy": "balanced",
|
|
"results": {
|
|
"anthropic/claude-opus-4-8": { <ModelAssessment> },
|
|
...
|
|
}
|
|
}
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Dict, Optional
|
|
|
|
from .models import ModelAssessment, Policy
|
|
|
|
# Default location under the app's config dir. Imported lazily so tests can
|
|
# point the store anywhere without touching the real home directory.
|
|
_DEFAULT_STORE_NAME = "assessments.json"
|
|
_HISTORY_DIR_NAME = "assessments_history"
|
|
|
|
|
|
def utc_now_iso() -> str:
|
|
"""Current UTC time as an ISO-8601 string (used for ``last_updated``)."""
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _fs_safe_stamp() -> str:
|
|
"""A filesystem-safe timestamp for history filenames (no ``:``)."""
|
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
|
|
|
|
class AssessmentStore:
|
|
"""Reads/writes the assessment JSON file with atomic writes + history.
|
|
|
|
Parameters
|
|
----------
|
|
store_path:
|
|
Path to the assessments JSON file. If ``None``, defaults to
|
|
``~/.cowork_local/assessments.json``.
|
|
history_dir:
|
|
Directory for pre-overwrite backups. Defaults to a sibling
|
|
``assessments_history/`` next to ``store_path``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
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
|
|
self.store_path = Path(store_path)
|
|
self.history_dir = Path(
|
|
history_dir or self.store_path.parent / _HISTORY_DIR_NAME
|
|
)
|
|
|
|
# -- read ----------------------------------------------------------- #
|
|
def load_raw(self) -> Dict:
|
|
"""Return the raw JSON dict, or an empty skeleton if absent/corrupt.
|
|
|
|
A corrupt store must never crash the app (same philosophy as
|
|
``AppConfig.load``) — we fall back to an empty result set so the next
|
|
reassess simply rebuilds it.
|
|
"""
|
|
if not self.store_path.exists():
|
|
return {"last_updated": None, "policy": Policy.BALANCED.value, "results": {}}
|
|
try:
|
|
return json.loads(self.store_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return {"last_updated": None, "policy": Policy.BALANCED.value, "results": {}}
|
|
|
|
def load(self) -> Dict[str, ModelAssessment]:
|
|
"""Return ``{candidate_key: ModelAssessment}`` parsed from disk.
|
|
|
|
Individual malformed entries are skipped rather than failing the whole
|
|
load — one bad row shouldn't hide every good assessment.
|
|
"""
|
|
raw = self.load_raw()
|
|
out: Dict[str, ModelAssessment] = {}
|
|
for key, payload in (raw.get("results") or {}).items():
|
|
try:
|
|
out[key] = ModelAssessment.model_validate(payload)
|
|
except Exception: # noqa: BLE001 — skip a single corrupt entry
|
|
continue
|
|
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 ---------------------------------------------------------- #
|
|
def save(
|
|
self,
|
|
results: Dict[str, ModelAssessment],
|
|
policy: Policy | str = Policy.BALANCED,
|
|
*,
|
|
last_updated: Optional[str] = None,
|
|
backup: bool = True,
|
|
) -> Path:
|
|
"""Atomically write ``results`` to the store, backing up the previous
|
|
version to history first.
|
|
|
|
Returns the store path. Never leaves a partially-written file: the
|
|
payload is fully serialized to a temp file and only then swapped into
|
|
place with ``os.replace`` (atomic on the same filesystem, incl. NTFS).
|
|
"""
|
|
if backup:
|
|
self._backup_existing()
|
|
|
|
policy_val = policy.value if isinstance(policy, Policy) else str(policy)
|
|
payload = {
|
|
"last_updated": last_updated or utc_now_iso(),
|
|
"policy": policy_val,
|
|
"results": {
|
|
key: assessment.model_dump(mode="json")
|
|
for key, assessment in results.items()
|
|
},
|
|
}
|
|
|
|
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
text = json.dumps(payload, indent=2, ensure_ascii=False)
|
|
|
|
# Temp file MUST be on the same volume as the target for os.replace to
|
|
# be atomic — so create it in the target's own directory.
|
|
fd, tmp_name = tempfile.mkstemp(
|
|
dir=str(self.store_path.parent),
|
|
prefix=".assessments-",
|
|
suffix=".tmp",
|
|
)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
fh.flush()
|
|
os.fsync(fh.fileno()) # durability: survive a crash right after
|
|
os.replace(tmp_name, self.store_path) # atomic swap
|
|
except BaseException:
|
|
# Clean up the temp file on any failure so we never leave litter.
|
|
try:
|
|
os.unlink(tmp_name)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
return self.store_path
|
|
|
|
def _backup_existing(self) -> Optional[Path]:
|
|
"""Copy the current store into history as ``<timestamp>.json``.
|
|
|
|
No-op when there is nothing to back up. Best-effort: a failed backup
|
|
must not block the (more important) new write.
|
|
"""
|
|
if not self.store_path.exists():
|
|
return None
|
|
try:
|
|
self.history_dir.mkdir(parents=True, exist_ok=True)
|
|
# The wall clock can be coarse (Windows ~15ms), so two rapid
|
|
# backups may share a timestamp — disambiguate with a counter so a
|
|
# snapshot is never silently overwritten.
|
|
stamp = _fs_safe_stamp()
|
|
dst = self.history_dir / f"{stamp}.json"
|
|
n = 1
|
|
while dst.exists():
|
|
dst = self.history_dir / f"{stamp}-{n}.json"
|
|
n += 1
|
|
dst.write_text(
|
|
self.store_path.read_text(encoding="utf-8"), encoding="utf-8"
|
|
)
|
|
return dst
|
|
except OSError:
|
|
return None
|
|
|
|
def history_files(self) -> list[Path]:
|
|
"""All history snapshots, oldest first (for degrade detection / UI)."""
|
|
if not self.history_dir.exists():
|
|
return []
|
|
return sorted(self.history_dir.glob("*.json"))
|
|
|
|
def prune_history(self, keep: int = 30) -> None:
|
|
"""Keep only the newest ``keep`` history snapshots; delete the rest."""
|
|
files = self.history_files()
|
|
for old in files[:-keep] if keep > 0 else files:
|
|
try:
|
|
old.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
__all__ = ["AssessmentStore", "utc_now_iso"]
|