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
+1
View File
@@ -0,0 +1 @@
"""Infrastructure config package: ConfigRepository and typed settings facades."""
+119
View File
@@ -0,0 +1,119 @@
"""Cấu hình ứng dụng — interface, chưa phải cài đặt.
Hợp đồng số 2 của mục chung. Đây là thứ gỡ chốt lớn nhất: **156 lời gọi
``ctx.config.*`` nằm rải trong 29 file**, nên nếu N2 và N3 phải đợi
``ConfigRepository`` bản thật (R02-T02, hạn 23/08) thì hai người mất mấy ngày
đầu ngồi không.
Danh sách thuộc tính dưới đây không bịa ra: đếm trực tiếp chỗ đang gọi trong
``core/``, ``ui/``, ``providers/`` và ``app.py`` rồi lấy những cái được dùng
thật, xếp theo số lần gọi.
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
SEAM · dựng 2026-08-21 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: một chỗ chú thích kiểu thật sự nhận ``ConfigRepository`` thay vì ``AppConfig``.
Để dormant thì sao: Protocol không ai chú thích tới thì không có bộ kiểm
kiểu nào đối chiếu nó với ``JsonConfigRepository``, nên hai bên lệch nhau
lúc nào không hay.
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
đọc theo — đừng sửa ngày để làm im lời nhắc.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Protocol, runtime_checkable
@runtime_checkable
class ConfigRepository(Protocol):
"""Đọc/ghi cấu hình. Cài đặt thật dùng ``AtomicJsonFile`` (R02-T01/T02)."""
# ---- provider ------------------------------------------------------
@property
def active_provider(self) -> str:
"""Tên provider đang chọn (24 lời gọi)."""
...
def set_active_provider(self, name: str) -> None:
"""Đổi provider đang dùng."""
...
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
"""Cấu hình của một provider (9 lời gọi).
CHÚ Ý — điểm còn bỏ ngỏ, xem ``docs/refactor/GammaTeam_decisions.md``:
dict này còn chứa ``api_key`` hay không là quyết định chưa chốt. Có 5
nơi đang đọc trực tiếp, 3 trong số đó thuộc ``providers/`` của Team Duy.
"""
...
# ---- đường dẫn -----------------------------------------------------
@property
def shared_dir(self) -> str:
"""Thư mục dùng chung cho telemetry nhiều máy (10 lời gọi)."""
...
def history_dir(self) -> Path:
"""Thư mục lịch sử chat của project đang chọn (7 lời gọi)."""
...
def cowork_output_dir(self) -> Path:
"""Thư mục Cowork ghi kết quả ra (6 lời gọi)."""
...
# ---- giao diện -----------------------------------------------------
@property
def theme(self) -> str:
"""``"dark"`` | ``"light"`` | ``"system"`` (8 lời gọi)."""
...
def set_theme(self, value: str) -> None:
"""Đổi giao diện sáng/tối."""
...
@property
def language(self) -> str:
"""``"vi"`` | ``"en"`` | ``"ja"`` (4 lời gọi)."""
...
def set_language(self, value: str) -> None:
"""Đổi ngôn ngữ hiển thị."""
...
# ---- các nhóm cấu hình còn lại -------------------------------------
@property
def routing(self) -> Dict[str, Any]:
"""Cấu hình định tuyến model (7 lời gọi)."""
...
@property
def auth(self) -> Dict[str, Any]:
"""Cấu hình đăng nhập (6 lời gọi)."""
...
@property
def agent_security(self) -> Dict[str, Any]:
"""Chính sách an toàn cho agent (5 lời gọi)."""
...
@property
def tools_disabled(self) -> list[str]:
"""Tool bị tắt (2 lời gọi)."""
...
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Bật/tắt một tool theo tên."""
...
# ---- ghi ------------------------------------------------------------
def save(self) -> None:
"""Ghi xuống đĩa. Bản thật ghi atomic — tạm + fsync + thay thế —
nên tắt máy giữa chừng không làm hỏng file (R02-T01).
"""
...
+128
View File
@@ -0,0 +1,128 @@
"""Các nhóm cấu hình đọc thẳng từ dict — tách khỏi ``json_config_repository.py``.
Mỗi thành viên ở đây chỉ làm đúng một việc: đặt tên cho một khoá trong file
cấu hình và nói rõ giá trị mặc định khi khoá đó chưa có. Không có hành vi nào
đáng bàn, nhưng có đến hơn hai mươi cái — để chung với phần có logic thật
(trộn mặc định, kho bí mật, di trú schema, ghi nguyên tử) thì phần ấy bị chìm.
Dùng ``setdefault`` chứ không ``get``: bên gọi sửa thẳng vào dict trả về
(``cfg.routing["switch_mode"] = ...``) rồi mới ``save()``, nên cái trả về phải
là dict THẬT nằm trong ``data``, không phải một bản sao rồi bị vứt đi.
Mixin chứ không phải lớp riêng: 29 file đang gọi ``ctx.config.<tên>`` thẳng,
nên tách thành ``ctx.config.sections.<tên>`` sẽ là đổi API công khai — việc
này chỉ chia file, không chia bề mặt.
"""
from __future__ import annotations
import copy
from typing import Any, Dict
class ConfigSectionsMixin:
"""Phần truy cập nhóm cấu hình của :class:`JsonConfigRepository`.
Chỉ trông vào hai thứ của lớp chủ: ``self.data`` (dict cấu hình đã trộn
mặc định) và ``self.save()``. Không tự đứng một mình được — và cũng không
cần, vì không có ai khác dùng.
"""
@property
def code(self) -> Dict[str, Any]:
"""Nhóm ``code``: thiết lập của agent lập trình (thư mục làm việc, model)."""
return self.data["code"]
@property
def teams(self) -> Dict[str, Any]:
"""Nhóm ``teams``: thiết lập tích hợp Microsoft Teams."""
return self.data["teams"]
@property
def history(self) -> Dict[str, Any]:
"""Nhóm ``history``: lưu lịch sử hội thoại (bật/tắt, giới hạn)."""
return self.data["history"]
@property
def codebase_memory(self) -> Dict[str, Any]:
"""Nhóm ``codebase_memory``: bộ nhớ mã nguồn cho agent lập trình."""
return self.data["codebase_memory"]
@property
def cowork(self) -> Dict[str, Any]:
"""Nhóm ``cowork``: thiết lập màn Cowork (thư mục kết quả, model mặc định)."""
return self.data["cowork"]
@property
def mcp_servers(self) -> list:
"""Danh sách máy chủ MCP đã khai báo; rỗng nếu chưa có cái nào."""
return self.data.setdefault("mcp_servers", [])
@property
def structure(self) -> Dict[str, Any]:
"""Nhóm ``structure``: trần số node/cạnh khi vẽ đồ thị GraphRAG.
Mặc định 400/400 — vượt ngưỡng đó thì đồ thị vừa vẽ chậm vừa rối, không
còn đọc được nữa.
"""
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
@property
def monitoring_visibility(self) -> Dict[str, bool]:
"""Nhóm ``monitoring_visibility``: tab nào của màn Giám sát được hiện.
Lấy bản sao sâu của mặc định khi khoá chưa có, để người dùng tắt một tab
không vô tình sửa luôn bộ mặc định dùng chung.
"""
return self.data.setdefault(
"monitoring_visibility",
copy.deepcopy(self._defaults["monitoring_visibility"]))
@property
def ext_connectors(self) -> Dict[str, list]:
"""Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other."""
d = self.data.setdefault(
"ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
for cat in ("cad", "cae", "ms365", "other"):
d.setdefault(cat, [])
return d
# ---- công tắc tổng cho connector -------------------------------------
@property
def connect_external(self) -> bool:
"""Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định
BẬT để cấu hình đang chạy không đổi hành vi."""
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
def set_connect_external(self, enabled: bool) -> None:
"""Bật/tắt công tắc tổng cho connector ngoài, ghi đĩa ngay."""
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
self.save()
# ---- những thứ đã gieo sẵn -------------------------------------------
@property
def seeded_library_skills(self) -> list:
"""Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị
lặng lẽ gieo lại."""
return list(self.data.setdefault("seeded_library_skills", []))
@seeded_library_skills.setter
def seeded_library_skills(self, slugs) -> None:
"""Ghi lại danh sách slug đã gieo, bỏ trùng và giữ nguyên thứ tự."""
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
@property
def seeded_builtin_flows(self) -> list:
"""Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người
dùng đã xoá như seeded_library_skills)."""
return list(self.data.setdefault("seeded_builtin_flows", []))
@seeded_builtin_flows.setter
def seeded_builtin_flows(self, ids) -> None:
"""Ghi lại danh sách id flow đã gieo, bỏ trùng và giữ nguyên thứ tự."""
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
__all__ = ["ConfigSectionsMixin"]
@@ -0,0 +1,359 @@
"""ConfigRepository chạy trên file JSON — R02-T02.
Thay cho ``config.py::AppConfig``. Hai khác biệt duy nhất về hành vi, cả hai
đều là thứ ta muốn:
1. Ghi qua :class:`AtomicJsonFile` — mất điện giữa lúc lưu không còn làm hỏng
cấu hình (R02-T01).
2. API key đọc từ :class:`SecretStore` rồi **ghép vào** dict do
``provider_conf()`` trả về — đúng đường A đã chốt 21/08
(``docs/refactor/GammaTeam_decisions.md``). Nhờ vậy 5 nơi đang đọc
``conf["api_key"]`` không phải sửa dòng nào, trong đó 3 nơi thuộc Team Duy.
Mọi thứ còn lại giữ nguyên có chủ đích: trộn sâu với mặc định, đọc biến môi
trường, ``ms365.unlocked`` không bao giờ chạm đĩa. Đây là refactor — hành vi
nhìn từ ngoài phải y hệt.
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Dict
from ..persistence.json.atomic_json_file import AtomicJsonFile
from ..secrets.secret_store import SecretStore, provider_key
from .config_sections import ConfigSectionsMixin
from .schema_migration import CURRENT_VERSION, migrate
class JsonConfigRepository(ConfigSectionsMixin):
"""Cấu hình đọc/ghi từ một file JSON, bí mật để trong ``SecretStore``.
``secrets`` để None nghĩa là không có kho bí mật — mọi thứ vẫn chạy, chỉ
là ``api_key`` lấy nguyên từ file như trước. Cần vậy để chuyển dần
(R02-T05) chứ không phải đổi một phát cả app.
Các nhóm cấu hình đọc thẳng từ dict (``code``, ``teams``, ``history``,
connector, những thứ đã gieo sẵn…) nằm ở
``config_sections.py::ConfigSectionsMixin``: chúng không có logic nào
riêng, chỉ đặt tên cho một khoá và một giá trị mặc định, nên để chung chỉ
làm trôi mất phần thật sự có hành vi của lớp này.
"""
def __init__(self, path: Path, *, secrets: SecretStore | None = None,
defaults: Dict[str, Any] | None = None,
env_overrides=None):
"""Mở một file cấu hình.
``defaults``/``env_overrides`` để None thì lấy thẳng từ ``config.py`` — hai
bên phải dùng chung một bộ mặc định trong suốt giai đoạn chuyển, nếu không
ứng dụng sẽ thấy hai bộ cấu hình khác nhau tuỳ đường nào gọi tới.
"""
self._file = AtomicJsonFile(path)
self._secrets = secrets
# Lấy thẳng từ config.py để hai bên không lệch nhau trong lúc chuyển.
if defaults is None or env_overrides is None:
from ... import config as legacy
defaults = defaults if defaults is not None else legacy.DEFAULT_CONFIG
env_overrides = env_overrides or legacy._apply_env_overrides
self._defaults = defaults
self._env_overrides = env_overrides
self.data: Dict[str, Any] = self._load()
@classmethod
def from_data(cls, data: Dict[str, Any], path: Path):
"""Dựng từ dict có sẵn — KHÔNG đọc đĩa, KHÔNG nâng cấp schema.
Dành cho test: chúng dựng cấu hình trong bộ nhớ rồi mới ghi. Đi qua
``__init__`` thường thì nó đọc file (chưa có) và có thể chạy migration
trên dữ liệu test, tức là test đo nhầm thứ khác.
"""
obj = cls.__new__(cls)
obj._file = AtomicJsonFile(Path(path))
obj._secrets = None
from ... import config as legacy
obj._defaults = legacy.DEFAULT_CONFIG
obj._env_overrides = legacy._apply_env_overrides
obj.data = data
return obj
# ---- nạp ------------------------------------------------------------
def _load(self) -> Dict[str, Any]:
"""Đọc file JSON, nâng cấp schema rồi trộn lên trên bộ mặc định."""
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.
merged.setdefault("ms365", {})["unlocked"] = False
return merged
def reload(self) -> None:
"""Đọc lại toàn bộ cấu hình từ đĩa, bỏ mọi sửa đổi chưa ``save()``."""
self.data = self._load()
# ---- provider --------------------------------------------------------
@property
def active_provider(self) -> str:
"""Id provider đang chọn ('openai_compat', 'anthropic', ...); '' nếu chưa đặt."""
return self.data.get("active_provider", "")
@active_provider.setter
def active_provider(self, name: str) -> None:
"""``AppConfig`` cũ cho gán thẳng, và 3 chỗ trong app.py đang gán. Bỏ
setter đi thì Qt nuốt AttributeError trong slot và triệu chứng là
"bấm không ăn", không có lỗi nào hiện ra — mất hẳn một buổi mới truy
ra. Refactor thì hành vi nhìn từ ngoài phải y hệt."""
self.data["active_provider"] = name
def set_active_provider(self, name: str) -> None:
"""Đổi provider đang dùng. Bản hàm của property cùng tên, cho chỗ gọi thích
gọi hàm hơn gán thuộc tính.
"""
self.data["active_provider"] = name
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
"""Cấu hình provider, có sẵn ``api_key``.
Trả về BẢN SAO: chỗ gọi sửa dict này thì không được âm thầm ghi ngược
vào cấu hình — và quan trọng hơn, khoá vừa ghép vào không được lẫn
ngược vào ``self.data`` rồi theo ``save()`` xuống đĩa.
"""
name = name or self.active_provider
conf = dict(self.data.get("providers", {}).get(name, {}))
if self._secrets is not None:
stored = self._secrets.get(provider_key(name))
if stored:
conf["api_key"] = stored
return conf
def set_api_key(self, name: str, value: str) -> None:
"""Lưu khoá vào kho bí mật, và xoá khỏi cấu hình trên đĩa.
Đây là nửa còn lại của đường A: dict *đọc ra* vẫn có ``api_key``,
nhưng file JSON *trên đĩa* thì không — điều kiện để qua CASAN Check 1.
"""
if self._secrets is not None:
self._secrets.set(provider_key(name), value)
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = ""
else:
self.data.setdefault("providers", {}).setdefault(name, {})["api_key"] = value
# ---- đường dẫn -------------------------------------------------------
@property
def shared_dir(self) -> str:
"""Thư mục dùng chung (OneDrive/mạng) chứa tài khoản và agent quản trị; '' là
chưa cấu hình.
"""
return self.data.get("shared_dir", "")
def history_dir(self) -> Path:
"""Thư mục chứa lịch sử hội thoại."""
rt = getattr(self, "_project_history_dir", None) or self.data.get("_project_history_dir")
if rt:
return Path(rt)
custom = (self.data.get("history", {}).get("custom_dir") or "").strip()
if custom:
return Path(custom).expanduser()
from ...config import CONFIG_DIR
return CONFIG_DIR / "history"
def cowork_output_dir(self) -> Path:
"""Thư mục agent ghi kết quả ra."""
custom = (self.data.get("cowork", {}).get("output_dir") or "").strip()
if custom:
return Path(custom).expanduser()
from ... import paths
from ...config import CONFIG_DIR
root = paths.primary_onedrive_root()
if root is not None:
return root / "CoworkLocal" / "output"
return CONFIG_DIR / "output" / "cowork"
# ---- giao diện -------------------------------------------------------
@property
def theme(self) -> str:
"""Giao diện đang chọn: 'dark' | 'light' | 'system'. Mặc định 'dark'."""
return self.data.get("theme", "dark")
@theme.setter
def theme(self, value: str) -> None:
"""Đổi giao diện. Chỉ ghi vào bộ nhớ — phải ``save()`` mới xuống đĩa."""
self.data["theme"] = value
def set_theme(self, value: str) -> None:
"""Đổi giao diện sáng/tối. Bản hàm của property ``theme``."""
self.data["theme"] = value
@property
def language(self) -> str:
"""Mã ngôn ngữ đang chọn: 'vi' | 'en' | 'ja'. Mặc định 'vi'."""
return self.data.get("language", "vi")
@language.setter
def language(self, value: str) -> None:
"""Đổi ngôn ngữ. Chỉ ghi vào bộ nhớ — phải ``save()`` mới xuống đĩa."""
self.data["language"] = value
def set_language(self, value: str) -> None:
"""Đổi ngôn ngữ. Bản hàm của property ``language``."""
self.data["language"] = value
# ---- nhóm cấu hình ---------------------------------------------------
@property
def routing(self) -> Dict[str, Any]:
"""Nhóm ``routing``: chế độ tự chọn model và các thiết lập riêng theo bề mặt chat."""
return self.data.setdefault("routing", {})
@property
def auth(self) -> Dict[str, Any]:
"""Nhóm ``auth``: thiết lập đăng nhập. Bản này không có lớp đăng nhập nên
thường rỗng.
"""
return self.data.setdefault("auth", {})
@property
def agent_security(self) -> Dict[str, Any]:
"""Nhóm ``agent_security``: ngưỡng rủi ro và các mục agent phải xin phép."""
return self.data.setdefault("agent_security", {})
@property
def tools_disabled(self) -> list[str]:
"""Danh sách tool BỊ TẮT.
Lưu theo chiều "bị tắt" chứ không phải "được bật": tool mới thêm vào bản
cập nhật sẽ tự chạy được mà không cần ai vào bật thủ công.
Trả về BẢN SAO — sửa danh sách phải đi qua ``set_tool_enabled`` để còn ghi
đĩa.
"""
return list(self.data.get("tools_disabled", []))
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Bật/tắt một tool. Lưu dưới dạng danh sách tool BỊ TẮT nên tool mới"""
disabled = list(self.data.get("tools_disabled", []))
if enabled:
disabled = [t for t in disabled if t != name]
elif name not in disabled:
disabled.append(name)
self.data["tools_disabled"] = disabled
# ---- phần bù để thay được AppConfig ----------------------------------
# 21 thành viên dưới đây chép nguyên ngữ nghĩa từ ``config.py::AppConfig``.
# Không phải thiết kế mới: chừng nào 29 file còn gọi qua ``ctx.config`` thì
# repository phải trả lời được đúng những câu hỏi cũ, nếu không thì không
# tráo được. Dọn lại là việc của các R sau, không phải của R02.
#: Các chế độ định tuyến. Delta thêm "fallback" ở R03-T03. Định nghĩa ở đây
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
@classmethod
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
"""Dựng repository từ đường dẫn mặc định — thay ``AppConfig.load()``."""
if path is None:
from ... import config as legacy
path = legacy.CONFIG_PATH
return cls(Path(path), secrets=secrets)
@property
def path(self) -> Path:
"""Đường dẫn file config.json đang dùng."""
return self._file.path
# ---- TLS -------------------------------------------------------------
@property
def ca_bundle(self) -> str:
"""Đường dẫn file PEM riêng, hoặc '' để kiểm chứng chỉ như bình thường.
Dùng làm tham số ``verify=`` của ``requests`` cho mọi lượt gọi HTTPS."""
return (self.data.get("tls_ca_bundle") or "").strip()
@ca_bundle.setter
def ca_bundle(self, value: str) -> None:
"""Đặt file PEM riêng; chuỗi rỗng nghĩa là quay về kiểm chứng chỉ mặc định."""
self.data["tls_ca_bundle"] = (value or "").strip()
# ---- MS365 -----------------------------------------------------------
@property
def ms365(self) -> Dict[str, Any]:
"""Nhóm ``ms365``: thiết lập Microsoft 365 kèm mã mở khoá phía giao diện."""
return self.data.setdefault("ms365", copy.deepcopy(self._defaults["ms365"]))
def ms365_try_unlock(self, code: str) -> bool:
"""Mở khoá nhóm MS365 trong Cài đặt cho phiên này.
Đây là khoá phía giao diện (chặn bấm nhầm vào một mục nhạy cảm), KHÔNG
phải xác thực Microsoft. Không bao giờ được lưu ở trạng thái đã mở."""
if (code or "") and code == self.ms365.get("unlock_code", ""):
self.data["ms365"]["unlocked"] = True
return True
return False
def ms365_lock(self) -> None:
"""Khoá lại nhóm MS365 trong Cài đặt. Trạng thái khoá chỉ tồn tại lúc chạy."""
self.data.setdefault("ms365", {})["unlocked"] = False
# ---- định tuyến theo từng bề mặt chat --------------------------------
def routing_mode_for(self, surface: str) -> str:
"""Chế độ có hiệu lực cho một bề mặt chat.
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
có người sửa tay file cấu hình."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
mode = mode if mode in self.ROUTING_MODES else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
self.save()
# ---- tiện ích --------------------------------------------------------
def model_label(self) -> str:
"""Tên model đang dùng, để hiện trên thanh trạng thái; '?' nếu chưa đặt."""
return str(self.provider_conf().get("model", "?"))
# ---- ghi -------------------------------------------------------------
def save(self) -> None:
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
to_write = self.data
if self.data.get("ms365", {}).get("unlocked"):
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)
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
"""Trộn sâu — giống hệt ``config.py::_deep_merge``.
Không import lại từ đó vì file này phải sống được sau khi ``config.py``
biến mất; giữ bản sao 6 dòng còn hơn giữ một sợi dây phụ thuộc.
"""
out = copy.deepcopy(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], value)
else:
out[key] = value
return out
+139
View File
@@ -0,0 +1,139 @@
"""Đá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:
"""Phiên bản schema của một dict cấu hình.
File cũ chưa có trường này thì coi như ``ASSUMED_VERSION`` — đó chính là
phiên bản trước khi trường được thêm vào.
"""
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
+216
View File
@@ -0,0 +1,216 @@
"""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.
SEAM · dựng 2026-08-22 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ít nhất một trong 156 lời gọi ``ctx.config.*`` chuyển sang đọc qua ``Settings``.
Để dormant thì sao: Mục đích của nó là chặn lỗi gõ sai tên khoá. Không ai
dùng thì không chặn được gì cả.
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
đọc theo — đừng sửa ngày để làm im lời nhắc.
"""
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]):
"""Bọc một nhánh dict cấu hình để đọc bằng thuộc tính thay vì tra khoá."""
self._d = data
def _get(self, key: str, default: Any) -> Any:
"""Đọc một khoá, coi ``None`` như thiếu.
Cấu hình cũ có chỗ ghi ``null``; nếu trả thẳng ``None`` ra ngoài thì
``str(None)`` thành chuỗi "None" và lỗi hiện ra ở tận nơi dùng.
"""
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:
"""Endpoint của provider. '' nghĩa là chưa cấu hình."""
return str(self._get("base_url", ""))
@property
def model(self) -> str:
"""Model mặc định của provider này. '' nghĩa là chưa chọn."""
return str(self._get("model", ""))
@property
def api_key(self) -> str:
"""Khoá API — đã được ``provider_conf()`` ghép từ kho bí mật của hệ điều hành."""
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:
"""Đặt chế độ định tuyến chung. Sửa thẳng vào dict cấu hình sống."""
self._d["switch_mode"] = value
@property
def enabled(self) -> bool:
"""Định tuyến tự động có đang bật không (tức ``switch_mode`` khác "off")."""
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:
"""GIỜ giữa hai lần chấm điểm lại danh mục model."""
return int(self._get("reassess_interval_hours", 24))
@property
def per_provider_concurrency(self) -> int:
"""Số lượt gọi chạy song song tối đa cho mỗi provider khi dò/chấm điểm."""
return int(self._get("per_provider_concurrency", 2))
@property
def judge_provider(self) -> str:
"""Provider dùng làm trọng tài chấm điểm model. '' nghĩa là dùng provider đang chọn."""
return str(self._get("judge_provider", ""))
@property
def judge_model(self) -> str:
"""Model dùng làm trọng tài chấm điểm. '' nghĩa là dùng model mặc định của trọng tài."""
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:
"""Công tắc tổng của lớp an toàn. Tắt là bỏ qua mọi bước kiểm dưới đây."""
return bool(self._get("enabled", True))
@property
def validate_prompt(self) -> bool:
"""Có quét prompt người dùng tìm dấu hiệu tấn công tiêm lệnh không."""
return bool(self._get("validate_prompt", True))
@property
def validate_attachments(self) -> bool:
"""Có kiểm tệp đính kèm (đuôi/kiểu MIME nguy hiểm) trước khi đưa vào lượt chat không."""
return bool(self._get("validate_attachments", True))
@property
def validate_commands(self) -> bool:
"""Có phân loại rủi ro lệnh shell trước khi chạy không."""
return bool(self._get("validate_commands", True))
@property
def command_ai_check(self) -> bool:
"""Có nhờ thêm AI xét lệnh khi bộ luật tĩnh chưa chắc chắn không. Mặc định tắt vì tốn một lượt gọi."""
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:
"""Link OneDrive tới bộ luật an toàn dùng chung. '' nghĩa là dùng bản đóng gói sẵn trong app."""
return str(self._get("rules_onedrive_url", ""))
@property
def admin_email(self) -> str:
"""Email quản trị nhận cảnh báo vi phạm. '' nghĩa là không gửi."""
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):
"""Bọc một ``ConfigRepository`` — mọi lượt đọc/ghi đều đi xuống nó, lớp này chỉ
đổi cách gọi cho dễ đọc.
"""
self._repo = repo
def provider(self, name: str | None = None) -> ProviderSettings:
"""Khung nhìn cấu hình của một provider; bỏ trống thì lấy provider đang chọn."""
return ProviderSettings(self._repo.provider_conf(name))
@property
def routing(self) -> RoutingSettings:
"""Khung nhìn nhóm cấu hình định tuyến model."""
return RoutingSettings(self._repo.routing)
@property
def security(self) -> SecuritySettings:
"""Khung nhìn nhóm cấu hình an toàn cho agent."""
return SecuritySettings(self._repo.agent_security)