## 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:
@@ -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"]
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user