cctally 1.6.3 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/bin/_lib_doctor.py +903 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/cctally +835 -52
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
|
@@ -171,6 +171,106 @@ _PROJECT_COLUMNS = (
|
|
|
171
171
|
)
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
# --- Cross-tab Detail-template helpers (issue #33, spec §6.1) ---
|
|
175
|
+
_CROSS_TAB_OTHER_KEY = "_other"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _aggregate_breakdowns(
|
|
179
|
+
breakdowns: list[dict[str, float]],
|
|
180
|
+
) -> list[tuple[str, float]]:
|
|
181
|
+
"""Aggregate per-row breakdowns into window-wide totals.
|
|
182
|
+
|
|
183
|
+
Returns a list of (label, total) tuples sorted by total desc, ties
|
|
184
|
+
broken lex ascending. Deterministic for goldens.
|
|
185
|
+
"""
|
|
186
|
+
totals: dict[str, float] = {}
|
|
187
|
+
for br in breakdowns:
|
|
188
|
+
for k, v in br.items():
|
|
189
|
+
totals[k] = totals.get(k, 0.0) + float(v or 0.0)
|
|
190
|
+
return sorted(totals.items(), key=lambda p: (-p[1], p[0]))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cross_tab_columns(
|
|
194
|
+
row_label_col: "_LS.ColumnSpec",
|
|
195
|
+
members: list[tuple[str, float]],
|
|
196
|
+
top_n: int,
|
|
197
|
+
has_other_residual: bool,
|
|
198
|
+
*,
|
|
199
|
+
kind: str, # "project" | "model"
|
|
200
|
+
) -> tuple[tuple, tuple[str, ...], bool]:
|
|
201
|
+
"""Build (columns, top_k_labels, has_other) for a cross-tab table.
|
|
202
|
+
|
|
203
|
+
Column keys are stable synthetic identifiers (m_0..m_K, _other) so
|
|
204
|
+
project paths with awkward characters or post-scrub labels never
|
|
205
|
+
affect renderer `row.cells.get(col.key)` lookups.
|
|
206
|
+
|
|
207
|
+
`has_other` is True iff either:
|
|
208
|
+
- len(members) > top_n (overflow case), OR
|
|
209
|
+
- has_other_residual is True (partial-coverage case).
|
|
210
|
+
|
|
211
|
+
Caller computes `has_other_residual` via `_detect_residual` over the
|
|
212
|
+
provisional top-K labels. Spec §4.2.
|
|
213
|
+
"""
|
|
214
|
+
top = members[:top_n]
|
|
215
|
+
has_other_cap = len(members) > top_n
|
|
216
|
+
has_other = has_other_cap or has_other_residual
|
|
217
|
+
cols: list = [
|
|
218
|
+
row_label_col,
|
|
219
|
+
_LS.ColumnSpec(key="total", label="$", align="right", emphasis=True),
|
|
220
|
+
]
|
|
221
|
+
for i, (lbl, _total) in enumerate(top):
|
|
222
|
+
cols.append(_LS.ColumnSpec(
|
|
223
|
+
key=f"m_{i}", label=lbl, align="right", kind=kind,
|
|
224
|
+
))
|
|
225
|
+
if has_other:
|
|
226
|
+
# kind=None: "Other" rollup is never a project name; scrubber skips.
|
|
227
|
+
cols.append(_LS.ColumnSpec(
|
|
228
|
+
key=_CROSS_TAB_OTHER_KEY, label="Other", align="right",
|
|
229
|
+
))
|
|
230
|
+
return tuple(cols), tuple(t[0] for t in top), has_other
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _cross_tab_row(
|
|
234
|
+
*,
|
|
235
|
+
row_label_key: str,
|
|
236
|
+
row_label_cell,
|
|
237
|
+
row_total: float,
|
|
238
|
+
breakdown: dict[str, float],
|
|
239
|
+
top_k_labels: tuple[str, ...],
|
|
240
|
+
has_other: bool,
|
|
241
|
+
):
|
|
242
|
+
"""One cross-tab row. Other = clamp(row_total - SUM(top_k cells), 0)."""
|
|
243
|
+
cells: dict = {
|
|
244
|
+
row_label_key: row_label_cell,
|
|
245
|
+
"total": _LS.MoneyCell(usd=row_total),
|
|
246
|
+
}
|
|
247
|
+
other_sum = row_total
|
|
248
|
+
for i, lbl in enumerate(top_k_labels):
|
|
249
|
+
v = float(breakdown.get(lbl, 0.0))
|
|
250
|
+
cells[f"m_{i}"] = _LS.MoneyCell(usd=v)
|
|
251
|
+
other_sum -= v
|
|
252
|
+
if has_other:
|
|
253
|
+
cells[_CROSS_TAB_OTHER_KEY] = _LS.MoneyCell(usd=max(0.0, other_sum))
|
|
254
|
+
return _LS.Row(cells=cells)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _detect_residual(
|
|
258
|
+
rows_and_breakdowns: list[tuple[float, dict[str, float]]],
|
|
259
|
+
top_k_labels: tuple[str, ...],
|
|
260
|
+
*,
|
|
261
|
+
epsilon: float = 1e-9,
|
|
262
|
+
) -> bool:
|
|
263
|
+
"""Return True if any row's residual (row_total - SUM(top-K cells))
|
|
264
|
+
exceeds epsilon. Short-circuits at the first violating row. Drives
|
|
265
|
+
`has_other_residual` in `_cross_tab_columns`.
|
|
266
|
+
"""
|
|
267
|
+
for row_total, breakdown in rows_and_breakdowns:
|
|
268
|
+
top_k_sum = sum(float(breakdown.get(lbl, 0.0)) for lbl in top_k_labels)
|
|
269
|
+
if abs(row_total - top_k_sum) > epsilon:
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
174
274
|
def _utc_now() -> _dt.datetime:
|
|
175
275
|
"""Override-aware UTC now (per `CCTALLY_AS_OF` env hook for fixture tests)."""
|
|
176
276
|
s = os.environ.get("CCTALLY_AS_OF")
|
|
@@ -727,29 +827,51 @@ def _build_weekly_visual(*, panel_data, options):
|
|
|
727
827
|
|
|
728
828
|
|
|
729
829
|
def _build_weekly_detail(*, panel_data, options):
|
|
730
|
-
"""Weekly detail —
|
|
731
|
-
|
|
732
|
-
Same panel_data shape as `_build_weekly_recap`; Detail uses
|
|
733
|
-
`top_n=50` (or higher) and includes all projects as table rows alongside
|
|
734
|
-
the chart.
|
|
830
|
+
"""Weekly detail — per-week × per-model cross-tab (spec §9.5).
|
|
735
831
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
832
|
+
`panel_data["weeks"][i].models: dict[model_name, cost_usd]` carries
|
|
833
|
+
each week's per-model breakdown. Window-wide top-K + `Other` rollup
|
|
834
|
+
is computed at render time via `_aggregate_breakdowns` (spec §4.2).
|
|
739
835
|
"""
|
|
740
836
|
weeks = panel_data["weeks"]
|
|
741
837
|
idx = panel_data.get("current_week_index", 0)
|
|
742
838
|
w = weeks[idx]
|
|
743
839
|
start = _parse_iso_utc(w["start_date"])
|
|
744
840
|
end = start + _dt.timedelta(days=6)
|
|
745
|
-
top_n = max(int(options.get("top_n",
|
|
841
|
+
top_n = max(int(options.get("top_n", 5)), 1)
|
|
842
|
+
|
|
843
|
+
breakdowns = [dict(week.get("models") or {}) for week in weeks]
|
|
844
|
+
members = _aggregate_breakdowns(breakdowns)
|
|
845
|
+
top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
|
|
846
|
+
rows_and_breakdowns = [
|
|
847
|
+
(float(week["cost_usd"]), dict(week.get("models") or {}))
|
|
848
|
+
for week in weeks
|
|
849
|
+
]
|
|
850
|
+
has_other_residual = _detect_residual(
|
|
851
|
+
rows_and_breakdowns, top_k_labels_provisional,
|
|
852
|
+
)
|
|
853
|
+
columns, top_k, has_other = _cross_tab_columns(
|
|
854
|
+
_LS.ColumnSpec(key="week", label="Week", align="left"),
|
|
855
|
+
members, top_n, has_other_residual, kind="model",
|
|
856
|
+
)
|
|
857
|
+
rows = tuple(
|
|
858
|
+
_cross_tab_row(
|
|
859
|
+
row_label_key="week",
|
|
860
|
+
row_label_cell=_LS.TextCell(week["start_date"]),
|
|
861
|
+
row_total=float(week["cost_usd"]),
|
|
862
|
+
breakdown=dict(week.get("models") or {}),
|
|
863
|
+
top_k_labels=top_k,
|
|
864
|
+
has_other=has_other,
|
|
865
|
+
)
|
|
866
|
+
for week in weeks
|
|
867
|
+
)
|
|
746
868
|
return _LS.ShareSnapshot(
|
|
747
869
|
cmd="weekly",
|
|
748
870
|
title=f"Weekly detail — week of {w['start_date']}",
|
|
749
871
|
subtitle=None,
|
|
750
872
|
period=_period(start, end, label="This week", display_tz=_display_tz(options)),
|
|
751
|
-
columns=
|
|
752
|
-
rows=
|
|
873
|
+
columns=columns,
|
|
874
|
+
rows=rows,
|
|
753
875
|
chart=_LS.LineChart(
|
|
754
876
|
points=tuple(
|
|
755
877
|
_LS.ChartPoint(x_label=w2["start_date"], x_value=float(i),
|
|
@@ -949,25 +1071,50 @@ def _build_daily_visual(*, panel_data, options):
|
|
|
949
1071
|
|
|
950
1072
|
|
|
951
1073
|
def _build_daily_detail(*, panel_data, options):
|
|
952
|
-
"""Daily detail — per-day × per-project
|
|
1074
|
+
"""Daily detail — per-day × per-project cross-tab (spec §9.5).
|
|
953
1075
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
per-day per-project cells (see issue #33).
|
|
1076
|
+
`panel_data["days"][i].projects: dict[project_path, cost_usd]`
|
|
1077
|
+
carries each day's per-project breakdown.
|
|
957
1078
|
"""
|
|
958
1079
|
days = panel_data.get("days") or []
|
|
959
1080
|
start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
|
|
960
1081
|
end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
|
|
961
1082
|
end = end_anchor + _dt.timedelta(days=1)
|
|
962
1083
|
sum_cost = sum(float(d["cost_usd"]) for d in days)
|
|
963
|
-
top_n = max(int(options.get("top_n",
|
|
1084
|
+
top_n = max(int(options.get("top_n", 5)), 1)
|
|
1085
|
+
|
|
1086
|
+
breakdowns = [dict(d.get("projects") or {}) for d in days]
|
|
1087
|
+
members = _aggregate_breakdowns(breakdowns)
|
|
1088
|
+
top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
|
|
1089
|
+
rows_and_breakdowns = [
|
|
1090
|
+
(float(d["cost_usd"]), dict(d.get("projects") or {}))
|
|
1091
|
+
for d in days
|
|
1092
|
+
]
|
|
1093
|
+
has_other_residual = _detect_residual(
|
|
1094
|
+
rows_and_breakdowns, top_k_labels_provisional,
|
|
1095
|
+
)
|
|
1096
|
+
columns, top_k, has_other = _cross_tab_columns(
|
|
1097
|
+
_LS.ColumnSpec(key="date", label="Day", align="left"),
|
|
1098
|
+
members, top_n, has_other_residual, kind="project",
|
|
1099
|
+
)
|
|
1100
|
+
rows = tuple(
|
|
1101
|
+
_cross_tab_row(
|
|
1102
|
+
row_label_key="date",
|
|
1103
|
+
row_label_cell=_LS.TextCell(d["date"]),
|
|
1104
|
+
row_total=float(d["cost_usd"]),
|
|
1105
|
+
breakdown=dict(d.get("projects") or {}),
|
|
1106
|
+
top_k_labels=top_k,
|
|
1107
|
+
has_other=has_other,
|
|
1108
|
+
)
|
|
1109
|
+
for d in days
|
|
1110
|
+
)
|
|
964
1111
|
return _LS.ShareSnapshot(
|
|
965
1112
|
cmd="daily",
|
|
966
1113
|
title=f"Daily detail — last {len(days)} day{'s' if len(days) != 1 else ''}",
|
|
967
1114
|
subtitle=None,
|
|
968
1115
|
period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
|
|
969
|
-
columns=
|
|
970
|
-
rows=
|
|
1116
|
+
columns=columns,
|
|
1117
|
+
rows=rows,
|
|
971
1118
|
chart=_LS.BarChart(
|
|
972
1119
|
points=tuple(
|
|
973
1120
|
_LS.ChartPoint(x_label=d["date"], x_value=float(i),
|
|
@@ -1028,12 +1175,7 @@ def _build_monthly_visual(*, panel_data, options):
|
|
|
1028
1175
|
|
|
1029
1176
|
|
|
1030
1177
|
def _build_monthly_detail(*, panel_data, options):
|
|
1031
|
-
"""Monthly detail — per-month × per-
|
|
1032
|
-
|
|
1033
|
-
NOTE: ships as per-project table; spec §9.5 calls for per-month × per-project
|
|
1034
|
-
cross-tab — deferred until `_build_monthly_share_panel_data` carries
|
|
1035
|
-
per-month per-project cells (see issue #33).
|
|
1036
|
-
"""
|
|
1178
|
+
"""Monthly detail — per-month × per-model cross-tab (spec §9.5)."""
|
|
1037
1179
|
months = panel_data.get("months") or []
|
|
1038
1180
|
|
|
1039
1181
|
def _month_start(s):
|
|
@@ -1047,15 +1189,41 @@ def _build_monthly_detail(*, panel_data, options):
|
|
|
1047
1189
|
else:
|
|
1048
1190
|
end = start
|
|
1049
1191
|
sum_cost = sum(float(m["cost_usd"]) for m in months)
|
|
1050
|
-
top_n = max(int(options.get("top_n",
|
|
1192
|
+
top_n = max(int(options.get("top_n", 5)), 1)
|
|
1193
|
+
|
|
1194
|
+
breakdowns = [dict(m.get("models") or {}) for m in months]
|
|
1195
|
+
members = _aggregate_breakdowns(breakdowns)
|
|
1196
|
+
top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
|
|
1197
|
+
rows_and_breakdowns = [
|
|
1198
|
+
(float(m["cost_usd"]), dict(m.get("models") or {}))
|
|
1199
|
+
for m in months
|
|
1200
|
+
]
|
|
1201
|
+
has_other_residual = _detect_residual(
|
|
1202
|
+
rows_and_breakdowns, top_k_labels_provisional,
|
|
1203
|
+
)
|
|
1204
|
+
columns, top_k, has_other = _cross_tab_columns(
|
|
1205
|
+
_LS.ColumnSpec(key="month", label="Month", align="left"),
|
|
1206
|
+
members, top_n, has_other_residual, kind="model",
|
|
1207
|
+
)
|
|
1208
|
+
rows = tuple(
|
|
1209
|
+
_cross_tab_row(
|
|
1210
|
+
row_label_key="month",
|
|
1211
|
+
row_label_cell=_LS.TextCell(m["month"]),
|
|
1212
|
+
row_total=float(m["cost_usd"]),
|
|
1213
|
+
breakdown=dict(m.get("models") or {}),
|
|
1214
|
+
top_k_labels=top_k,
|
|
1215
|
+
has_other=has_other,
|
|
1216
|
+
)
|
|
1217
|
+
for m in months
|
|
1218
|
+
)
|
|
1051
1219
|
return _LS.ShareSnapshot(
|
|
1052
1220
|
cmd="monthly",
|
|
1053
1221
|
title=f"Monthly detail — last {len(months)} month{'s' if len(months) != 1 else ''}",
|
|
1054
1222
|
subtitle=None,
|
|
1055
1223
|
period=_period(start, end, label="Recent months",
|
|
1056
1224
|
display_tz=_display_tz(options)),
|
|
1057
|
-
columns=
|
|
1058
|
-
rows=
|
|
1225
|
+
columns=columns,
|
|
1226
|
+
rows=rows,
|
|
1059
1227
|
chart=_LS.BarChart(
|
|
1060
1228
|
points=tuple(
|
|
1061
1229
|
_LS.ChartPoint(x_label=m["month"], x_value=float(i),
|
|
@@ -1108,25 +1276,46 @@ def _build_blocks_visual(*, panel_data, options):
|
|
|
1108
1276
|
|
|
1109
1277
|
|
|
1110
1278
|
def _build_blocks_detail(*, panel_data, options):
|
|
1111
|
-
"""Blocks detail —
|
|
1112
|
-
|
|
1113
|
-
NOTE: ships as per-project table; spec §9.5 calls for per-block × per-project
|
|
1114
|
-
cross-tab — deferred until `_build_blocks_share_panel_data` carries
|
|
1115
|
-
per-block per-project cells (see issue #33).
|
|
1116
|
-
"""
|
|
1279
|
+
"""Blocks detail — per-block × per-project cross-tab (spec §9.5)."""
|
|
1117
1280
|
cb = panel_data.get("current_block") or {}
|
|
1118
1281
|
recent = panel_data.get("recent_blocks") or []
|
|
1119
1282
|
start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
|
|
1120
1283
|
end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
|
|
1121
|
-
top_n = max(int(options.get("top_n",
|
|
1284
|
+
top_n = max(int(options.get("top_n", 5)), 1)
|
|
1285
|
+
|
|
1286
|
+
breakdowns = [dict(b.get("projects") or {}) for b in recent]
|
|
1287
|
+
members = _aggregate_breakdowns(breakdowns)
|
|
1288
|
+
top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
|
|
1289
|
+
rows_and_breakdowns = [
|
|
1290
|
+
(float(b["cost_usd"]), dict(b.get("projects") or {}))
|
|
1291
|
+
for b in recent
|
|
1292
|
+
]
|
|
1293
|
+
has_other_residual = _detect_residual(
|
|
1294
|
+
rows_and_breakdowns, top_k_labels_provisional,
|
|
1295
|
+
)
|
|
1296
|
+
columns, top_k, has_other = _cross_tab_columns(
|
|
1297
|
+
_LS.ColumnSpec(key="block", label="Block (start)", align="left"),
|
|
1298
|
+
members, top_n, has_other_residual, kind="project",
|
|
1299
|
+
)
|
|
1300
|
+
rows = tuple(
|
|
1301
|
+
_cross_tab_row(
|
|
1302
|
+
row_label_key="block",
|
|
1303
|
+
row_label_cell=_LS.TextCell(b["start_at"]),
|
|
1304
|
+
row_total=float(b["cost_usd"]),
|
|
1305
|
+
breakdown=dict(b.get("projects") or {}),
|
|
1306
|
+
top_k_labels=top_k,
|
|
1307
|
+
has_other=has_other,
|
|
1308
|
+
)
|
|
1309
|
+
for b in recent
|
|
1310
|
+
)
|
|
1122
1311
|
return _LS.ShareSnapshot(
|
|
1123
1312
|
cmd="five-hour-blocks",
|
|
1124
1313
|
title="Current 5-hour block — detail",
|
|
1125
1314
|
subtitle=None,
|
|
1126
1315
|
period=_period(start, end, label="Current block",
|
|
1127
1316
|
display_tz=_display_tz(options)),
|
|
1128
|
-
columns=
|
|
1129
|
-
rows=
|
|
1317
|
+
columns=columns,
|
|
1318
|
+
rows=rows,
|
|
1130
1319
|
chart=_LS.LineChart(
|
|
1131
1320
|
points=tuple(
|
|
1132
1321
|
_LS.ChartPoint(x_label=b["start_at"], x_value=float(i),
|
|
@@ -1443,8 +1632,8 @@ _VISUAL = (
|
|
|
1443
1632
|
|
|
1444
1633
|
_DETAIL = (
|
|
1445
1634
|
ShareTemplate(id="weekly-detail", panel="weekly", label="Detail",
|
|
1446
|
-
description="Per-week × per-
|
|
1447
|
-
default_options={"top_n":
|
|
1635
|
+
description="Per-week × per-model cross-tab",
|
|
1636
|
+
default_options={"top_n": 5, "show_chart": True, "show_table": True},
|
|
1448
1637
|
builder=_build_weekly_detail),
|
|
1449
1638
|
ShareTemplate(id="current-week-detail", panel="current-week", label="Detail",
|
|
1450
1639
|
description="Per-project table + sidebar chart",
|
|
@@ -1455,16 +1644,16 @@ _DETAIL = (
|
|
|
1455
1644
|
default_options={"top_n": 50, "show_chart": True, "show_table": True},
|
|
1456
1645
|
builder=_build_trend_detail),
|
|
1457
1646
|
ShareTemplate(id="daily-detail", panel="daily", label="Detail",
|
|
1458
|
-
description="Per-day × per-project
|
|
1459
|
-
default_options={"top_n":
|
|
1647
|
+
description="Per-day × per-project cross-tab",
|
|
1648
|
+
default_options={"top_n": 5, "show_chart": True, "show_table": True},
|
|
1460
1649
|
builder=_build_daily_detail),
|
|
1461
1650
|
ShareTemplate(id="monthly-detail", panel="monthly", label="Detail",
|
|
1462
|
-
description="Per-month × per-
|
|
1463
|
-
default_options={"top_n":
|
|
1651
|
+
description="Per-month × per-model cross-tab",
|
|
1652
|
+
default_options={"top_n": 5, "show_chart": True, "show_table": True},
|
|
1464
1653
|
builder=_build_monthly_detail),
|
|
1465
1654
|
ShareTemplate(id="blocks-detail", panel="blocks", label="Detail",
|
|
1466
|
-
description="Per-block × per-project
|
|
1467
|
-
default_options={"top_n":
|
|
1655
|
+
description="Per-block × per-project cross-tab",
|
|
1656
|
+
default_options={"top_n": 5, "show_chart": True, "show_table": True},
|
|
1468
1657
|
builder=_build_blocks_detail),
|
|
1469
1658
|
ShareTemplate(id="forecast-detail", panel="forecast", label="Detail",
|
|
1470
1659
|
description="Per-day forecast table with $/% budget",
|