Coverage for baibu/metrics/report.py: 100%
124 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Read stored rollups back for the staff usage page and the CSV export."""
3from collections import defaultdict
4from dataclasses import dataclass
5from dataclasses import field
6from datetime import date
7from datetime import timedelta
9from django.conf import settings
10from django.db.models import Max
11from django.utils.translation import gettext_lazy as _
13from .definitions import DAILY_METRICS
14from .definitions import METRICS
15from .definitions import is_suppressed
16from .models import DailyMetric
18DayValues = dict[tuple[str, str], int]
21@dataclass(frozen=True)
22class Column:
23 """A per-day figure: a metric's total, or one value of a breakdown."""
25 key: str
26 label: str
27 name: str
28 dimension: str | None = None # None: the total over all dimensions
29 # How days add up for the headline figures: "sum", or the metric that
30 # holds the figure for 7 and 30 days (distinct users), or None (not shown).
31 headline: str | None = "sum"
32 windows: tuple[str, str] = ("", "")
34 def value(self, values: DayValues) -> int | None:
35 if self.dimension is not None:
36 return values.get((self.name, self.dimension), 0 if values else None)
37 if (self.name, "") in values:
38 return values[self.name, ""]
39 if METRICS[self.name].dimension and values:
40 return sum(v for (name, _dimension), v in values.items() if name == self.name)
41 return None
44COLUMNS = (
45 Column("users_new", _("New users"), "users_new"),
46 Column(
47 "active_users",
48 _("Active users"),
49 "active_users",
50 headline="distinct",
51 windows=("active_users_7d", "active_users_30d"),
52 ),
53 Column("active_users_7d", _("Active, 7 days"), "active_users_7d", headline=None),
54 Column("active_users_30d", _("Active, 30 days"), "active_users_30d", headline=None),
55 Column("conversations", _("Conversations started"), "conversations"),
56 Column("messages_user", _("Messages sent"), "messages_user"),
57 Column("messages_assistant", _("Assistant replies"), "messages_assistant"),
58 Column("runs_failed", _("Failed reply attempts"), "runs", dimension="failed"),
59 Column("reply_time_median_ms", _("Reply time, median (ms)"), "reply_time_median_ms", headline=None),
60 Column("reply_time_p90_ms", _("Reply time, 90th percentile (ms)"), "reply_time_p90_ms", headline=None),
61 Column("tool_calls", _("Tool calls"), "tool_calls"),
62 Column("submissions", _("Submissions"), "submissions"),
63)
64COLUMNS_BY_KEY = {column.key: column for column in COLUMNS}
66BREAKDOWNS = (
67 "conversations_by_language",
68 "submissions_by_language",
69 "submissions_by_status",
70 "runs",
71 "tool_calls",
72)
75def display_value(name: str, dimension: str, value: int) -> int | str:
76 """The value, or "<N" if it is a small count in a sensitive breakdown."""
77 min_size = settings.METRICS_MIN_GROUP_SIZE
78 return f"<{min_size}" if is_suppressed(name, dimension, value, min_size) else value
81def latest_day(until: date) -> date | None:
82 """The last day up to ``until`` that has been computed."""
83 return DailyMetric.objects.filter(name="active_users", date__lte=until).aggregate(Max("date"))["date__max"]
86def values_by_day(first: date, last: date) -> dict[date, DayValues]:
87 days: dict[date, DayValues] = defaultdict(dict)
88 rows = DailyMetric.objects.filter(date__gte=first, date__lte=last, name__in=DAILY_METRICS)
89 for day, name, dimension, value in rows.values_list("date", "name", "dimension", "value"):
90 days[day][name, dimension] = value
91 return days
94@dataclass
95class Headline:
96 label: str
97 values: list[int | None] = field(default_factory=list)
100def headlines(anchor: date) -> list[Headline]:
101 """Figures for the last day, 7 days and 30 days up to ``anchor``."""
102 days = values_by_day(anchor - timedelta(days=29), anchor)
103 last = days.get(anchor, {})
104 result = []
105 for column in COLUMNS:
106 if column.headline == "sum":
107 sums = []
108 for span in (1, 7, 30):
109 window = [days.get(anchor - timedelta(days=offset), {}) for offset in range(span)]
110 sums.append(sum(column.value(values) or 0 for values in window))
111 result.append(Headline(column.label, sums))
112 elif column.headline == "distinct":
113 names = (column.name, *column.windows)
114 result.append(Headline(column.label, [_plain(last, name) for name in names]))
115 return result
118def _plain(values: DayValues, name: str) -> int | None:
119 return values.get((name, ""))
122def users_total(day: date) -> int | None:
123 row = DailyMetric.objects.filter(date=day, name="users_total", dimension="").first()
124 return row.value if row else None
127def daily_rows(first: date, last: date) -> list[dict]:
128 """One row per day, newest first; ``computed`` is False for days with no rollups."""
129 days = values_by_day(first, last)
130 rows = []
131 day = last
132 while day >= first:
133 values = days.get(day, {})
134 rows.append(
135 {
136 "date": day,
137 "computed": ("active_users", "") in values,
138 "cells": [column.value(values) for column in COLUMNS],
139 }
140 )
141 day -= timedelta(days=1)
142 return rows
145def chart(rows: list[dict], column: Column) -> dict:
146 """Bar geometry for an inline SVG of one column, oldest day on the left."""
147 index = COLUMNS.index(column)
148 points = [(row["date"], row["cells"][index]) for row in reversed(rows)]
149 width, height, gap = 720, 160, 2
150 top = max((value or 0 for _day, value in points), default=0)
151 step = width / max(len(points), 1)
152 bars = []
153 for position, (day, value) in enumerate(points):
154 bar_height = round((value or 0) / top * height, 1) if top else 0
155 bars.append(
156 {
157 "x": round(position * step + gap / 2, 1),
158 "y": round(height - bar_height, 1),
159 "width": round(max(step - gap, 1), 1),
160 "height": bar_height,
161 "date": day,
162 "value": value,
163 }
164 )
165 return {"column": column, "bars": bars, "max": top, "width": width, "height": height}
168def breakdowns(first: date, last: date) -> list[dict]:
169 """Totals over the range for each breakdown, largest first."""
170 rows = DailyMetric.objects.filter(date__gte=first, date__lte=last, name__in=BREAKDOWNS).exclude(dimension="")
171 totals: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
172 for name, dimension, value in rows.values_list("name", "dimension", "value"):
173 totals[name][dimension] += value
174 result = []
175 for name in BREAKDOWNS:
176 items = sorted(totals[name].items(), key=lambda item: (-item[1], item[0]))
177 result.append(
178 {
179 "metric": METRICS[name],
180 "rows": [(dimension, display_value(name, dimension, value)) for dimension, value in items],
181 }
182 )
183 return result
186def consent(day: date | None) -> list[tuple[str, str, int | str]]:
187 """Users per consent scope and tier at the end of ``day``."""
188 if day is None:
189 return []
190 rows = DailyMetric.objects.filter(date=day, name="consent").values_list("dimension", "value")
191 result = []
192 for dimension, value in sorted(rows):
193 scope, _sep, tier = dimension.partition(":")
194 result.append((scope, tier, display_value("consent", dimension, value)))
195 return result
198def retention(first: date, last: date) -> dict:
199 """Weekly cohorts that start between ``first`` and ``last``, newest first."""
200 weeks = list(range(1, settings.METRICS_RETENTION_WEEKS + 1))
201 rows = DailyMetric.objects.filter(date__gte=first, date__lte=last, name__startswith="retention_")
202 cohorts: dict[date, dict[str, int]] = defaultdict(dict)
203 for day, name, dimension, value in rows.values_list("date", "name", "dimension", "value"):
204 cohorts[day][dimension if name == "retention_active" else "size"] = value
205 result = []
206 for day in sorted(cohorts, reverse=True):
207 size = cohorts[day].get("size")
208 if not size:
209 continue
210 cells = []
211 for week in weeks:
212 active = cohorts[day].get(str(week))
213 cells.append(None if active is None else {"value": active, "percent": round(100 * active / size)})
214 result.append({"week": day, "size": size, "cells": cells})
215 return {"weeks": weeks, "cohorts": result}
218def export_rows(first: date, last: date):
219 """CSV rows (header first) for every stored value dated in the range."""
220 yield ["date", "name", "dimension", "value"]
221 rows = DailyMetric.objects.filter(date__gte=first, date__lte=last).order_by("date", "name", "dimension")
222 for day, name, dimension, value in rows.values_list("date", "name", "dimension", "value"):
223 yield [day.isoformat(), name, dimension, display_value(name, dimension, value)]