From bdf66d37cbc65eb8d3f2aa3e8ecf63401add6a42 Mon Sep 17 00:00:00 2001 From: Kaushik Agrawal Date: Tue, 21 Jul 2026 14:32:47 -0400 Subject: [PATCH 1/3] feat: rank users by balanced score and volume Implement combined scoring metric for balanced users list: - Balance factor: penalizes deviation from 45-55% model split - Logarithmic volume dampening: prevents massive unbalance from overshadowing perfect splits - Score = log10(total) * (balance_factor ^ 2) Update UI to display balance score and credit percentages across QualityTab, SummaryTab, TeamsTab, and UsersTab. Add comprehensive test validating score ranking with perfect vs. imbalanced users. --- backend/app/analytics.py | 20 ++++++- backend/tests/test_billing_usage.py | 82 ++++++++++++++++++++++++++ backend/tests/test_features.py | 2 +- frontend/package-lock.json | 39 ------------ frontend/src/api.ts | 1 + frontend/src/components/QualityTab.tsx | 2 +- frontend/src/components/SummaryTab.tsx | 9 ++- frontend/src/components/TeamsTab.tsx | 82 +++++++++++++------------- frontend/src/components/UsersTab.tsx | 9 +-- 9 files changed, 154 insertions(+), 92 deletions(-) diff --git a/backend/app/analytics.py b/backend/app/analytics.py index edc4f79..b63e1cf 100644 --- a/backend/app/analytics.py +++ b/backend/app/analytics.py @@ -10,6 +10,7 @@ from collections import defaultdict from datetime import UTC, datetime, timedelta from datetime import date as date_cls +import math from typing import Any from . import db @@ -683,7 +684,7 @@ def model_breakdown( "WHERE date BETWEEN ? AND ? ORDER BY date ASC", (start_iso, end_iso), ).fetchall() - + # Aggregate code from raw_json into per-editor rows. code_by_editor: dict[str, dict[str, Any]] = {} for dr in day_rows: @@ -760,7 +761,7 @@ def model_breakdown( "GROUP BY editor, model", (start_iso, end_iso, scope), ).fetchall() - + # Combine code (per-editor) and chat (per-model) rows. rows = list(code_by_editor.values()) + [ { @@ -2124,12 +2125,25 @@ def ai_credits_summary( key=lambda row: row["quantity"], reverse=True, ) + if high_pct < 45.0: + distance = 45.0 - high_pct + elif high_pct > 55.0: + distance = high_pct - 55.0 + else: + distance = 0.0 + + balance_factor = max(0.0, 1.0 - (distance / 45.0)) + # Logarithmic volume dampening prevents massive unbalance from overshadowing perfect splits, + # while squaring the balance factor penalizes imbalance more sharply. + balanced_score = math.log10(total) * (balance_factor ** 2) + balanced_users.append( { "login": login, "total_ai_credits": round(total, 2), "high_pct": round(high_pct, 2), "low_pct": round(low_pct, 2), + "balanced_score": round(balanced_score, 2), "models": [ { "model": row["model"], @@ -2141,7 +2155,7 @@ def ai_credits_summary( ], } ) - balanced_users.sort(key=lambda row: row["total_ai_credits"], reverse=True) + balanced_users.sort(key=lambda row: row["balanced_score"], reverse=True) # Headline totals from the ai_credit/usage aggregate endpoint (fresher # than per-day row sums). Only include when the stored period covers the diff --git a/backend/tests/test_billing_usage.py b/backend/tests/test_billing_usage.py index b5c8033..15c6f3b 100644 --- a/backend/tests/test_billing_usage.py +++ b/backend/tests/test_billing_usage.py @@ -453,6 +453,87 @@ def test_ai_credits_summary_returns_balanced_users() -> None: assert all(user["login"] != "high_only" for user in out["balanced_users"]) +def test_balanced_users_ranking_prioritizes_perfect_balance_and_volume() -> None: + """Balanced users are sorted by combined score of closeness to the 45-55% plateau and volume.""" + # Arrange + db.init_db() + db.replace_billing_usage( + [ + # User 1: total = 10, perfectly 50/50 + # Within plateau => distance = 0 => balance_factor = 1.0 => score = log10(10) * 1.0 = 1.00 + { + "date": "2026-06-01", + "login": "user_low_vol_perfect", + "product": "Copilot", + "sku": "copilot_ai_credit", + "quantity": 5, + "net_amount_usd": 0.20, + "model": "Claude Opus 4.6", + }, + { + "date": "2026-06-01", + "login": "user_low_vol_perfect", + "product": "Copilot", + "sku": "copilot_ai_credit", + "quantity": 5, + "net_amount_usd": 0.20, + "model": "Claude Sonnet 4.6", + }, + # User 2: total = 10000, 55/45 balance (perfect mix sample) + # Within plateau => distance = 0 => balance_factor = 1.0 => score = log10(10000) * 1.0 = 4.00 + { + "date": "2026-06-01", + "login": "user_high_vol_balanced_plateau", + "product": "Copilot", + "sku": "copilot_ai_credit", + "quantity": 5500, + "net_amount_usd": 220.00, + "model": "Claude Opus 4.6", + }, + { + "date": "2026-06-01", + "login": "user_high_vol_balanced_plateau", + "product": "Copilot", + "sku": "copilot_ai_credit", + "quantity": 4500, + "net_amount_usd": 180.00, + "model": "Claude Sonnet 4.6", + }, + # User 3: total = 10000, 40/60 balance + # Outside plateau => high_pct = 40.0 => distance = 5.0 => balance_factor = 1 - 5/45 = 8/9 => score = 4.00 * (8/9)^2 = 3.16 + { + "date": "2026-06-01", + "login": "user_high_vol_less_balanced", + "product": "Copilot", + "sku": "copilot_ai_credit", + "quantity": 4000, + "net_amount_usd": 160.00, + "model": "Claude Opus 4.6", + }, + { + "date": "2026-06-01", + "login": "user_high_vol_less_balanced", + "product": "Copilot", + "sku": "copilot_ai_credit", + "quantity": 6000, + "net_amount_usd": 240.00, + "model": "Claude Sonnet 4.6", + }, + ] + ) + + # Act + out = analytics.ai_credits_summary(start="2026-06-01", end="2026-06-01") + ranked = [u["login"] for u in out["balanced_users"]] + + # Assert + # Expected scores: + # user_high_vol_balanced_plateau: log10(10000) * 1.0 = 4.00 + # user_high_vol_less_balanced: log10(10000) * (8/9)^2 = 3.16 + # user_low_vol_perfect: log10(10) * 1.0 = 1.00 + assert ranked == ["user_high_vol_balanced_plateau", "user_high_vol_less_balanced", "user_low_vol_perfect"] + + def test_model_tier_auto_gpt54_counts_as_high() -> None: """High-tier model matches should override the generic Auto:* low-tier bucket.""" # Act + Assert @@ -614,6 +695,7 @@ def test_ai_credits_summary_includes_headline_from_meta(billing_db: None) -> Non db.set_meta("ai_credit_headline_qty", "806917.98") db.set_meta("ai_credit_headline_net_usd", "1234.98") db.set_meta("ai_credit_headline_gross_usd", "8069.18") + db.set_meta("ai_credit_headline_period", "2026-06") db.set_meta("ai_credit_headline_at", "2026-06-26T12:00:00+00:00") out = analytics.ai_credits_summary(start="2026-06-01", end="2026-06-02") diff --git a/backend/tests/test_features.py b/backend/tests/test_features.py index 333c4b5..6f79f13 100644 --- a/backend/tests/test_features.py +++ b/backend/tests/test_features.py @@ -34,7 +34,7 @@ def _seed_feature_data() -> None: def test_features_endpoint_returns_aggregated_features() -> None: """GET /api/features returns summed feature metrics across the window.""" _seed_feature_data() - resp = client.get("/api/features?days=30") + resp = client.get("/api/features?days=45") assert resp.status_code == 200 data = resp.json() assert "features" in data diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f607aa8..b73c1c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -844,9 +844,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -861,9 +858,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -878,9 +872,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -895,9 +886,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -912,9 +900,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -929,9 +914,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -946,9 +928,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -963,9 +942,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -980,9 +956,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -997,9 +970,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1014,9 +984,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1031,9 +998,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1048,9 +1012,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d0296b9..64b6aea 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -509,6 +509,7 @@ export interface AiCreditBalancedUser { total_ai_credits: number; high_pct: number; low_pct: number; + balanced_score: number; models: AiCreditBalancedModelRow[]; } diff --git a/frontend/src/components/QualityTab.tsx b/frontend/src/components/QualityTab.tsx index e8b2b3d..2f68a4e 100644 --- a/frontend/src/components/QualityTab.tsx +++ b/frontend/src/components/QualityTab.tsx @@ -253,7 +253,7 @@ export function QualityTab({ win, onWinChange }: { win: WindowState; onWinChange {data.ai_credits.top_users.map((u) => ( {u.login} - {fmtNum(u.ai_credits)} + {fmtNum(u.ai_credits)} ({fmtPct(data.ai_credits.total_ai_credits > 0 ? u.ai_credits / data.ai_credits.total_ai_credits : 0)}) {fmtMoney(u.gross_amount_usd)} {fmtMoney(u.net_amount_usd)} diff --git a/frontend/src/components/SummaryTab.tsx b/frontend/src/components/SummaryTab.tsx index 34df999..1fdfdd5 100644 --- a/frontend/src/components/SummaryTab.tsx +++ b/frontend/src/components/SummaryTab.tsx @@ -115,6 +115,7 @@ function BalancedUsersTable({ users }: { users: AiCreditBalancedUser[] }): JSX.E total: user.total_ai_credits, highPct: user.high_pct, lowPct: user.low_pct, + score: user.balanced_score, model: m.model, quantity: m.quantity, pct: m.pct, @@ -132,6 +133,7 @@ function BalancedUsersTable({ users }: { users: AiCreditBalancedUser[] }): JSX.E Total High % Low % + Balance Score Model Quantity Percentage @@ -145,6 +147,7 @@ function BalancedUsersTable({ users }: { users: AiCreditBalancedUser[] }): JSX.E {r.showUser ? {fmtNum(r.total)} : null} {r.showUser ? {r.highPct.toFixed(0)}% : null} {r.showUser ? {r.lowPct.toFixed(0)}% : null} + {r.showUser ? {fmtNum(r.score)} : null} {r.model} {fmtNum(r.quantity)} {r.pct.toFixed(2)}% @@ -256,9 +259,9 @@ export function SummaryTab({ win, onWinChange }: { win: WindowState; onWinChange sub={ state.premium && state.premium.available ? fmtMoney( - state.premium.headline_ai_credit_cost_usd - ?? state.premium.total_ai_credit_cost_usd, - ) + state.premium.headline_ai_credit_cost_usd + ?? state.premium.total_ai_credit_cost_usd, + ) : "billing API unavailable" } tooltip={ diff --git a/frontend/src/components/TeamsTab.tsx b/frontend/src/components/TeamsTab.tsx index af50b03..7ec4c74 100644 --- a/frontend/src/components/TeamsTab.tsx +++ b/frontend/src/components/TeamsTab.tsx @@ -74,48 +74,48 @@ export function TeamsTab({ win, onWinChange }: { win: WindowState; onWinChange: {error ?
{error}
: null}
-
-

