UX: Improve rough edges of AI usage page (#1014)

* UX: Improve rough edges of AI usage page

* Ensure all text uses I18n
* Change from <button> usage to <DButton>
* Use <AdminConfigAreaCard> in place of custom card styles
* Format numbers nicely using our number format helper,
  show full values on hover using title attr
* Ensure 0 is always shown for counters, instead of being blank

* FEATURE: Load usage data after page load

Use ConditionalLoadingSpinner to hide load of usage
data, this prevents us hanging on page load with a white
screen.

* UX: Split users table, and add empty placeholders and page subheader

* DEV: Test fix
This commit is contained in:
Martin Brennan 2024-12-12 07:55:24 +10:00 committed by GitHub
parent a4440c507b
commit ae80494448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 395 additions and 249 deletions

View File

@ -6,6 +6,9 @@ module DiscourseAi
requires_plugin "discourse-ai" requires_plugin "discourse-ai"
def show def show
end
def report
render json: AiUsageSerializer.new(create_report, root: false) render json: AiUsageSerializer.new(create_report, root: false)
end end

View File

@ -1,15 +1,22 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper"; import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { LinkTo } from "@ember/routing"; import { LinkTo } from "@ember/routing";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { eq } from "truth-helpers"; import { eq, gt, lt } from "truth-helpers";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import DateTimeInputRange from "discourse/components/date-time-input-range"; import DateTimeInputRange from "discourse/components/date-time-input-range";
import avatar from "discourse/helpers/avatar"; import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { number } from "discourse/lib/formatter";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import AdminPageSubheader from "admin/components/admin-page-subheader";
import Chart from "admin/components/chart"; import Chart from "admin/components/chart";
import ComboBox from "select-kit/components/combo-box"; import ComboBox from "select-kit/components/combo-box";
@ -22,18 +29,30 @@ export default class AiUsage extends Component {
@tracked selectedModel; @tracked selectedModel;
@tracked selectedPeriod = "month"; @tracked selectedPeriod = "month";
@tracked isCustomDateActive = false; @tracked isCustomDateActive = false;
@tracked loadingData = true;
constructor() {
super(...arguments);
this.fetchData();
}
@action @action
async fetchData() { async fetchData() {
const response = await ajax("/admin/plugins/discourse-ai/ai-usage.json", { const response = await ajax(
"/admin/plugins/discourse-ai/ai-usage-report.json",
{
data: { data: {
start_date: moment(this.startDate).format("YYYY-MM-DD"), start_date: moment(this.startDate).format("YYYY-MM-DD"),
end_date: moment(this.endDate).format("YYYY-MM-DD"), end_date: moment(this.endDate).format("YYYY-MM-DD"),
feature: this.selectedFeature, feature: this.selectedFeature,
model: this.selectedModel, model: this.selectedModel,
}, },
}); }
);
this.data = response; this.data = response;
this.loadingData = false;
this._cachedFeatures = null;
this._cachedModels = null;
} }
@action @action
@ -53,6 +72,11 @@ export default class AiUsage extends Component {
this.onFilterChange(); this.onFilterChange();
} }
@bind
takeUsers(start, end) {
return this.data.users.slice(start, end);
}
normalizeTimeSeriesData(data) { normalizeTimeSeriesData(data) {
if (!data?.length) { if (!data?.length) {
return []; return [];
@ -79,16 +103,16 @@ export default class AiUsage extends Component {
); );
for ( for (
let m = moment(startDate); let currentMoment = moment(startDate);
m.isSameOrBefore(endDate); currentMoment.isSameOrBefore(endDate);
m.add(1, interval) currentMoment.add(1, interval)
) { ) {
const dateKey = m.format(format); const dateKey = currentMoment.format(format);
const existingData = dataMap.get(dateKey); const existingData = dataMap.get(dateKey);
normalized.push( normalized.push(
existingData || { existingData || {
period: m.format(), period: currentMoment.format(),
total_tokens: 0, total_tokens: 0,
total_cached_tokens: 0, total_cached_tokens: 0,
total_request_tokens: 0, total_request_tokens: 0,
@ -131,19 +155,19 @@ export default class AiUsage extends Component {
}), }),
datasets: [ datasets: [
{ {
label: "Response Tokens", label: i18n("discourse_ai.usage.response_tokens"),
data: normalizedData.map((row) => row.total_response_tokens), data: normalizedData.map((row) => row.total_response_tokens),
backgroundColor: colors.response, backgroundColor: colors.response,
}, },
{ {
label: "Net Request Tokens", label: i18n("discourse_ai.usage.net_request_tokens"),
data: normalizedData.map( data: normalizedData.map(
(row) => row.total_request_tokens - row.total_cached_tokens (row) => row.total_request_tokens - row.total_cached_tokens
), ),
backgroundColor: colors.request, backgroundColor: colors.request,
}, },
{ {
label: "Cached Request Tokens", label: i18n("discourse_ai.usage.cached_request_tokens"),
data: normalizedData.map((row) => row.total_cached_tokens), data: normalizedData.map((row) => row.total_cached_tokens),
backgroundColor: colors.cached, backgroundColor: colors.cached,
}, },
@ -190,9 +214,9 @@ export default class AiUsage extends Component {
get periodOptions() { get periodOptions() {
return [ return [
{ id: "day", name: "Last 24 Hours" }, { id: "day", name: i18n("discourse_ai.usage.periods.last_day") },
{ id: "week", name: "Last Week" }, { id: "week", name: i18n("discourse_ai.usage.periods.last_week") },
{ id: "month", name: "Last Month" }, { id: "month", name: i18n("discourse_ai.usage.periods.last_month") },
]; ];
} }
@ -253,33 +277,31 @@ export default class AiUsage extends Component {
} }
<template> <template>
<div class="ai-usage"> <div class="ai-usage admin-detail">
<AdminPageSubheader
@titleLabel="discourse_ai.usage.short_title"
@learnMoreUrl="https://meta.discourse.org/t/estimating-costs-of-using-llms-for-discourse-ai/307243"
@descriptionLabel="discourse_ai.usage.subheader_description"
/>
<div class="ai-usage__filters"> <div class="ai-usage__filters">
<div class="ai-usage__filters-dates"> <div class="ai-usage__filters-dates">
<div class="ai-usage__period-buttons"> <div class="ai-usage__period-buttons">
{{#each this.periodOptions as |option|}} {{#each this.periodOptions as |option|}}
<button <DButton
type="button" class={{if
class="btn
{{if
(eq this.selectedPeriod option.id) (eq this.selectedPeriod option.id)
'btn-primary' "btn-primary"
'btn-default' "btn-default"
}}" }}
{{on "click" (fn this.onPeriodSelect option.id)}} @action={{fn this.onPeriodSelect option.id}}
> @translatedLabel={{option.name}}
{{option.name}} />
</button>
{{/each}} {{/each}}
<button <DButton
type="button" class={{if this.isCustomDateActive "btn-primary" "btn-default"}}
class="btn @action={{this.onCustomDateClick}}
{{if this.isCustomDateActive 'btn-primary' 'btn-default'}}" @label="discourse_ai.usage.periods.custom"
{{on "click" this.onCustomDateClick}} />
>
Custom...
</button>
</div> </div>
{{#if this.isCustomDateActive}} {{#if this.isCustomDateActive}}
@ -293,13 +315,7 @@ export default class AiUsage extends Component {
@showToTime={{false}} @showToTime={{false}}
/> />
<button <DButton @action={{this.onRefreshDateRange}} @label="refresh" />
type="button"
class="btn btn-default"
{{on "click" this.onRefreshDateRange}}
>
{{i18n "refresh"}}
</button>
</div> </div>
{{/if}} {{/if}}
</div> </div>
@ -322,23 +338,30 @@ export default class AiUsage extends Component {
/> />
</div> </div>
{{#if this.data}} <ConditionalLoadingSpinner @condition={{this.loadingData}}>
<div class="ai-usage__summary"> <AdminConfigAreaCard
<h3 class="ai-usage__summary-title"> @heading="discourse_ai.usage.summary"
{{i18n "discourse_ai.usage.summary"}} class="ai-usage__summary"
</h3> >
<:content>
<div class="ai-usage__summary-stats"> <div class="ai-usage__summary-stats">
<div class="ai-usage__summary-stat"> <div class="ai-usage__summary-stat">
<span class="label">{{i18n <span class="label">{{i18n
"discourse_ai.usage.total_requests" "discourse_ai.usage.total_requests"
}}</span> }}</span>
<span class="value">{{this.data.summary.total_requests}}</span> <span
class="value"
title={{this.data.summary.total_requests}}
>{{number this.data.summary.total_requests}}</span>
</div> </div>
<div class="ai-usage__summary-stat"> <div class="ai-usage__summary-stat">
<span class="label">{{i18n <span class="label">{{i18n
"discourse_ai.usage.total_tokens" "discourse_ai.usage.total_tokens"
}}</span> }}</span>
<span class="value">{{this.data.summary.total_tokens}}</span> <span
class="value"
title={{this.data.summary.total_tokens}}
>{{number this.data.summary.total_tokens}}</span>
</div> </div>
<div class="ai-usage__summary-stat"> <div class="ai-usage__summary-stat">
<span class="label">{{i18n <span class="label">{{i18n
@ -346,7 +369,8 @@ export default class AiUsage extends Component {
}}</span> }}</span>
<span <span
class="value" class="value"
>{{this.data.summary.total_request_tokens}}</span> title={{this.data.summary.total_request_tokens}}
>{{number this.data.summary.total_request_tokens}}</span>
</div> </div>
<div class="ai-usage__summary-stat"> <div class="ai-usage__summary-stat">
<span class="label">{{i18n <span class="label">{{i18n
@ -354,7 +378,8 @@ export default class AiUsage extends Component {
}}</span> }}</span>
<span <span
class="value" class="value"
>{{this.data.summary.total_response_tokens}}</span> title={{this.data.summary.total_response_tokens}}
>{{number this.data.summary.total_response_tokens}}</span>
</div> </div>
<div class="ai-usage__summary-stat"> <div class="ai-usage__summary-stat">
<span class="label">{{i18n <span class="label">{{i18n
@ -362,66 +387,40 @@ export default class AiUsage extends Component {
}}</span> }}</span>
<span <span
class="value" class="value"
>{{this.data.summary.total_cached_tokens}}</span> title={{this.data.summary.total_cached_tokens}}
</div> >{{number this.data.summary.total_cached_tokens}}</span>
</div> </div>
</div> </div>
</:content>
</AdminConfigAreaCard>
<div class="ai-usage__charts"> <AdminConfigAreaCard
class="ai-usage__charts"
@heading="discourse_ai.usage.tokens_over_time"
>
<:content>
<div class="ai-usage__chart-container"> <div class="ai-usage__chart-container">
<h3 class="ai-usage__chart-title">
{{i18n "discourse_ai.usage.tokens_over_time"}}
</h3>
<Chart <Chart
@chartConfig={{this.chartConfig}} @chartConfig={{this.chartConfig}}
class="ai-usage__chart" class="ai-usage__chart"
/> />
</div> </div>
</:content>
</AdminConfigAreaCard>
<div class="ai-usage__breakdowns"> <div class="ai-usage__breakdowns">
<AdminConfigAreaCard
<div class="ai-usage__users"> class="ai-usage__features"
<h3 class="ai-usage__users-title"> @heading="discourse_ai.usage.features_breakdown"
{{i18n "discourse_ai.usage.users_breakdown"}}
</h3>
<table class="ai-usage__users-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.usage.username"}}</th>
<th>{{i18n "discourse_ai.usage.usage_count"}}</th>
<th>{{i18n "discourse_ai.usage.total_tokens"}}</th>
</tr>
</thead>
<tbody>
{{#each this.data.users as |user|}}
<tr class="ai-usage__users-row">
<td class="ai-usage__users-cell">
<div class="user-info">
<LinkTo
@route="user"
@model={{user.username}}
class="username"
> >
{{avatar user imageSize="tiny"}} <:content>
{{user.username}} {{#unless this.data.features.length}}
</LinkTo> <AdminConfigAreaEmptyList
</div></td> @emptyLabel="discourse_ai.usage.no_features"
<td />
class="ai-usage__users-cell" {{/unless}}
>{{user.usage_count}}</td>
<td
class="ai-usage__users-cell"
>{{user.total_tokens}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<div class="ai-usage__features"> {{#if this.data.features.length}}
<h3 class="ai-usage__features-title">
{{i18n "discourse_ai.usage.features_breakdown"}}
</h3>
<table class="ai-usage__features-table"> <table class="ai-usage__features-table">
<thead> <thead>
<tr> <tr>
@ -438,20 +437,32 @@ export default class AiUsage extends Component {
>{{feature.feature_name}}</td> >{{feature.feature_name}}</td>
<td <td
class="ai-usage__features-cell" class="ai-usage__features-cell"
>{{feature.usage_count}}</td> title={{feature.usage_count}}
>{{number feature.usage_count}}</td>
<td <td
class="ai-usage__features-cell" class="ai-usage__features-cell"
>{{feature.total_tokens}}</td> title={{feature.total_tokens}}
>{{number feature.total_tokens}}</td>
</tr> </tr>
{{/each}} {{/each}}
</tbody> </tbody>
</table> </table>
</div> {{/if}}
</:content>
</AdminConfigAreaCard>
<div class="ai-usage__models"> <AdminConfigAreaCard
<h3 class="ai-usage__models-title"> class="ai-usage__models"
{{i18n "discourse_ai.usage.models_breakdown"}} @heading="discourse_ai.usage.models_breakdown"
</h3> >
<:content>
{{#unless this.data.models.length}}
<AdminConfigAreaEmptyList
@emptyLabel="discourse_ai.usage.no_models"
/>
{{/unless}}
{{#if this.data.models.length}}
<table class="ai-usage__models-table"> <table class="ai-usage__models-table">
<thead> <thead>
<tr> <tr>
@ -466,18 +477,117 @@ export default class AiUsage extends Component {
<td class="ai-usage__models-cell">{{model.llm}}</td> <td class="ai-usage__models-cell">{{model.llm}}</td>
<td <td
class="ai-usage__models-cell" class="ai-usage__models-cell"
>{{model.usage_count}}</td> title={{model.usage_count}}
>{{number model.usage_count}}</td>
<td <td
class="ai-usage__models-cell" class="ai-usage__models-cell"
>{{model.total_tokens}}</td> title={{model.total_tokens}}
>{{number model.total_tokens}}</td>
</tr> </tr>
{{/each}} {{/each}}
</tbody> </tbody>
</table> </table>
</div>
</div>
</div>
{{/if}} {{/if}}
</:content>
</AdminConfigAreaCard>
<AdminConfigAreaCard
class="ai-usage__users"
@heading="discourse_ai.usage.users_breakdown"
>
<:content>
{{#unless this.data.users.length}}
<AdminConfigAreaEmptyList
@emptyLabel="discourse_ai.usage.no_users"
/>
{{/unless}}
{{#if this.data.users.length}}
<table
class={{concatClass
"ai-usage__users-table"
(if (lt this.data.users.length 25) "-double-width")
}}
>
<thead>
<tr>
<th class="ai-usage__users-username">{{i18n
"discourse_ai.usage.username"
}}</th>
<th>{{i18n "discourse_ai.usage.usage_count"}}</th>
<th>{{i18n "discourse_ai.usage.total_tokens"}}</th>
</tr>
</thead>
<tbody>
{{#each (this.takeUsers 0 24) as |user|}}
<tr class="ai-usage__users-row">
<td class="ai-usage__users-cell">
<div class="user-info">
<LinkTo
@route="user"
@model={{user.username}}
class="username"
>
{{avatar user imageSize="tiny"}}
{{user.username}}
</LinkTo>
</div></td>
<td
class="ai-usage__users-cell"
title={{user.usage_count}}
>{{number user.usage_count}}</td>
<td
class="ai-usage__users-cell"
title={{user.total_tokens}}
>{{number user.total_tokens}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{#if (gt this.data.users.length 25)}}
<table class="ai-usage__users-table">
<thead>
<tr>
<th class="ai-usage__users-username">{{i18n
"discourse_ai.usage.username"
}}</th>
<th>{{i18n "discourse_ai.usage.usage_count"}}</th>
<th>{{i18n "discourse_ai.usage.total_tokens"}}</th>
</tr>
</thead>
<tbody>
{{#each (this.takeUsers 25 49) as |user|}}
<tr class="ai-usage__users-row">
<td class="ai-usage__users-cell">
<div class="user-info">
<LinkTo
@route="user"
@model={{user.username}}
class="username"
>
{{avatar user imageSize="tiny"}}
{{user.username}}
</LinkTo>
</div></td>
<td
class="ai-usage__users-cell"
title={{user.usage_count}}
>{{number user.usage_count}}</td>
<td
class="ai-usage__users-cell"
title={{user.total_tokens}}
>{{number user.total_tokens}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
{{/if}}
</:content>
</AdminConfigAreaCard>
</div>
</ConditionalLoadingSpinner>
</div> </div>
</div> </div>
</template> </template>

View File

@ -60,9 +60,6 @@
&__summary { &__summary {
margin: 2em 0; margin: 2em 0;
padding: 1.5em;
background: var(--primary-very-low);
border-radius: 0.5em;
} }
&__summary-title { &__summary-title {
@ -81,7 +78,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1em; padding: 1em;
background: var(--secondary); background: var(--primary-very-low);
border-radius: 0.25em; border-radius: 0.25em;
.label { .label {
@ -120,22 +117,38 @@
margin-top: 2em; margin-top: 2em;
@media (max-width: 768px) { @media (max-width: 768px) {
grid-template-columns: 1fr; grid-template-columns: none;
display: flex;
flex-direction: column;
} }
} }
&__features, &__users {
&__users, grid-column: span 2;
&__models {
background: var(--primary-very-low); .admin-config-area-card__content {
padding: 1em; display: flex;
border-radius: 0.5em;
.ai-usage__users-table {
&:first-child {
margin-right: 2em;
&.-double-width {
margin-right: 0;
}
} }
&__features-title, &.-double-width {
&__users-title, .ai-usage__users-username {
&__models-title { width: auto;
margin-bottom: 1em; }
}
.ai-usage__users-username {
width: 50px;
}
}
}
} }
&__features-table, &__features-table,

View File

@ -146,7 +146,18 @@ en:
total_requests: "Total requests" total_requests: "Total requests"
request_tokens: "Request tokens" request_tokens: "Request tokens"
response_tokens: "Response tokens" response_tokens: "Response tokens"
net_request_tokens: "Net request tokens"
cached_tokens: "Cached tokens" cached_tokens: "Cached tokens"
cached_request_tokens: "Cached request tokens"
no_users: "No user usage data found"
no_models: "No model usage data found"
no_features: "No feature usage data found"
subheader_description: "Tokens are the basic units that LLMs use to understand and generate text, usage data may affect costs."
periods:
last_day: "Last 24 hours"
last_week: "Last week"
last_month: "Last month"
custom: "Custom..."
ai_persona: ai_persona:
tool_strategies: tool_strategies:

View File

@ -79,6 +79,7 @@ Discourse::Application.routes.draw do
to: "discourse_ai/admin/rag_document_fragments#indexing_status_check" to: "discourse_ai/admin/rag_document_fragments#indexing_status_check"
get "/ai-usage", to: "discourse_ai/admin/ai_usage#show" get "/ai-usage", to: "discourse_ai/admin/ai_usage#show"
get "/ai-usage-report", to: "discourse_ai/admin/ai_usage#report"
resources :ai_llms, resources :ai_llms,
only: %i[index create show update destroy], only: %i[index create show update destroy],

View File

@ -14,33 +14,33 @@ module DiscourseAi
end end
def total_tokens def total_tokens
stats.total_tokens stats.total_tokens || 0
end end
def total_cached_tokens def total_cached_tokens
stats.total_cached_tokens stats.total_cached_tokens || 0
end end
def total_request_tokens def total_request_tokens
stats.total_request_tokens stats.total_request_tokens || 0
end end
def total_response_tokens def total_response_tokens
stats.total_response_tokens stats.total_response_tokens || 0
end end
def total_requests def total_requests
stats.total_requests stats.total_requests || 0
end end
def stats def stats
@stats ||= @stats ||=
base_query.select( base_query.select(
"COUNT(*) as total_requests", "COUNT(*) as total_requests",
"SUM(request_tokens + response_tokens) as total_tokens", "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
"SUM(request_tokens) as total_request_tokens", "SUM(COALESCE(request_tokens,0)) as total_request_tokens",
"SUM(response_tokens) as total_response_tokens", "SUM(COALESCE(response_tokens,0)) as total_response_tokens",
)[ )[
0 0
] ]
@ -66,10 +66,10 @@ module DiscourseAi
.order("DATE_TRUNC('#{period}', created_at)") .order("DATE_TRUNC('#{period}', created_at)")
.select( .select(
"DATE_TRUNC('#{period}', created_at) as period", "DATE_TRUNC('#{period}', created_at) as period",
"SUM(request_tokens + response_tokens) as total_tokens", "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
"SUM(request_tokens) as total_request_tokens", "SUM(COALESCE(request_tokens,0)) as total_request_tokens",
"SUM(response_tokens) as total_response_tokens", "SUM(COALESCE(response_tokens,0)) as total_response_tokens",
) )
end end
@ -83,10 +83,10 @@ module DiscourseAi
"users.username", "users.username",
"users.uploaded_avatar_id", "users.uploaded_avatar_id",
"COUNT(*) as usage_count", "COUNT(*) as usage_count",
"SUM(request_tokens + response_tokens) as total_tokens", "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
"SUM(request_tokens) as total_request_tokens", "SUM(COALESCE(request_tokens,0)) as total_request_tokens",
"SUM(response_tokens) as total_response_tokens", "SUM(COALESCE(response_tokens,0)) as total_response_tokens",
) )
end end
@ -97,10 +97,10 @@ module DiscourseAi
.select( .select(
"case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name", "case when coalesce(feature_name, '') = '' then '#{UNKNOWN_FEATURE}' else feature_name end as feature_name",
"COUNT(*) as usage_count", "COUNT(*) as usage_count",
"SUM(request_tokens + response_tokens) as total_tokens", "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
"SUM(request_tokens) as total_request_tokens", "SUM(COALESCE(request_tokens,0)) as total_request_tokens",
"SUM(response_tokens) as total_response_tokens", "SUM(COALESCE(response_tokens,0)) as total_response_tokens",
) )
end end
@ -111,10 +111,10 @@ module DiscourseAi
.select( .select(
"language_model as llm", "language_model as llm",
"COUNT(*) as usage_count", "COUNT(*) as usage_count",
"SUM(request_tokens + response_tokens) as total_tokens", "SUM(COALESCE(request_tokens + response_tokens, 0)) as total_tokens",
"SUM(COALESCE(cached_tokens,0)) as total_cached_tokens", "SUM(COALESCE(cached_tokens,0)) as total_cached_tokens",
"SUM(request_tokens) as total_request_tokens", "SUM(COALESCE(request_tokens,0)) as total_request_tokens",
"SUM(response_tokens) as total_response_tokens", "SUM(COALESCE(response_tokens,0)) as total_response_tokens",
) )
end end

View File

@ -5,7 +5,7 @@ require "rails_helper"
RSpec.describe DiscourseAi::Admin::AiUsageController do RSpec.describe DiscourseAi::Admin::AiUsageController do
fab!(:admin) fab!(:admin)
fab!(:user) fab!(:user)
let(:usage_path) { "/admin/plugins/discourse-ai/ai-usage.json" } let(:usage_report_path) { "/admin/plugins/discourse-ai/ai-usage-report.json" }
before { SiteSetting.discourse_ai_enabled = true } before { SiteSetting.discourse_ai_enabled = true }
@ -36,7 +36,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
end end
it "returns correct data structure" do it "returns correct data structure" do
get usage_path get usage_report_path
expect(response.status).to eq(200) expect(response.status).to eq(200)
@ -48,14 +48,18 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
end end
it "respects date filters" do it "respects date filters" do
get usage_path, params: { start_date: 3.days.ago.to_date, end_date: 1.day.ago.to_date } get usage_report_path,
params: {
start_date: 3.days.ago.to_date,
end_date: 1.day.ago.to_date,
}
json = response.parsed_body json = response.parsed_body
expect(json["summary"]["total_tokens"]).to eq(450) # sum of all tokens expect(json["summary"]["total_tokens"]).to eq(450) # sum of all tokens
end end
it "filters by feature" do it "filters by feature" do
get usage_path, params: { feature: "summarize" } get usage_report_path, params: { feature: "summarize" }
json = response.parsed_body json = response.parsed_body
@ -66,7 +70,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
end end
it "filters by model" do it "filters by model" do
get usage_path, params: { model: "gpt-3.5" } get usage_report_path, params: { model: "gpt-3.5" }
json = response.parsed_body json = response.parsed_body
models = json["models"] models = json["models"]
@ -76,10 +80,10 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
end end
it "handles different period groupings" do it "handles different period groupings" do
get usage_path, params: { period: "hour" } get usage_report_path, params: { period: "hour" }
expect(response.status).to eq(200) expect(response.status).to eq(200)
get usage_path, params: { period: "month" } get usage_report_path, params: { period: "month" }
expect(response.status).to eq(200) expect(response.status).to eq(200)
end end
end end
@ -102,7 +106,11 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
end end
it "returns hourly data when period is day" do it "returns hourly data when period is day" do
get usage_path, params: { start_date: 1.day.ago.to_date, end_date: Time.current.to_date } get usage_report_path,
params: {
start_date: 1.day.ago.to_date,
end_date: Time.current.to_date,
}
expect(response.status).to eq(200) expect(response.status).to eq(200)
json = response.parsed_body json = response.parsed_body
@ -121,7 +129,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
before { sign_in(user) } before { sign_in(user) }
it "blocks access" do it "blocks access" do
get usage_path get usage_report_path
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
end end
@ -133,7 +141,7 @@ RSpec.describe DiscourseAi::Admin::AiUsageController do
end end
it "returns error" do it "returns error" do
get usage_path get usage_report_path
expect(response.status).to eq(404) expect(response.status).to eq(404)
end end
end end