## 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:
+29
-241
@@ -12,6 +12,17 @@ The turn's source/label is set by the caller ON THE WORKER THREAD via
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Giữ đường vào cũ: nhiều nơi import mấy tên này thẳng từ usage_tracker.
|
||||
from .usage_ai_report import build_ai_analysis_prompt # noqa: F401
|
||||
from .usage_cost import ( # noqa: F401
|
||||
DEFAULT_PRICING, SUPPORTED_CURRENCIES, cost_usd, cost_usd_events,
|
||||
format_cost, format_cost_compact,
|
||||
)
|
||||
from .usage_periods import ( # noqa: F401
|
||||
bucketed_series, period_bounds, period_breakdown, period_range_label,
|
||||
period_totals, period_window,
|
||||
)
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
@@ -19,6 +30,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..config import CONFIG_DIR
|
||||
from . import model_pricing as mp
|
||||
|
||||
USAGE_DIR = CONFIG_DIR / "usage"
|
||||
|
||||
@@ -49,6 +61,18 @@ def set_context(source: str, label: str = "") -> None:
|
||||
_local.label = label
|
||||
|
||||
|
||||
def current_context() -> tuple:
|
||||
"""The ``(source, label)`` currently tagged on THIS thread.
|
||||
|
||||
Public counterpart to :func:`set_context`, added for
|
||||
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
|
||||
attribute one event to a different surface must be able to save the
|
||||
caller's context and put it back afterwards, instead of leaving the worker
|
||||
thread permanently retagged.
|
||||
"""
|
||||
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
|
||||
|
||||
|
||||
# ---- per-thread usage accumulator -----------------------------------------
|
||||
# A step/run that wants to know its OWN token/cost (not the all-time file total)
|
||||
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
|
||||
@@ -79,6 +103,11 @@ def end_accumulation() -> None:
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Ước lượng số token của một đoạn văn bản theo tỉ lệ 4 ký tự ≈ 1 token.
|
||||
|
||||
Ước lượng thô là đủ: con số này chỉ dùng để quyết định khi nào nén lịch sử,
|
||||
không dùng để tính tiền (tiền lấy từ số token thật provider trả về).
|
||||
"""
|
||||
return max(0, len(text or "") // 4)
|
||||
|
||||
|
||||
@@ -191,198 +220,30 @@ def summarize(events: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
# ---- cost ------------------------------------------------------------------
|
||||
DEFAULT_PRICING = {
|
||||
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
|
||||
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
|
||||
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
|
||||
"currency": "USD", # display currency: USD | VND | JPY
|
||||
"usd_to_vnd": 25000.0,
|
||||
"usd_to_jpy": 150.0,
|
||||
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
|
||||
# Events whose model has an entry are costed with ITS rates; everything
|
||||
# else falls back to the flat price_per_mtok_* rates above. Edited in the
|
||||
# Monitoring Overview's pricing table.
|
||||
"model_prices": {},
|
||||
# Reference URL of the price list the table was filled from (set in
|
||||
# Settings; shown as a link beside the table — informational only, the
|
||||
# app never scrapes it).
|
||||
"pricing_url": "",
|
||||
}
|
||||
|
||||
_CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
|
||||
|
||||
# Currencies the display picker offers — exactly the ones format_cost() can
|
||||
# actually convert to (symbol/precision above + a usd_to_* rate below).
|
||||
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
|
||||
|
||||
|
||||
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
return {
|
||||
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
|
||||
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
|
||||
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
|
||||
}
|
||||
|
||||
|
||||
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
|
||||
table applies: an event whose ``model`` has an entry in
|
||||
``pricing["model_prices"]`` is costed with that model's own rates; any
|
||||
other event uses the flat ``price_per_mtok_*`` rates. With an empty
|
||||
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
table = p.get("model_prices") or {}
|
||||
flat = {"in": float(p["price_per_mtok_in_usd"]),
|
||||
"out": float(p["price_per_mtok_out_usd"]),
|
||||
"cache": float(p["price_per_mtok_cache_usd"])}
|
||||
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
|
||||
for e in events:
|
||||
rates = table.get(e.get("model", "")) or {}
|
||||
for bucket in ("in", "out", "cache"):
|
||||
try:
|
||||
rate = float(rates.get(bucket, flat[bucket]))
|
||||
except (TypeError, ValueError):
|
||||
rate = flat[bucket]
|
||||
out[bucket] += e.get(bucket, 0) / 1e6 * rate
|
||||
return out
|
||||
|
||||
|
||||
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
|
||||
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
|
||||
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
|
||||
|
||||
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
|
||||
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
|
||||
from collections import OrderedDict
|
||||
pricing = pricing or {}
|
||||
|
||||
def _key(ts: Any) -> str:
|
||||
s = str(ts or "")[:10]
|
||||
if granularity == "year":
|
||||
return s[:4]
|
||||
if granularity == "month":
|
||||
return s[:7]
|
||||
return s
|
||||
|
||||
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
||||
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
|
||||
k = _key(e.get("ts"))
|
||||
if k:
|
||||
buckets.setdefault(k, []).append(e)
|
||||
out = []
|
||||
for k, evs in buckets.items():
|
||||
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
|
||||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||||
cost = sum(cost_usd_events(evs, pricing).values())
|
||||
out.append((k, tokens, cost))
|
||||
if last and len(out) > last:
|
||||
out = out[-last:]
|
||||
return out
|
||||
|
||||
|
||||
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
|
||||
"""[start, end) dates of the period ``offset`` periods from the current one
|
||||
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
|
||||
from datetime import timedelta
|
||||
today = today or date.today()
|
||||
if gran == "week":
|
||||
monday = today - timedelta(days=today.weekday()) # Monday of this week
|
||||
start = monday + timedelta(weeks=offset)
|
||||
return start, start + timedelta(days=7)
|
||||
if gran == "year":
|
||||
y = today.year + offset
|
||||
return date(y, 1, 1), date(y + 1, 1, 1)
|
||||
# month (default)
|
||||
base = today.year * 12 + (today.month - 1) + offset
|
||||
y, m = divmod(base, 12)
|
||||
y2, m2 = divmod(base + 1, 12)
|
||||
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
|
||||
|
||||
|
||||
def _period_label(gran: str, start: date) -> str:
|
||||
if gran == "week":
|
||||
return start.isoformat() # the week's Monday (YYYY-MM-DD)
|
||||
if gran == "year":
|
||||
return str(start.year)
|
||||
return start.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
|
||||
pricing: Dict[str, Any]) -> tuple:
|
||||
lo, hi = start.isoformat(), end.isoformat()
|
||||
evs = [e for e in events if lo <= str(e.get("ts", ""))[:10] < hi]
|
||||
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
|
||||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||||
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
|
||||
return tokens, cost
|
||||
|
||||
|
||||
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
offset: int = 0, today: Optional[date] = None) -> tuple:
|
||||
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
return _sum_between(events, start, end, pricing)
|
||||
|
||||
|
||||
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||||
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
|
||||
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
|
||||
pages the window into the past for the Dashboard's prev/next navigation."""
|
||||
out = []
|
||||
for i in range(count - 1, -1, -1):
|
||||
start, end = period_bounds(gran, offset - i, today)
|
||||
tok, cost = _sum_between(events, start, end, pricing)
|
||||
out.append((_period_label(gran, start), tok, cost))
|
||||
return out
|
||||
|
||||
|
||||
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||||
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
|
||||
→ ``[(label, tokens, cost_usd)]``:
|
||||
· week → 7 days Mon→Sun (label ``MM/DD``)
|
||||
· month → weeks W1…Wn (7-day chunks from the 1st)
|
||||
· year → 12 months (label ``01``…``12``)."""
|
||||
from datetime import timedelta
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
out = []
|
||||
if gran == "week":
|
||||
for i in range(7):
|
||||
d = start + timedelta(days=i)
|
||||
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
|
||||
out.append((d.strftime("%m/%d"), tok, cost))
|
||||
elif gran == "year":
|
||||
for m in range(1, 13):
|
||||
ms = date(start.year, m, 1)
|
||||
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
|
||||
tok, cost = _sum_between(events, ms, me, pricing)
|
||||
out.append((f"{m:02d}", tok, cost))
|
||||
else: # month → weeks W1..Wn
|
||||
ndays = (end - start).days
|
||||
wk, day = 1, 1
|
||||
while day <= ndays:
|
||||
ws = date(start.year, start.month, day)
|
||||
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
|
||||
tok, cost = _sum_between(events, ws, we, pricing)
|
||||
out.append((f"W{wk}", tok, cost))
|
||||
wk += 1
|
||||
day += 7
|
||||
return out
|
||||
|
||||
|
||||
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
|
||||
"""Human label for the selected period (shown in the Dashboard header) —
|
||||
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
|
||||
from datetime import timedelta
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
if gran == "week":
|
||||
last_day = end - timedelta(days=1)
|
||||
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
|
||||
if gran == "year":
|
||||
return str(start.year)
|
||||
return start.strftime("%Y/%m")
|
||||
|
||||
|
||||
def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
|
||||
@@ -397,7 +258,6 @@ def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
|
||||
order and ``budget_set_at`` only has 1-second resolution, so a timestamp
|
||||
cutoff could mis-include/exclude an event recorded in that same second —
|
||||
the count baseline is exact regardless of timing."""
|
||||
from . import model_pricing as mp
|
||||
usage = config.data.setdefault("usage", {})
|
||||
ccy = (currency or usage.get("currency") or "USD").upper()
|
||||
usage["budget_amount_usd"] = mp.convert(float(amount or 0), ccy, "USD", config)
|
||||
@@ -442,83 +302,11 @@ def budget_status(config) -> Optional[Dict[str, Any]]:
|
||||
}
|
||||
|
||||
|
||||
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
|
||||
"""Format a USD amount in the display currency. ``digits`` caps the number
|
||||
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
|
||||
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
|
||||
currency that uses fewer (VND stays whole, JPY one place)."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
cur = p.get("currency", "USD")
|
||||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||||
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||||
if digits is not None:
|
||||
cur_digits = min(cur_digits, digits)
|
||||
value = usd * rate
|
||||
return f"{symbol}{value:,.{cur_digits}f}"
|
||||
|
||||
|
||||
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
|
||||
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
|
||||
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
|
||||
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
|
||||
y-axis label box is narrow; the longer full-precision string used to
|
||||
overflow it, visually clipping/obscuring the leading currency symbol."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
cur = p.get("currency", "USD")
|
||||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||||
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||||
value = usd * rate
|
||||
sign = "-" if value < 0 else ""
|
||||
value = abs(value)
|
||||
if value >= 1_000_000:
|
||||
body = f"{value / 1_000_000:,.2f}M"
|
||||
elif value >= 1_000:
|
||||
body = f"{value / 1_000:,.2f}K"
|
||||
else:
|
||||
body = f"{value:,.2f}"
|
||||
return f"{sign}{symbol}{body}"
|
||||
|
||||
|
||||
_AI_ANALYSIS_HEADERS = {
|
||||
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
|
||||
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
|
||||
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
|
||||
}
|
||||
|
||||
|
||||
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
|
||||
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
|
||||
numbers only — never raw prompt contents — asking for concrete habits
|
||||
feedback and token-saving recommendations, in the CURRENTLY SELECTED
|
||||
display language (headers included — not just the model's free-text reply,
|
||||
which would otherwise leave the section titles in Vietnamese regardless of
|
||||
the app's language setting)."""
|
||||
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
|
||||
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
|
||||
top = "\n".join(f"- {label}: {tok:,} tokens"
|
||||
for label, tok in summary.get("top_labels", []))
|
||||
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
|
||||
return (
|
||||
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
|
||||
"scheduled agent tasks). Analyze this usage summary and give the user "
|
||||
"practical advice, replying in "
|
||||
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
|
||||
f"Period stats: {summary.get('turns', 0)} turns, "
|
||||
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
|
||||
f"cache={summary.get('cache', 0):,}, "
|
||||
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
|
||||
f"Top consumers:\n{top or '- (none)'}\n"
|
||||
f"By area: {by_source or '(none)'}\n"
|
||||
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
|
||||
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
|
||||
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
|
||||
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
|
||||
f"2. **{h2}** — 3 concrete prompt-writing tips "
|
||||
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
|
||||
"first; many small turns → batch questions).\n"
|
||||
f"3. **{h3}** — 2-3 app-level actions (compact history, "
|
||||
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
|
||||
"Keep the whole reply under 250 words."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user