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

1"""Read stored rollups back for the staff usage page and the CSV export.""" 

2 

3from collections import defaultdict 

4from dataclasses import dataclass 

5from dataclasses import field 

6from datetime import date 

7from datetime import timedelta 

8 

9from django.conf import settings 

10from django.db.models import Max 

11from django.utils.translation import gettext_lazy as _ 

12 

13from .definitions import DAILY_METRICS 

14from .definitions import METRICS 

15from .definitions import is_suppressed 

16from .models import DailyMetric 

17 

18DayValues = dict[tuple[str, str], int] 

19 

20 

21@dataclass(frozen=True) 

22class Column: 

23 """A per-day figure: a metric's total, or one value of a breakdown.""" 

24 

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] = ("", "") 

33 

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 

42 

43 

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} 

65 

66BREAKDOWNS = ( 

67 "conversations_by_language", 

68 "submissions_by_language", 

69 "submissions_by_status", 

70 "runs", 

71 "tool_calls", 

72) 

73 

74 

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 

79 

80 

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"] 

84 

85 

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 

92 

93 

94@dataclass 

95class Headline: 

96 label: str 

97 values: list[int | None] = field(default_factory=list) 

98 

99 

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 

116 

117 

118def _plain(values: DayValues, name: str) -> int | None: 

119 return values.get((name, "")) 

120 

121 

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 

125 

126 

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 

143 

144 

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} 

166 

167 

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 

184 

185 

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 

196 

197 

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} 

216 

217 

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)]