Teams

-
- Derived from per-user data (seat activity + billing) rolled up by team membership. - Import a billing CSV covering this period to see AI credit attribution. -
- - - - - - - - - - - {sorted.map((t) => ( - setSelected(t.team)} - style={{ cursor: "pointer" }} - > - - - - - - ))} - {sorted.length === 0 ? ( +
+

Teams

+
+ Derived from per-user data (seat activity + billing) rolled up by team membership. + Import a billing CSV covering this period to see AI credit attribution. +
+
toggleSort("team")}>Team{sortIndicator("team")} toggleSort("members_total")}>Members{sortIndicator("members_total")} toggleSort("active_members")}>Active{sortIndicator("active_members")} toggleSort("ai_credits")}>AI Credits{sortIndicator("ai_credits")}
{t.team}{t.members_total} - {t.active_members} - {t.members_with_seats ? ` (${(t.adoption_rate * 100).toFixed(0)}%)` : ""} - {t.credit_data_available ? fmtNum(t.ai_credits) : }
+ - + + + + - ) : null} - -
- No teams synced. Check that the PAT has read:org. - toggleSort("team")}>Team{sortIndicator("team")} toggleSort("members_total")}>Members{sortIndicator("members_total")} toggleSort("active_members")}>Active{sortIndicator("active_members")} toggleSort("ai_credits")}>AI Credits{sortIndicator("ai_credits")}
-
+ + + {sorted.map((t) => ( + setSelected(t.team)} + style={{ cursor: "pointer" }} + > + {t.team} + {t.members_total} + + {t.active_members} + {t.members_with_seats ? ` (${(t.adoption_rate * 100).toFixed(0)}%)` : ""} + + {t.credit_data_available ? fmtNum(t.ai_credits) : } + + ))} + {sorted.length === 0 ? ( + + + No teams synced. Check that the PAT has read:org. + + + ) : null} + + +
{detail ? (

PR Activity — {detail.team}

@@ -619,7 +619,7 @@ export function AiCreditsTeamBlock({ data.top_users.map((u) => ( {u.login} - {fmtNum(u.ai_credits)} + {fmtNum(u.ai_credits)} ({fmtPct(data.ai_credits > 0 ? u.ai_credits / data.ai_credits : 0)}) {fmtMoney(u.net_amount_usd)} )) diff --git a/frontend/src/components/UsersTab.tsx b/frontend/src/components/UsersTab.tsx index 2e92628..39f06c3 100644 --- a/frontend/src/components/UsersTab.tsx +++ b/frontend/src/components/UsersTab.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { api, type UserDetail, type UserRow } from "../api"; import { DateRangeSelector, toWindowParams, type WindowState } from "./DateRangeSelector"; -import { fmtMoney, fmtNum, Kpi } from "./TeamsTab"; +import { fmtMoney, fmtNum, fmtPct, Kpi } from "./TeamsTab"; import { BarChart, Bar, @@ -67,6 +67,7 @@ export function UsersTab({ win, onWinChange }: { win: WindowState; onWinChange: }); const creditDataAvailable = users.some((u) => u.ai_credits > 0); + const totalCredits = users.reduce((s, u) => s + u.ai_credits, 0); function toggleSort(col: SortCol): void { if (sortCol === col) { @@ -144,7 +145,7 @@ export function UsersTab({ win, onWinChange }: { win: WindowState; onWinChange: {u.prs} {u.net_lines.toLocaleString()} - {creditDataAvailable ? fmtNum(u.ai_credits) : } + {creditDataAvailable ? `${fmtNum(u.ai_credits)} (${fmtPct(totalCredits > 0 ? u.ai_credits / totalCredits : 0)})` : } ))} @@ -415,8 +416,8 @@ function ModelUsagePanel({ detail }: { detail: UserDetail }): JSX.Element { topModelShare >= 0.8 ? "single-model" : topModelShare >= 0.5 - ? "dominant model" - : "diversified"; + ? "dominant model" + : "diversified"; return (
From f8ea3db9de6f3f43bd6a976eded0a9c590a74f3d Mon Sep 17 00:00:00 2001 From: Kaushik Agrawal Date: Fri, 7 Aug 2026 18:02:07 -0400 Subject: [PATCH 2/3] fix(frontend): prepend base URL to API requests and configure proxy base path correctly --- frontend/src/api.ts | 28 ++++++++++++++++++---------- frontend/vite.config.ts | 6 +++++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8d5cdd6..050e2db 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -546,11 +546,19 @@ export interface AiCreditsProjection { } async function getJson(path: string): Promise { - const r = await fetch(path); - if (!r.ok) throw new Error(`${path}: ${r.status} ${r.statusText}`); + // Prepend base path so API requests resolve under the same base prefix (/copilot/api/...) + const prefix = import.meta.env.BASE_URL.replace(/\/$/, ""); + const fullPath = path.startsWith("/api") ? `${prefix}${path}` : path; + const r = await fetch(fullPath); + if (!r.ok) throw new Error(`${fullPath}: ${r.status} ${r.statusText}`); return (await r.json()) as T; } +function apiPath(path: string): string { + const prefix = import.meta.env.BASE_URL.replace(/\/$/, ""); + return path.startsWith("/api") ? `${prefix}${path}` : path; +} + export const api = { kpis: (p: WindowParams = {}) => getJson(`/api/kpis${qs(p)}`), trends: (p: WindowParams = { days: 90 }) => getJson(`/api/trends${qs(p)}`), @@ -584,7 +592,7 @@ export const api = { getJson("/api/ai-credits/projection"), projections: () => getJson("/api/projections"), runSnapshot: async (): Promise => { - const r = await fetch("/api/snapshot/run", { method: "POST" }); + const r = await fetch(apiPath("/api/snapshot/run"), { method: "POST" }); if (!r.ok) { let detail = `${r.status} ${r.statusText}`; try { @@ -602,7 +610,7 @@ export const api = { body.set("file", file); const headers: Record = {}; if (token) headers["X-Admin-Token"] = token; - const r = await fetch("/api/data/import-file", { method: "POST", body, headers }); + const r = await fetch(apiPath("/api/data/import-file"), { method: "POST", body, headers }); if (!r.ok) { let detail = `${r.status} ${r.statusText}`; try { @@ -618,7 +626,7 @@ export const api = { exportData: async (token?: string): Promise => { const headers: Record = {}; if (token) headers["X-Admin-Token"] = token; - const r = await fetch("/api/data/export", { headers }); + const r = await fetch(apiPath("/api/data/export"), { headers }); if (!r.ok) throw new Error(`export failed: ${r.status} ${r.statusText}`); const blob = await r.blob(); const disposition = r.headers.get("Content-Disposition") ?? ""; @@ -638,7 +646,7 @@ export const api = { body.set("file", file); const headers: Record = {}; if (token) headers["X-Admin-Token"] = token; - const r = await fetch(`/api/data/import-db?mode=${mode}`, { method: "POST", body, headers }); + const r = await fetch(apiPath(`/api/data/import-db?mode=${mode}`), { method: "POST", body, headers }); if (!r.ok) { let detail = `${r.status} ${r.statusText}`; try { @@ -653,7 +661,7 @@ export const api = { }, listUsageReports: () => getJson("/api/usage-reports"), createUsageReport: async (req: UsageReportCreateRequest): Promise => { - const r = await fetch("/api/usage-reports", { + const r = await fetch(apiPath("/api/usage-reports"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), @@ -673,7 +681,7 @@ export const api = { getUsageReport: (reportId: string) => getJson(`/api/usage-reports/${encodeURIComponent(reportId)}`), downloadUsageReport: async (reportId: string): Promise => { - const r = await fetch(`/api/usage-reports/${encodeURIComponent(reportId)}/download`); + const r = await fetch(apiPath(`/api/usage-reports/${encodeURIComponent(reportId)}/download`)); if (!r.ok) { let detail = `${r.status} ${r.statusText}`; try { @@ -698,7 +706,7 @@ export const api = { URL.revokeObjectURL(url); }, importUsageReport: async (reportId: string): Promise => { - const r = await fetch(`/api/usage-reports/${encodeURIComponent(reportId)}/import`, { + const r = await fetch(apiPath(`/api/usage-reports/${encodeURIComponent(reportId)}/import`), { method: "POST", }); if (!r.ok) { @@ -715,7 +723,7 @@ export const api = { }, validateAdminToken: async (token: string): Promise => { try { - const r = await fetch(`/api/auth/validate-admin?token=${encodeURIComponent(token)}`); + const r = await fetch(apiPath(`/api/auth/validate-admin?token=${encodeURIComponent(token)}`)); if (!r.ok) return false; const payload = (await r.json()) as { valid?: boolean }; return payload.valid === true; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 851866c..b0da9af 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ + base: "/copilot/", plugins: [react()], server: { port: 5173, @@ -9,10 +10,13 @@ export default defineConfig({ usePolling: true, }, proxy: { - "/api": { + "/copilot/api": { target: process.env.VITE_API_TARGET || "http://localhost:8000", changeOrigin: true, + rewrite: (path) => path.replace(/^\/copilot\/api/, "/api"), }, }, + // HMR connects back through the Caddy TLS proxy rather than direct to the Vite dev port. + hmr: { protocol: "wss", host: "0.0.0.0", clientPort: 443 }, }, }); From d94ab997f581ff716c13362734407d7401410460 Mon Sep 17 00:00:00 2001 From: Kaushik Agrawal Date: Wed, 19 Aug 2026 18:12:57 -0400 Subject: [PATCH 3/3] fix: stabilize build validation --- backend/tests/test_features.py | 2 +- frontend/src/vite-env.d.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 frontend/src/vite-env.d.ts diff --git a/backend/tests/test_features.py b/backend/tests/test_features.py index 6f79f13..582e877 100644 --- a/backend/tests/test_features.py +++ b/backend/tests/test_features.py @@ -34,7 +34,7 @@ def _seed_feature_data() -> None: def test_features_endpoint_returns_aggregated_features() -> None: """GET /api/features returns summed feature metrics across the window.""" _seed_feature_data() - resp = client.get("/api/features?days=45") + resp = client.get("/api/features?start=2026-06-14&end=2026-06-15") assert resp.status_code == 200 data = resp.json() assert "features" in data diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +///