Feature/delta team/epic r04 #7
@@ -22,6 +22,7 @@ from typing import Any, Dict
|
||||
|
||||
from ..persistence.json.atomic_json_file import AtomicJsonFile
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
from .schema_migration import CURRENT_VERSION, migrate
|
||||
|
||||
|
||||
class JsonConfigRepository:
|
||||
@@ -51,7 +52,14 @@ class JsonConfigRepository:
|
||||
merged = copy.deepcopy(self._defaults)
|
||||
stored = self._file.read(default=None)
|
||||
if isinstance(stored, dict):
|
||||
# Nâng cấp TRƯỚC khi trộn với mặc định: bước v1→v2 gỡ api_key khỏi
|
||||
# đĩa, mà mặc định thì không có khoá nào để gỡ.
|
||||
stored, changed = migrate(stored, secrets=self._secrets,
|
||||
path=self._file.path)
|
||||
merged = _deep_merge(merged, stored)
|
||||
if changed:
|
||||
self.data = merged
|
||||
self.save() # ghi ngay, để lần sau khỏi chuyển lại
|
||||
merged = self._env_overrides(merged)
|
||||
# Trạng thái mở khoá ms365 chỉ tồn tại lúc chạy — mỗi lần mở app đều
|
||||
# bắt đầu ở trạng thái khoá, không tin giá trị đọc từ đĩa.
|
||||
@@ -170,6 +178,7 @@ class JsonConfigRepository:
|
||||
to_write = copy.deepcopy(self.data)
|
||||
to_write["ms365"]["unlocked"] = False
|
||||
to_write.pop("_project_history_dir", None)
|
||||
to_write["schema_version"] = CURRENT_VERSION
|
||||
self._file.write(to_write)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
|
||||
|
||||
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
|
||||
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
|
||||
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
|
||||
khoá ``office`` nghĩa là file cũ".
|
||||
|
||||
Ở đây đặt luật rõ:
|
||||
|
||||
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
|
||||
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
|
||||
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
|
||||
file mới có thể hỏng — phải còn đường về.
|
||||
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
|
||||
đoán ngược.
|
||||
|
||||
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
|
||||
``SecretStore``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from ..secrets.secret_store import SecretStore, provider_key
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Phiên bản app hiện đang ghi ra.
|
||||
CURRENT_VERSION = 2
|
||||
|
||||
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
|
||||
ASSUMED_VERSION = 1
|
||||
|
||||
|
||||
def read_version(data: Dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(data.get("schema_version", ASSUMED_VERSION))
|
||||
except (TypeError, ValueError):
|
||||
return ASSUMED_VERSION
|
||||
|
||||
|
||||
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
|
||||
"""Chuyển API key từ file sang kho bí mật — R02-T05.
|
||||
|
||||
Không có kho bí mật thì **không chuyển**: thà để khoá nằm nguyên trong file
|
||||
còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. File giữ
|
||||
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
|
||||
"""
|
||||
if secrets is None or not getattr(secrets, "available", True):
|
||||
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
||||
return data
|
||||
|
||||
out = copy.deepcopy(data)
|
||||
moved = []
|
||||
for name, conf in (out.get("providers") or {}).items():
|
||||
if not isinstance(conf, dict):
|
||||
continue
|
||||
key = (conf.get("api_key") or "").strip()
|
||||
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
|
||||
# dung. Đẩy nó vào keyring chỉ tổ rác.
|
||||
if not key or key == "ollama":
|
||||
continue
|
||||
secrets.set(provider_key(name), key)
|
||||
conf["api_key"] = ""
|
||||
moved.append(name)
|
||||
|
||||
out["schema_version"] = 2
|
||||
if moved:
|
||||
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
|
||||
return out
|
||||
|
||||
|
||||
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
|
||||
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
|
||||
1: _v1_to_v2,
|
||||
}
|
||||
|
||||
|
||||
def backup(path: Path) -> Path | None:
|
||||
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
|
||||
if not path.exists():
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
|
||||
try:
|
||||
shutil.copy2(path, target)
|
||||
return target
|
||||
except OSError as exc:
|
||||
log.warning("không sao lưu được %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
|
||||
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
|
||||
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
|
||||
|
||||
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
|
||||
khỏi phải ghi lại đĩa.
|
||||
"""
|
||||
version = read_version(data)
|
||||
|
||||
if version > CURRENT_VERSION:
|
||||
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
|
||||
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
|
||||
version, CURRENT_VERSION)
|
||||
return data, False
|
||||
|
||||
if version == CURRENT_VERSION:
|
||||
return data, False
|
||||
|
||||
if path is not None:
|
||||
backup(path)
|
||||
|
||||
changed = False
|
||||
while version < CURRENT_VERSION:
|
||||
step = STEPS.get(version)
|
||||
if step is None:
|
||||
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
|
||||
break
|
||||
data = step(data, secrets)
|
||||
new_version = read_version(data)
|
||||
if new_version <= version:
|
||||
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
|
||||
# keyring). Dừng, đừng lặp vô hạn.
|
||||
break
|
||||
version = new_version
|
||||
changed = True
|
||||
|
||||
return data, changed
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Khung nhìn có kiểu cho từng nhóm cấu hình — R02-T03.
|
||||
|
||||
Vấn đề đang có: khắp nơi viết ``ctx.config.routing.get("switch_mode", "off")``.
|
||||
Gõ sai một chữ thì lặng lẽ nhận giá trị mặc định, không ai biết cho tới khi
|
||||
tính năng "không hiểu sao không chạy". Đếm được **156 lời gọi ``ctx.config.*``
|
||||
trong 29 file** kiểu đó.
|
||||
|
||||
Ở đây mỗi nhóm cấu hình có một lớp: gõ sai tên thuộc tính là lỗi ngay, và kiểu
|
||||
dữ liệu ghi rõ ràng nên đọc code là biết ``confirm_timeout_sec`` là số giây
|
||||
chứ không phải mili giây.
|
||||
|
||||
Cố ý KHÔNG dùng dataclass đông cứng: đây là *khung nhìn* lên dict cấu hình
|
||||
sống, sửa qua đây là sửa vào dict rồi ``save()`` là xuống đĩa. Sao chép thành
|
||||
dataclass thì lại sinh chuyện đồng bộ hai chiều.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class _View:
|
||||
"""Khung nhìn lên một nhánh của dict cấu hình."""
|
||||
|
||||
def __init__(self, data: Dict[str, Any]):
|
||||
self._d = data
|
||||
|
||||
def _get(self, key: str, default: Any) -> Any:
|
||||
value = self._d.get(key, default)
|
||||
return default if value is None else value
|
||||
|
||||
def raw(self) -> Dict[str, Any]:
|
||||
"""Dict gốc — dùng khi cần đọc khoá chưa được đưa vào khung nhìn.
|
||||
|
||||
Có mặt để không ai bị kẹt: thiếu thuộc tính thì dùng tạm ``raw()`` rồi
|
||||
mở issue bổ sung, chứ đừng vòng lại ``ctx.config.data``.
|
||||
"""
|
||||
return self._d
|
||||
|
||||
|
||||
class ProviderSettings(_View):
|
||||
"""Một provider: đi đâu, model nào, khoá nào.
|
||||
|
||||
``api_key`` ở đây là thứ ``JsonConfigRepository.provider_conf()`` đã ghép
|
||||
sẵn từ kho bí mật — xem đường A trong ``GammaTeam_decisions.md``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return str(self._get("base_url", ""))
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
return str(self._get("model", ""))
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return str(self._get("api_key", ""))
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
"""Đủ thông tin để gọi được chưa.
|
||||
|
||||
Ollama chạy cục bộ nên không cần khoá — đó là lý do điều kiện là
|
||||
"có base_url và model", không phải "có api_key".
|
||||
"""
|
||||
return bool(self.base_url and self.model)
|
||||
|
||||
|
||||
class RoutingSettings(_View):
|
||||
"""Định tuyến model tự động (``core/routing/``)."""
|
||||
|
||||
@property
|
||||
def switch_mode(self) -> str:
|
||||
"""``"off"`` | ``"auto"`` | ``"manual"``."""
|
||||
return str(self._get("switch_mode", "off"))
|
||||
|
||||
@switch_mode.setter
|
||||
def switch_mode(self, value: str) -> None:
|
||||
self._d["switch_mode"] = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.switch_mode != "off"
|
||||
|
||||
@property
|
||||
def policy(self) -> str:
|
||||
"""``"balanced"`` | ``"cheap"`` | ``"quality"``…"""
|
||||
return str(self._get("policy", "balanced"))
|
||||
|
||||
@property
|
||||
def min_score_gain(self) -> float:
|
||||
"""Phải hơn model hiện tại bao nhiêu điểm mới đáng đổi."""
|
||||
return float(self._get("min_score_gain", 0.05))
|
||||
|
||||
@property
|
||||
def confirm_timeout_sec(self) -> int:
|
||||
"""GIÂY, không phải mili giây — đọc tên là biết, khỏi phải mò."""
|
||||
return int(self._get("confirm_timeout_sec", 60))
|
||||
|
||||
@property
|
||||
def reassess_interval_hours(self) -> int:
|
||||
return int(self._get("reassess_interval_hours", 24))
|
||||
|
||||
@property
|
||||
def per_provider_concurrency(self) -> int:
|
||||
return int(self._get("per_provider_concurrency", 2))
|
||||
|
||||
@property
|
||||
def judge_provider(self) -> str:
|
||||
return str(self._get("judge_provider", ""))
|
||||
|
||||
@property
|
||||
def judge_model(self) -> str:
|
||||
return str(self._get("judge_model", ""))
|
||||
|
||||
|
||||
class SecuritySettings(_View):
|
||||
"""Chính sách an toàn cho agent (``core/agent_security.py``)."""
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._get("enabled", True))
|
||||
|
||||
@property
|
||||
def validate_prompt(self) -> bool:
|
||||
return bool(self._get("validate_prompt", True))
|
||||
|
||||
@property
|
||||
def validate_attachments(self) -> bool:
|
||||
return bool(self._get("validate_attachments", True))
|
||||
|
||||
@property
|
||||
def validate_commands(self) -> bool:
|
||||
return bool(self._get("validate_commands", True))
|
||||
|
||||
@property
|
||||
def command_ai_check(self) -> bool:
|
||||
return bool(self._get("command_ai_check", False))
|
||||
|
||||
@property
|
||||
def cowork_confirm_commands(self) -> bool:
|
||||
"""Có hỏi trước khi chạy lệnh không.
|
||||
|
||||
Ứng với ``PolicyOutcome.ASK`` trong
|
||||
``domain/security/tool_policy.py``.
|
||||
"""
|
||||
return bool(self._get("cowork_confirm_commands", True))
|
||||
|
||||
@property
|
||||
def rules_onedrive_url(self) -> str:
|
||||
return str(self._get("rules_onedrive_url", ""))
|
||||
|
||||
@property
|
||||
def admin_email(self) -> str:
|
||||
return str(self._get("admin_email", ""))
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Cửa vào duy nhất cho các nhóm cấu hình có kiểu.
|
||||
|
||||
>>> s = Settings(repo)
|
||||
>>> if s.routing.enabled and s.provider().configured:
|
||||
... ...
|
||||
"""
|
||||
|
||||
def __init__(self, repo):
|
||||
self._repo = repo
|
||||
|
||||
def provider(self, name: str | None = None) -> ProviderSettings:
|
||||
return ProviderSettings(self._repo.provider_conf(name))
|
||||
|
||||
@property
|
||||
def routing(self) -> RoutingSettings:
|
||||
return RoutingSettings(self._repo.routing)
|
||||
|
||||
@property
|
||||
def security(self) -> SecuritySettings:
|
||||
return SecuritySettings(self._repo.agent_security)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Đánh số phiên bản + chuyển API key — R02-T06 và R02-T05."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
from cowork_local.infrastructure.config.schema_migration import (
|
||||
CURRENT_VERSION, migrate, read_version,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeSecretStore
|
||||
|
||||
DEFAULTS = {
|
||||
"active_provider": "openai",
|
||||
"providers": {"openai": {"base_url": "u", "model": "m", "api_key": ""},
|
||||
"ollama": {"base_url": "u", "model": "m", "api_key": "ollama"}},
|
||||
"theme": "dark", "language": "vi", "ms365": {},
|
||||
}
|
||||
|
||||
|
||||
def _repo(tmp_path, secrets=None):
|
||||
return JsonConfigRepository(tmp_path / "config.json", secrets=secrets,
|
||||
defaults=DEFAULTS, env_overrides=lambda d: d)
|
||||
|
||||
|
||||
def test_thieu_so_phien_ban_thi_coi_la_v1():
|
||||
assert read_version({}) == 1
|
||||
assert read_version({"schema_version": 2}) == 2
|
||||
assert read_version({"schema_version": "hỏng"}) == 1
|
||||
|
||||
|
||||
def test_v1_sang_v2_chuyen_khoa_vao_kho_bi_mat():
|
||||
secrets = FakeSecretStore()
|
||||
data = {"providers": {"openai": {"api_key": "sk-cu-nam-trong-file"}}} # casan: allow - du lieu test
|
||||
|
||||
out, changed = migrate(data, secrets=secrets)
|
||||
|
||||
assert changed is True
|
||||
assert out["schema_version"] == 2
|
||||
assert out["providers"]["openai"]["api_key"] == ""
|
||||
assert secrets.get("provider:openai") == "sk-cu-nam-trong-file"
|
||||
|
||||
|
||||
def test_khong_day_gia_tri_bu_nhin_cua_ollama_vao_kho():
|
||||
"""Ollama đòi có api_key nhưng bỏ qua nội dung — đẩy vào keyring chỉ tổ rác."""
|
||||
secrets = FakeSecretStore()
|
||||
out, _ = migrate({"providers": {"ollama": {"api_key": "ollama"}}}, secrets=secrets)
|
||||
assert secrets.get("provider:ollama") is None
|
||||
assert out["providers"]["ollama"]["api_key"] == "ollama"
|
||||
|
||||
|
||||
def test_khong_co_kho_bi_mat_thi_KHONG_chuyen():
|
||||
"""Thà để khoá nằm nguyên trong file còn hơn xoá đi rồi người dùng mất
|
||||
khoá mà không hiểu vì sao."""
|
||||
data = {"providers": {"openai": {"api_key": "sk-quy-gia"}}}
|
||||
out, changed = migrate(data, secrets=None)
|
||||
|
||||
assert changed is False
|
||||
assert out["providers"]["openai"]["api_key"] == "sk-quy-gia"
|
||||
assert read_version(out) == 1 # giữ v1, lần sau có keyring sẽ chuyển
|
||||
|
||||
|
||||
def test_da_v2_thi_khong_lam_gi_them():
|
||||
out, changed = migrate({"schema_version": 2}, secrets=FakeSecretStore())
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_file_moi_hon_app_thi_dung_nguyen_trang():
|
||||
"""App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu."""
|
||||
data = {"schema_version": 99, "thu_gi_do_tuong_lai": True}
|
||||
out, changed = migrate(data, secrets=FakeSecretStore())
|
||||
assert changed is False
|
||||
assert out == data
|
||||
|
||||
|
||||
def test_sao_luu_truoc_khi_nang_cap(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}),
|
||||
encoding="utf-8")
|
||||
|
||||
migrate(json.loads(path.read_text(encoding="utf-8")),
|
||||
secrets=FakeSecretStore(), path=path)
|
||||
|
||||
backups = list(tmp_path.glob("*.bak"))
|
||||
assert len(backups) == 1, "phải có bản sao lưu để còn đường lùi"
|
||||
assert "sk-x" in backups[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---- nối vào repository ----------------------------------------------------
|
||||
|
||||
def test_repository_tu_chuyen_khoa_khi_mo_file_cu(tmp_path):
|
||||
"""Cảnh thật: người dùng cập nhật app, mở lên, khoá cũ tự vào keyring."""
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"providers": {"openai": {"api_key": "sk-tu-ban-cu"}}}), # casan: allow - du lieu test
|
||||
encoding="utf-8")
|
||||
|
||||
secrets = FakeSecretStore()
|
||||
cfg = _repo(tmp_path, secrets)
|
||||
|
||||
# đọc ra vẫn thấy khoá...
|
||||
assert cfg.provider_conf("openai")["api_key"] == "sk-tu-ban-cu"
|
||||
# ...nhưng trên đĩa thì hết
|
||||
raw = (tmp_path / "config.json").read_text(encoding="utf-8")
|
||||
assert "sk-tu-ban-cu" not in raw
|
||||
assert json.loads(raw)["schema_version"] == CURRENT_VERSION
|
||||
# và có bản sao lưu
|
||||
assert len(list(tmp_path.glob("*.bak"))) == 1
|
||||
|
||||
|
||||
def test_mo_lai_lan_hai_khong_chuyen_lai(tmp_path):
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"providers": {"openai": {"api_key": "sk-x"}}}), encoding="utf-8")
|
||||
secrets = FakeSecretStore()
|
||||
_repo(tmp_path, secrets)
|
||||
so_ban_sao = len(list(tmp_path.glob("*.bak")))
|
||||
|
||||
_repo(tmp_path, secrets)
|
||||
assert len(list(tmp_path.glob("*.bak"))) == so_ban_sao, "không nâng cấp lại"
|
||||
|
||||
|
||||
def test_save_luon_ghi_so_phien_ban(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
cfg.save()
|
||||
raw = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
assert raw["schema_version"] == CURRENT_VERSION
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Typed Settings Facade — R02-T03."""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.infrastructure.config.settings_facade import (
|
||||
ProviderSettings, RoutingSettings, SecuritySettings, Settings,
|
||||
)
|
||||
from cowork_local.tests.fakes.fake_config import FakeConfigRepository
|
||||
|
||||
|
||||
def test_provider_doc_duoc_ba_truong():
|
||||
p = ProviderSettings({"base_url": "http://x/v1", "model": "llama3",
|
||||
"api_key": "sk-abc"})
|
||||
assert p.base_url == "http://x/v1"
|
||||
assert p.model == "llama3"
|
||||
assert p.api_key == "sk-abc"
|
||||
assert p.configured is True
|
||||
|
||||
|
||||
def test_ollama_khong_can_khoa_van_tinh_la_da_cau_hinh():
|
||||
"""Điều kiện là có base_url và model, không phải có api_key — Ollama chạy
|
||||
cục bộ nên không cần khoá."""
|
||||
p = ProviderSettings({"base_url": "http://localhost:11434/v1", "model": "llama3"})
|
||||
assert p.api_key == ""
|
||||
assert p.configured is True
|
||||
|
||||
|
||||
def test_thieu_model_thi_chua_cau_hinh():
|
||||
assert ProviderSettings({"base_url": "http://x/v1"}).configured is False
|
||||
assert ProviderSettings({}).configured is False
|
||||
|
||||
|
||||
def test_gia_tri_None_tra_ve_mac_dinh_chu_khong_None():
|
||||
"""File cấu hình cũ hay có khoá để null. Đọc ra None rồi đem so sánh số là
|
||||
vỡ — nên khung nhìn phải nuốt luôn trường hợp này."""
|
||||
r = RoutingSettings({"switch_mode": None, "min_score_gain": None,
|
||||
"confirm_timeout_sec": None})
|
||||
assert r.switch_mode == "off"
|
||||
assert r.min_score_gain == 0.05
|
||||
assert r.confirm_timeout_sec == 60
|
||||
|
||||
|
||||
def test_routing_kieu_du_lieu_dung():
|
||||
r = RoutingSettings({"switch_mode": "auto", "min_score_gain": "0.2",
|
||||
"confirm_timeout_sec": "90"})
|
||||
assert r.enabled is True
|
||||
assert isinstance(r.min_score_gain, float) and r.min_score_gain == 0.2
|
||||
assert isinstance(r.confirm_timeout_sec, int) and r.confirm_timeout_sec == 90
|
||||
|
||||
|
||||
def test_tat_dinh_tuyen():
|
||||
assert RoutingSettings({"switch_mode": "off"}).enabled is False
|
||||
assert RoutingSettings({}).enabled is False
|
||||
|
||||
|
||||
def test_sua_qua_khung_nhin_la_sua_vao_dict_that():
|
||||
"""Khung nhìn, không phải bản sao — sửa xong gọi save() là xuống đĩa."""
|
||||
d = {"switch_mode": "off"}
|
||||
RoutingSettings(d).switch_mode = "auto"
|
||||
assert d["switch_mode"] == "auto"
|
||||
|
||||
|
||||
def test_raw_de_khong_ai_bi_ket():
|
||||
d = {"switch_mode": "auto", "khoa_chua_dua_vao_khung_nhin": 1}
|
||||
assert RoutingSettings(d).raw()["khoa_chua_dua_vao_khung_nhin"] == 1
|
||||
|
||||
|
||||
def test_security_mac_dinh_la_bat():
|
||||
"""Mặc định an toàn: thiếu cấu hình thì bật kiểm tra, không phải tắt."""
|
||||
s = SecuritySettings({})
|
||||
assert s.enabled is True
|
||||
assert s.validate_prompt is True
|
||||
assert s.validate_commands is True
|
||||
assert s.cowork_confirm_commands is True
|
||||
assert s.command_ai_check is False # trừ cái này: gọi AI, tốn tiền
|
||||
|
||||
|
||||
def test_settings_noi_vao_repo():
|
||||
repo = FakeConfigRepository(active_provider="openai",
|
||||
routing={"switch_mode": "auto"},
|
||||
agent_security={"cowork_confirm_commands": False})
|
||||
s = Settings(repo)
|
||||
assert s.provider().model == "gpt-4o-mini"
|
||||
assert s.routing.enabled is True
|
||||
assert s.security.cowork_confirm_commands is False
|
||||
|
||||
|
||||
def test_doi_provider_thi_khung_nhin_theo_ngay():
|
||||
repo = FakeConfigRepository(active_provider="ollama")
|
||||
s = Settings(repo)
|
||||
assert s.provider().model == "llama3"
|
||||
repo.set_active_provider("openai")
|
||||
assert s.provider().model == "gpt-4o-mini"
|
||||
Reference in New Issue
Block a user