feat: model evaluation activity chart
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
############################
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
195
src/lib/components/admin/Evaluations/ModelActivityChart.svelte
Normal file
195
src/lib/components/admin/Evaluations/ModelActivityChart.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user