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
+19
View File
@@ -0,0 +1,19 @@
"""Read-only query services for monitoring/dashboard screens (EPIC R08).
⚠️ Ownership note (R08-T13): per ``docs/refactor/Feature_Architecture_
Proposal.md``'s file-split diagram, ``dashboard_query_service.py`` lives
under ``application/monitoring/`` alongside the Dashboard split — but the
SAME document's "Ranh giới phân hệ" table assigns ``application/monitoring/``
to Team Nam (R08-T07→T10, Monitoring's own 8-tab split). This directory did
not exist yet when Team Hoa reached R08-T13, so creating it here does not
collide with any file Team Nam has written — same situation R06-T02 flagged
for ``infrastructure/persistence/json/atomic_write.py`` vs. Team Nam's
planned ``atomic_json_file.py``. Team Nam should confirm when they start
R08-T07→T10 whether ``DashboardQueryService`` belongs here permanently or
should move once Monitoring's own query service exists.
"""
from .dashboard_query_service import DashboardQueryService
from .monitoring_query_service import MonitoringQueryService
__all__ = ["DashboardQueryService", "MonitoringQueryService"]
@@ -0,0 +1,120 @@
"""DashboardQueryService - read-only usage/cost queries for the Dashboard
screen (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
196-199/256-261/284-322 of the original 437-line file: ``_pricing``,
``_period_range``'s date-math, and the ``period_totals``/``period_breakdown``
calls ``_refresh_chart`` made directly).
``ui/dashboard_tab.py`` called ``core/usage_tracker.py``/``core/model_
pricing.py`` directly from FIVE different methods spread across what is now
three widgets (``token_usage_card_widget.py``, ``usage_chart_widget.py``,
``habits_widget.py``) — each recomputing the same merged pricing dict. This
service is the one place that merge happens now; the three widgets share it
instead of each calling ``core.usage_tracker``/``core.model_pricing`` on
their own.
Pure Python: no Qt. Wraps ``core/usage_tracker.py`` (a plain-Python module
already) rather than reimplementing any of its date/cost math.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Dict, List, Tuple
class DashboardQueryService:
"""Usage/cost queries scoped to one ``AppContext``.
Args:
ctx: ``AppContext`` — read for ``ctx.config`` (pricing table,
currency, budget) and nothing else; this class does no I/O of
its own beyond what ``core.usage_tracker`` already does.
directory: Optional custom usage directory. If None, uses default USAGE_DIR.
"""
def __init__(self, ctx: Any, directory: Optional[Path] = None) -> None:
"""``directory`` để None thì đọc thư mục telemetry mặc định; test trỏ nó vào
``tmp_path`` để không chạm dữ liệu thật.
"""
self.ctx = ctx
self._directory = directory
def pricing(self) -> Dict[str, Any]:
"""The merged price table (defaults + user overrides), synced from
Monitoring's model-pricing table first so cost figures always agree
between the two screens."""
from cowork_local.core import model_pricing as mp
from cowork_local.core import usage_tracker as ut
mp.sync_to_usage(self.ctx.config)
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
def period_range(self, granularity: str, offset: int) -> Tuple[date, date]:
"""The SELECTED period as an inclusive ``(start, end)`` date range —
drives every widget on the screen (cards, chart, habits)."""
from cowork_local.core import usage_tracker as ut
start, end = ut.period_bounds(granularity, offset)
return start, end - timedelta(days=1) # load_events end is inclusive
def summary(self, start: date, end: date) -> Dict[str, Any]:
"""Everything the stat cards + habits panel need for one period:
the raw events, ``usage_tracker.summarize``'s aggregate stats, the
per-bucket costs, and their total — computed once so both widgets
read the same numbers instead of loading events twice."""
from cowork_local.core import usage_tracker as ut
events = ut.load_events(start, end, directory=self._directory)
pricing = self.pricing()
stats = ut.summarize(events)
costs = ut.cost_usd_events(events, pricing)
return {
"events": events,
"pricing": pricing,
"stats": stats,
"costs": costs,
"total_cost": sum(costs.values()),
}
def chart_series(self, granularity: str, offset: int, metric: str
) -> List[Tuple[str, float]]:
"""``(label, value)`` points for the spline chart — WEEK -> 7 days,
MONTH -> weeks, YEAR -> 12 months, in whichever ``metric``
("tokens" | "cost") was selected."""
from cowork_local.core import usage_tracker as ut
events = ut.load_events(directory=self._directory) # all events; breakdown slices by period
pricing = self.pricing()
parts = ut.period_breakdown(events, granularity, pricing, offset=offset)
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) -> +1 for the value
return [(row[0], float(row[mi + 1])) for row in parts]
def period_totals(self, granularity: str, offset: int) -> Tuple[float, float]:
"""``(tokens, cost)`` totals for one period — used to compute the
vs-previous-period delta the chart's reference line shows."""
from cowork_local.core import usage_tracker as ut
events = ut.load_events(directory=self._directory)
return ut.period_totals(events, granularity, self.pricing(), offset)
def period_range_label(self, granularity: str, offset: int) -> str:
"""Nhãn hiển thị của một kỳ (tuần/tháng/năm cộng độ lệch)."""
from cowork_local.core import usage_tracker as ut
return ut.period_range_label(granularity, offset)
def budget_status(self):
"""Tình trạng ngân sách: đã dùng bao nhiêu, còn lại bao nhiêu, có vượt ngưỡng chưa."""
from cowork_local.core import usage_tracker as ut
return ut.budget_status(self.ctx.config)
def set_budget(self, amount: float, currency: str) -> None:
"""Đặt hạn mức ngân sách mới — mở một chu kỳ đếm mới, chi tiêu trước đó không
còn được tính vào.
"""
from cowork_local.core import usage_tracker as ut
ut.set_budget(self.ctx.config, amount, currency)
__all__ = ["DashboardQueryService"]
+3
View File
@@ -0,0 +1,3 @@
"""DTO của phân hệ Giám sát: hình dạng dữ liệu mà tầng application trả cho
giao diện, không phụ thuộc nguồn đọc.
"""
@@ -0,0 +1,51 @@
"""Application-layer view of an audit event — decoupled from the
infrastructure ``CanonicalAuditEvent`` so ``application/`` doesn't need to
share a concrete class with ``infrastructure/`` (only the shape). Field names
match the canonical audit schema (see
``infrastructure/telemetry/audit_logger.py``) 1:1.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict
@dataclass(frozen=True)
class AuditEventDTO:
"""Một sự kiện kiểm toán ở dạng tầng application dùng — không phụ thuộc khuôn
lưu trên đĩa, nên đổi định dạng nhật ký không kéo theo sửa giao diện.
"""
ts: str
kind: str
name: str
ok: bool
detail: str
agent_role: str = ""
account: str = ""
role: str = ""
machine: str = ""
@classmethod
def from_raw(cls, raw: Dict[str, Any]) -> "AuditEventDTO":
"""Tolerant of missing keys — accepts both a
``CanonicalAuditEvent.to_dict()`` result and any historical raw
``.jsonl`` row."""
return cls(
ts=str(raw.get("ts", "")),
kind=str(raw.get("kind", "")),
name=str(raw.get("name", "")),
ok=bool(raw.get("ok", False)),
detail=str(raw.get("detail", "")),
agent_role=str(raw.get("agent_role", "")),
account=str(raw.get("account", "")),
role=str(raw.get("role", "")),
machine=str(raw.get("machine", "")),
)
def to_dict(self) -> Dict[str, Any]:
"""Bản ghi dưới dạng dict cho lớp giao diện."""
return {
"ts": self.ts, "kind": self.kind, "agent_role": self.agent_role,
"name": self.name, "ok": self.ok, "detail": self.detail,
"account": self.account, "role": self.role, "machine": self.machine,
}
@@ -0,0 +1,60 @@
"""Read-only query service over audit events — filter + sort + pagination.
Pure Python: no PySide6 import, no UI code. Depends only on an injected
``AuditEventRepository`` (see ``repository/audit_event_repository.py``), so it
is fully unit-testable with ``InMemoryAuditEventRepository`` and independent
of file I/O or Qt.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional
from .dto.audit_event_dto import AuditEventDTO
from .repository.audit_event_repository import AuditEventRepository
@dataclass(frozen=True)
class Page:
"""Một trang kết quả truy vấn nhật ký: các mục, tổng số, số trang và cỡ trang."""
items: List[AuditEventDTO]
total: int
page: int
page_size: int
@property
def has_more(self) -> bool:
"""Còn trang sau nữa không."""
return self.page * self.page_size < self.total
class MonitoringQueryService:
"""Read-only. Callers ask for a filtered/sorted/paginated slice of the
audit log; this service never writes anything."""
def __init__(self, repository: AuditEventRepository) -> None:
"""Nhận kho sự kiện kiểm toán qua tham số — bản thật đọc đĩa, bản test nằm
trong bộ nhớ.
"""
self._repository = repository
def query(self, kind: Optional[str] = None, ok: Optional[bool] = None,
text: Optional[str] = None, sort_by: str = "ts",
descending: bool = True, page: int = 1, page_size: int = 50) -> Page:
"""Lọc theo loại/kết quả/từ khoá, sắp xếp rồi cắt thành một trang."""
events = self._repository.load(kind=kind)
if ok is not None:
events = [e for e in events if e.ok == ok]
if text:
needle = text.lower()
events = [e for e in events
if needle in e.name.lower() or needle in e.detail.lower()]
events = sorted(events, key=lambda e: getattr(e, sort_by, ""), reverse=descending)
total = len(events)
page = max(1, page)
start = (page - 1) * page_size
items = events[start:start + page_size] if page_size > 0 else events
return Page(items=items, total=total, page=page, page_size=page_size)
@@ -0,0 +1 @@
"""Cổng đọc dữ liệu của phân hệ Giám sát — hợp đồng, không phải cài đặt."""
@@ -0,0 +1,53 @@
"""Audit-event repository — the boundary between ``MonitoringQueryService``
and where events actually live. ``CanonicalAuditEventRepository`` is the real
adapter (wraps an injected ``CanonicalAuditLogger``); ``InMemoryAuditEventRepository``
is a constructor-injected test double, following this repo's existing
``Fake*``/``Recording*`` convention (see ``tests/routing/*``,
``tests/test_project_context_mcp_template.py``) rather than ``unittest.mock``.
"""
from __future__ import annotations
from typing import List, Optional, Protocol
from ..dto.audit_event_dto import AuditEventDTO
class AuditEventRepository(Protocol):
"""Cổng đọc nhật ký kiểm toán mà tầng application dùng.
Chỉ là hợp đồng: bản cài đặt thật đọc từ file cục bộ hoặc thư mục chia sẻ,
còn test truyền vào bộ giả.
"""
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
"""Đọc sự kiện kiểm toán, lọc theo loại nếu có."""
...
class CanonicalAuditEventRepository:
"""Adapter over ``infrastructure.telemetry.audit_logger.CanonicalAuditLogger``
— the only place this application service reaches into infrastructure."""
def __init__(self, audit_logger) -> None:
"""Bọc bộ ghi nhật ký kiểm toán chuẩn để đọc sự kiện ra."""
self._audit_logger = audit_logger
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
"""Đọc sự kiện từ nhật ký và đổi sang DTO của tầng application."""
events = self._audit_logger.load_events(kind=kind)
return [AuditEventDTO.from_raw(e.to_dict()) for e in events]
class InMemoryAuditEventRepository:
"""Test double — holds a fixed list of events, no file I/O."""
def __init__(self, events: List[AuditEventDTO]) -> None:
"""Nhận sẵn danh sách sự kiện. Chép lại chứ không giữ tham chiếu: bên gọi sửa
danh sách gốc thì kết quả test không được đổi theo.
"""
self._events = list(events)
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
"""Trả về danh sách đã nạp sẵn, lọc theo loại nếu có."""
if kind is None:
return list(self._events)
return [e for e in self._events if e.kind == kind]