feat: model evaluation activity chart

This commit is contained in:
Timothy Jaeryang Baek
2026-01-09 20:19:51 +04:00
parent 636ba171a4
commit 9496e8f7b5
5 changed files with 407 additions and 5 deletions

View File

@@ -127,6 +127,17 @@ class FeedbackListResponse(BaseModel):
total: int
class ModelHistoryEntry(BaseModel):
date: str
won: int
lost: int
class ModelHistoryResponse(BaseModel):
model_id: str
history: list[ModelHistoryEntry]
class FeedbackTable:
def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None
@@ -288,6 +299,73 @@ class FeedbackTable:
for row in db.query(Feedback.id, Feedback.data).all()
]
def get_model_evaluation_history(
self, model_id: str, days: int = 30, db: Optional[Session] = None
) -> list[ModelHistoryEntry]:
"""
Get daily wins/losses for a specific model over the past N days.
If days=0, returns all time data starting from first feedback.
Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
"""
from datetime import datetime, timedelta
from collections import defaultdict
with get_db_context(db) as db:
if days == 0:
# All time - no cutoff
rows = db.query(Feedback.created_at, Feedback.data).all()
else:
cutoff = int(time.time()) - (days * 86400)
rows = (
db.query(Feedback.created_at, Feedback.data)
.filter(Feedback.created_at >= cutoff)
.all()
)
daily_counts = defaultdict(lambda: {"won": 0, "lost": 0})
first_date = None
for created_at, data in rows:
if not data:
continue
if data.get("model_id") != model_id:
continue
rating_str = str(data.get("rating", ""))
if rating_str not in ("1", "-1"):
continue
date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d")
if rating_str == "1":
daily_counts[date_str]["won"] += 1
else:
daily_counts[date_str]["lost"] += 1
# Track first date for this model
if first_date is None or date_str < first_date:
first_date = date_str
# Generate date range
result = []
today = datetime.now().date()
if days == 0 and first_date:
# All time: start from first feedback date
start_date = datetime.strptime(first_date, "%Y-%m-%d").date()
num_days = (today - start_date).days + 1
else:
# Fixed range
num_days = days
start_date = today - timedelta(days=days - 1)
for i in range(num_days):
d = start_date + timedelta(days=i)
date_str = d.strftime("%Y-%m-%d")
counts = daily_counts.get(date_str, {"won": 0, "lost": 0})
result.append(ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"]))
return result
def get_feedbacks_by_type(
self, type: str, db: Optional[Session] = None
) -> list[FeedbackModel]:

View File

@@ -13,6 +13,8 @@ from open_webui.models.feedbacks import (
FeedbackUserResponse,
FeedbackListResponse,
LeaderboardFeedbackData,
ModelHistoryEntry,
ModelHistoryResponse,
Feedbacks,
)
@@ -254,6 +256,22 @@ async def get_leaderboard(
return LeaderboardResponse(entries=entries)
@router.get("/leaderboard/{model_id}/history", response_model=ModelHistoryResponse)
async def get_model_history(
model_id: str,
days: int = 30,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get daily win/loss history for a specific model."""
history = Feedbacks.get_model_evaluation_history(
model_id=model_id, days=days, db=db
)
return ModelHistoryResponse(model_id=model_id, history=history)
############################
# GetConfig
############################

View File

@@ -127,6 +127,44 @@ export const getLeaderboard = async (token: string = '', query: string = '') =>
return res;
};
export const getModelHistory = async (
token: string = '',
modelId: string,
days: number = 30
) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('days', days.toString());
const res = await fetch(
`${WEBUI_API_BASE_URL}/evaluations/leaderboard/${encodeURIComponent(modelId)}/history?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => {
let error = null;

View File

@@ -1,22 +1,63 @@
<script lang="ts">
import Modal from '$lib/components/common/Modal.svelte';
import { getContext } from 'svelte';
import { getModelHistory } from '$lib/apis/evaluations';
import ModelActivityChart from './ModelActivityChart.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
export let model = null;
export let onClose: () => void = () => {};
const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
type TimeRange = '30d' | '1y' | 'all';
const TIME_RANGES: { key: TimeRange; label: string; days: number }[] = [
{ key: '30d', label: '30D', days: 30 },
{ key: '1y', label: '1Y', days: 365 },
{ key: 'all', label: 'All', days: 0 } // 0 = all time, starts from first feedback
];
let selectedRange: TimeRange = '30d';
let history: Array<{ date: string; won: number; lost: number }> = [];
let loadingHistory = false;
const close = () => {
show = false;
onClose();
};
const loadHistory = async (days: number) => {
if (!model?.id) return;
loadingHistory = true;
try {
const result = await getModelHistory(localStorage.token, model.id, days);
history = result?.history ?? [];
} catch (err) {
console.error('Failed to load model history:', err);
history = [];
}
loadingHistory = false;
};
const selectRange = (range: TimeRange) => {
selectedRange = range;
const config = TIME_RANGES.find((r) => r.key === range);
if (config) {
loadHistory(config.days);
}
};
// Load history when model changes and modal is shown
$: if (show && model?.id) {
selectRange(selectedRange);
}
// Use top_tags from backend response (already computed)
$: topTags = model?.top_tags ?? [];
</script>
<Modal size="sm" bind:show>
<Modal size="md" bind:show>
{#if model}
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class="text-lg font-medium self-center">
@@ -27,9 +68,40 @@
</button>
</div>
<div class="px-5 pb-4 dark:text-gray-200">
<div class="mb-2">
<!-- Activity Chart -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-gray-500 font-medium uppercase tracking-wide">
{$i18n.t('Activity')}
</div>
<div class="inline-flex rounded-full bg-gray-100/80 p-0.5 dark:bg-gray-800/80 backdrop-blur-sm">
{#each TIME_RANGES as range}
<button
type="button"
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-medium {selectedRange ===
range.key
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
on:click={() => selectRange(range.key)}
>
{range.label}
</button>
{/each}
</div>
</div>
<ModelActivityChart
{history}
loading={loadingHistory}
aggregateWeekly={selectedRange === '1y' || selectedRange === 'all'}
/>
</div>
<div class="mb-4">
<div class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">
{$i18n.t('Tags')}
</div>
{#if topTags.length}
<div class="flex flex-wrap gap-1 mt-1 -mx-1">
<div class="flex flex-wrap gap-1 -mx-1">
{#each topTags as tagInfo}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
@@ -37,9 +109,10 @@
{/each}
</div>
{:else}
<span>-</span>
<span class="text-gray-500 text-sm">-</span>
{/if}
</div>
<div class="flex justify-end pt-2">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getContext } from 'svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
const i18n = getContext('i18n');
export let history: Array<{ date: string; won: number; lost: number }> = [];
export let loading = false;
export let aggregateWeekly = false;
let chartCanvas: HTMLCanvasElement;
let chartInstance: any = null;
let Chart: any = null;
const createChart = async () => {
if (!chartCanvas || !history.length) return;
// Dynamically import Chart.js
if (!Chart) {
const module = await import('chart.js/auto');
Chart = module.default;
}
// Destroy previous chart instance
if (chartInstance) {
chartInstance.destroy();
}
// For year/all views, aggregate by week
let chartData = history;
if (aggregateWeekly && history.length > 7) {
// Aggregate daily data into weekly buckets
const weeklyData: { [key: string]: { won: number; lost: number; startDate: string } } = {};
history.forEach((h) => {
const date = new Date(h.date);
// Get the Monday of this week as the bucket key
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
const monday = new Date(date);
monday.setDate(diff);
const weekKey = monday.toISOString().split('T')[0];
if (!weeklyData[weekKey]) {
weeklyData[weekKey] = { won: 0, lost: 0, startDate: weekKey };
}
weeklyData[weekKey].won += h.won;
weeklyData[weekKey].lost += h.lost;
});
chartData = Object.values(weeklyData).sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
}
const labels = chartData.map((h) => {
const date = new Date('startDate' in h ? h.startDate : h.date);
if (aggregateWeekly) {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
});
// Diverging chart: wins go UP (positive), losses go DOWN (negative)
const wonData = chartData.map((h) => h.won);
const lostData = chartData.map((h) => -h.lost); // Negative for below-zero bars
// Thicker bars for weekly aggregated data
const barPercentage = aggregateWeekly ? 0.95 : 0.9;
const categoryPercentage = aggregateWeekly ? 1.0 : 0.95;
chartInstance = new Chart(chartCanvas, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Won',
data: wonData,
backgroundColor: '#5ba3c8',
borderRadius: 2,
barPercentage,
categoryPercentage
},
{
label: 'Lost',
data: lostData,
backgroundColor: '#d97c5a',
borderRadius: 2,
barPercentage,
categoryPercentage
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false // Hide legend for cleaner look
},
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
titleColor: '#f3f4f6',
bodyColor: '#d1d5db',
borderColor: 'rgba(75, 85, 99, 0.3)',
borderWidth: 1,
padding: 8,
displayColors: true,
boxWidth: 8,
boxHeight: 8,
callbacks: {
label: function (context: any) {
const value = Math.abs(context.raw);
return `${context.dataset.label}: ${value}`;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
display: false
},
ticks: {
display: false // Hide x-axis labels for cleaner look
},
border: {
display: false
}
},
y: {
stacked: true,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawTicks: false
},
ticks: {
color: '#6b7280',
font: {
size: 10
},
padding: 8,
stepSize: 1,
precision: 0,
callback: function (value: number) {
return Math.abs(value); // Show absolute values on y-axis
}
},
border: {
display: false
}
}
},
animation: {
duration: 400,
easing: 'easeOutQuart'
}
}
});
};
$: if (chartCanvas && history.length && !loading && aggregateWeekly !== undefined) {
createChart();
}
onDestroy(() => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
});
</script>
<div class="w-full">
{#if loading}
<div class="flex items-center justify-center h-40">
<Spinner className="size-5" />
</div>
{:else if !history.length || history.every((h) => h.won === 0 && h.lost === 0)}
<div class="flex items-center justify-center h-40 text-gray-500 text-sm">
{$i18n.t('No activity data')}
</div>
{:else}
<div class="h-48">
<canvas bind:this={chartCanvas}></canvas>
</div>
{/if}
</div>