Coverage for open_webui/models/feedbacks.py: 54%
338 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1import logging
2import time
3import uuid
4from typing import Optional
6from open_webui.internal.db import Base, JSONField, get_async_db_context
7from open_webui.models.users import User, UserModel
8from pydantic import BaseModel, ConfigDict
9from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select
10from sqlalchemy.ext.asyncio import AsyncSession
12log = logging.getLogger(__name__)
15####################
16# Feedback DB Schema
17####################
20class Feedback(Base):
21 __tablename__ = 'feedback'
22 id = Column(Text, primary_key=True, unique=True)
23 user_id = Column(Text)
24 version = Column(BigInteger, default=0)
25 type = Column(Text)
26 data = Column(JSON, nullable=True)
27 meta = Column(JSON, nullable=True)
28 snapshot = Column(JSON, nullable=True)
29 created_at = Column(BigInteger)
30 updated_at = Column(BigInteger)
33class FeedbackModel(BaseModel):
34 id: str
35 user_id: str
36 version: int
37 type: str
38 data: Optional[dict] = None
39 meta: Optional[dict] = None
40 snapshot: Optional[dict] = None
41 created_at: int
42 updated_at: int
44 model_config = ConfigDict(from_attributes=True)
47####################
48# Forms
49####################
52class FeedbackResponse(BaseModel):
53 id: str
54 user_id: str
55 version: int
56 type: str
57 data: Optional[dict] = None
58 meta: Optional[dict] = None
59 created_at: int
60 updated_at: int
63class FeedbackIdResponse(BaseModel):
64 id: str
65 user_id: str
66 created_at: int
67 updated_at: int
70class LeaderboardFeedbackData(BaseModel):
71 """Minimal feedback data for leaderboard computation (excludes snapshot/meta)."""
73 id: str
74 data: Optional[dict] = None
77class RatingData(BaseModel):
78 rating: Optional[str | int] = None
79 model_id: Optional[str] = None
80 sibling_model_ids: Optional[list[str]] = None
81 reason: Optional[str] = None
82 comment: Optional[str] = None
83 model_config = ConfigDict(extra='allow', protected_namespaces=())
86class MetaData(BaseModel):
87 arena: Optional[bool] = None
88 chat_id: Optional[str] = None
89 message_id: Optional[str] = None
90 tags: Optional[list[str]] = None
91 model_config = ConfigDict(extra='allow')
94class SnapshotData(BaseModel):
95 chat: Optional[dict] = None
96 model_config = ConfigDict(extra='allow')
99class FeedbackForm(BaseModel):
100 type: str
101 data: Optional[RatingData] = None
102 meta: Optional[dict] = None
103 snapshot: Optional[SnapshotData] = None
104 # ignore: drop client-supplied id/user_id/version/timestamps at parse time.
105 model_config = ConfigDict(extra='ignore')
108class UserResponse(BaseModel):
109 id: str
110 name: str
111 email: str
112 role: str = 'pending'
114 last_active_at: int # timestamp in epoch
115 updated_at: int # timestamp in epoch
116 created_at: int # timestamp in epoch
118 model_config = ConfigDict(from_attributes=True)
121class FeedbackUserResponse(FeedbackResponse):
122 user: Optional[UserResponse] = None
125class FeedbackListResponse(BaseModel):
126 items: list[FeedbackUserResponse]
127 total: int
130class ModelHistoryEntry(BaseModel):
131 date: str
132 won: int
133 lost: int
136class ModelHistoryCounts(BaseModel):
137 date: str
138 won: int = 0
139 lost: int = 0
142class ModelHistoryResponse(BaseModel):
143 model_id: str
144 history: list[ModelHistoryEntry]
147class FeedbackTable:
148 async def insert_new_feedback(
149 self, user_id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None
150 ) -> Optional[FeedbackModel]:
151 async with get_async_db_context(db) as db:
152 id = str(uuid.uuid4())
153 # Spread form_data first so server-controlled fields win on duplicate keys.
154 feedback = FeedbackModel(
155 **{
156 **form_data.model_dump(),
157 'id': id,
158 'user_id': user_id,
159 'version': 0,
160 'created_at': int(time.time()),
161 'updated_at': int(time.time()),
162 }
163 )
164 try:
165 result = Feedback(**feedback.model_dump())
166 db.add(result)
167 await db.commit()
168 if result:
169 return FeedbackModel.model_validate(result)
170 else:
171 return None
172 except Exception as e:
173 log.exception(f'Error creating a new feedback: {e}')
174 return None
176 async def get_feedback_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FeedbackModel]:
177 try:
178 async with get_async_db_context(db) as db:
179 result = await db.execute(select(Feedback).filter_by(id=id))
180 feedback = result.scalars().first()
181 if not feedback:
182 return None
183 return FeedbackModel.model_validate(feedback)
184 except Exception:
185 return None
187 async def get_feedback_by_id_and_user_id(
188 self, id: str, user_id: str, db: Optional[AsyncSession] = None
189 ) -> Optional[FeedbackModel]:
190 try:
191 async with get_async_db_context(db) as db:
192 result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id))
193 feedback = result.scalars().first()
194 if not feedback:
195 return None
196 return FeedbackModel.model_validate(feedback)
197 except Exception:
198 return None
200 async def get_feedbacks_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
201 """Get all feedbacks for a specific chat."""
202 try:
203 async with get_async_db_context(db) as db:
204 # meta.chat_id stores the chat reference
205 result = await db.execute(
206 select(Feedback)
207 .filter(Feedback.meta['chat_id'].as_string() == chat_id)
208 .order_by(Feedback.created_at.desc())
209 )
210 feedbacks = result.scalars().all()
211 return [FeedbackModel.model_validate(fb) for fb in feedbacks]
212 except Exception:
213 return []
215 async def get_feedback_items(
216 self,
217 filter: dict = {},
218 skip: int = 0,
219 limit: int = 30,
220 db: Optional[AsyncSession] = None,
221 ) -> FeedbackListResponse:
222 async with get_async_db_context(db) as db:
223 stmt = select(Feedback, User).join(User, Feedback.user_id == User.id)
224 count_stmt = select(func.count(Feedback.id)).select_from(Feedback).join(User, Feedback.user_id == User.id)
226 if filter:
227 # Apply model_id filter (exact match)
228 model_id = filter.get('model_id')
229 if model_id:
230 model_id_filter = Feedback.data['model_id'].as_string() == model_id
231 stmt = stmt.filter(model_id_filter)
232 count_stmt = count_stmt.filter(model_id_filter)
234 order_by = filter.get('order_by')
235 direction = filter.get('direction')
237 if order_by == 'username': 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 if direction == 'asc':
239 stmt = stmt.order_by(User.name.asc())
240 else:
241 stmt = stmt.order_by(User.name.desc())
242 elif order_by == 'model_id': 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 if direction == 'asc':
244 stmt = stmt.order_by(Feedback.data['model_id'].as_string().asc())
245 else:
246 stmt = stmt.order_by(Feedback.data['model_id'].as_string().desc())
247 elif order_by == 'rating': 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 if direction == 'asc':
249 stmt = stmt.order_by(Feedback.data['rating'].as_string().asc())
250 else:
251 stmt = stmt.order_by(Feedback.data['rating'].as_string().desc())
252 elif order_by == 'updated_at': 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 if direction == 'asc':
254 stmt = stmt.order_by(Feedback.updated_at.asc())
255 else:
256 stmt = stmt.order_by(Feedback.updated_at.desc())
258 else:
259 stmt = stmt.order_by(Feedback.created_at.desc())
261 # Count before pagination without wrapping the ordered item query.
262 count_result = await db.execute(count_stmt)
263 total = count_result.scalar() or 0
265 if skip:
266 stmt = stmt.offset(skip)
267 if limit:
268 stmt = stmt.limit(limit)
270 result = await db.execute(stmt)
271 items = result.all()
273 feedbacks = []
274 for feedback, user in items:
275 feedback_model = FeedbackModel.model_validate(feedback)
276 user_model = UserResponse.model_validate(user)
277 feedbacks.append(FeedbackUserResponse(**feedback_model.model_dump(), user=user_model))
279 return FeedbackListResponse(items=feedbacks, total=total)
281 async def get_all_feedbacks(self, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
282 async with get_async_db_context(db) as db:
283 result = await db.execute(select(Feedback).order_by(Feedback.updated_at.desc()))
284 return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
286 async def get_all_feedback_ids(self, db: Optional[AsyncSession] = None) -> list[FeedbackIdResponse]:
287 async with get_async_db_context(db) as db:
288 result = await db.execute(
289 select(Feedback.id, Feedback.user_id, Feedback.created_at, Feedback.updated_at).order_by(
290 Feedback.updated_at.desc()
291 )
292 )
293 return [
294 FeedbackIdResponse(
295 id=row.id,
296 user_id=row.user_id,
297 created_at=row.created_at,
298 updated_at=row.updated_at,
299 )
300 for row in result.all()
301 ]
303 async def get_distinct_model_ids(self, db: Optional[AsyncSession] = None) -> list[str]:
304 """Get distinct model_ids from feedback data for filter dropdowns."""
305 async with get_async_db_context(db) as db:
306 result = await db.execute(
307 select(Feedback.data['model_id'].as_string())
308 .filter(Feedback.data['model_id'].as_string().isnot(None))
309 .distinct()
310 )
311 rows = result.all()
312 return sorted([row[0] for row in rows if row[0]])
314 async def get_feedbacks_for_leaderboard(self, db: Optional[AsyncSession] = None) -> list[LeaderboardFeedbackData]:
315 """Fetch only id and data for leaderboard computation (excludes snapshot/meta)."""
316 async with get_async_db_context(db) as db:
317 result = await db.execute(select(Feedback.id, Feedback.data))
318 return [LeaderboardFeedbackData(id=row.id, data=row.data) for row in result.all()]
320 async def get_model_evaluation_history(
321 self, model_id: str, days: int = 30, db: Optional[AsyncSession] = None
322 ) -> list[ModelHistoryEntry]:
323 """
324 Get daily wins/losses for a specific model over the past N days.
325 If days=0, returns all time data starting from first feedback.
326 Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
327 """
328 from collections import defaultdict
329 from datetime import datetime, timedelta
331 async with get_async_db_context(db) as db:
332 if days == 0:
333 # All time - no cutoff
334 result = await db.execute(select(Feedback.created_at, Feedback.data))
335 else:
336 cutoff = int(time.time()) - (days * 86400)
337 result = await db.execute(
338 select(Feedback.created_at, Feedback.data).filter(Feedback.created_at >= cutoff)
339 )
340 rows = result.all()
342 daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0})
343 first_date = None
345 for created_at, data in rows:
346 if not data:
347 continue
348 if data.get('model_id') != model_id: 348 ↛ 351line 348 didn't jump to line 351 because the condition on line 348 was always true
349 continue
351 rating_str = str(data.get('rating', ''))
352 if rating_str not in ('1', '-1'):
353 continue
355 date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d')
356 if rating_str == '1':
357 daily_counts[date_str]['won'] += 1
358 else:
359 daily_counts[date_str]['lost'] += 1
361 # Track first date for this model
362 if first_date is None or date_str < first_date:
363 first_date = date_str
365 # Generate date range
366 result = []
367 today = datetime.now().date()
369 if days == 0 and first_date: 369 ↛ 371line 369 didn't jump to line 371 because the condition on line 369 was never true
370 # All time: start from first feedback date
371 start_date = datetime.strptime(first_date, '%Y-%m-%d').date()
372 num_days = (today - start_date).days + 1
373 else:
374 # Fixed range
375 num_days = days
376 start_date = today - timedelta(days=days - 1)
378 for i in range(num_days):
379 d = start_date + timedelta(days=i)
380 date_str = d.strftime('%Y-%m-%d')
381 counts = daily_counts.get(date_str, {'won': 0, 'lost': 0})
382 result.append(ModelHistoryEntry(date=date_str, won=counts['won'], lost=counts['lost']))
384 return result
386 async def get_model_feedback_counts_by_day(
387 self,
388 model_id: str,
389 start_date: Optional[int] = None,
390 db: Optional[AsyncSession] = None,
391 ) -> list[ModelHistoryCounts]:
392 """Get aggregated feedback counts per day for a model, preserving all matching days."""
393 from collections import defaultdict
394 from datetime import datetime
396 async with get_async_db_context(db) as db:
397 stmt = select(Feedback.created_at, Feedback.data).filter(Feedback.data['model_id'].as_string() == model_id)
398 if start_date is not None:
399 stmt = stmt.filter(Feedback.created_at >= start_date)
401 result = await db.execute(stmt.order_by(Feedback.created_at.asc()))
402 rows = result.all()
404 daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0})
406 for created_at, data in rows: 406 ↛ 407line 406 didn't jump to line 407 because the loop on line 406 never started
407 if not data:
408 continue
410 rating_str = str(data.get('rating', ''))
411 if rating_str not in ('1', '-1'):
412 continue
414 date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d')
415 if rating_str == '1':
416 daily_counts[date_str]['won'] += 1
417 else:
418 daily_counts[date_str]['lost'] += 1
420 return [
421 ModelHistoryCounts(date=date_str, won=counts['won'], lost=counts['lost'])
422 for date_str, counts in sorted(daily_counts.items())
423 ]
425 async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
426 async with get_async_db_context(db) as db:
427 result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()))
428 return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
430 async def get_feedbacks_by_user_id(
431 self,
432 user_id: str,
433 skip: int = 0,
434 limit: int = 30,
435 db: Optional[AsyncSession] = None,
436 ) -> FeedbackListResponse:
437 async with get_async_db_context(db) as db:
438 stmt = (
439 select(Feedback, User)
440 .join(User, Feedback.user_id == User.id)
441 .filter(Feedback.user_id == user_id)
442 .order_by(Feedback.updated_at.desc())
443 )
445 count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
446 total = count_result.scalar()
448 if skip:
449 stmt = stmt.offset(skip)
450 if limit:
451 stmt = stmt.limit(limit)
453 result = await db.execute(stmt)
454 items = result.all()
456 feedbacks = []
457 for feedback, user in items:
458 feedback_model = FeedbackModel.model_validate(feedback)
459 user_model = UserResponse.model_validate(user)
460 feedbacks.append(FeedbackUserResponse(**feedback_model.model_dump(), user=user_model))
462 return FeedbackListResponse(items=feedbacks, total=total)
464 async def update_feedback_by_id(
465 self, id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None
466 ) -> Optional[FeedbackModel]:
467 async with get_async_db_context(db) as db:
468 result = await db.execute(select(Feedback).filter_by(id=id))
469 feedback = result.scalars().first()
470 if not feedback:
471 return None
473 if form_data.data:
474 feedback.data = form_data.data.model_dump()
475 if form_data.meta:
476 feedback.meta = form_data.meta
477 if form_data.snapshot:
478 feedback.snapshot = form_data.snapshot.model_dump()
480 feedback.updated_at = int(time.time())
482 await db.commit()
483 return FeedbackModel.model_validate(feedback)
485 async def update_feedback_by_id_and_user_id(
486 self,
487 id: str,
488 user_id: str,
489 form_data: FeedbackForm,
490 db: Optional[AsyncSession] = None,
491 ) -> Optional[FeedbackModel]:
492 async with get_async_db_context(db) as db:
493 result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id))
494 feedback = result.scalars().first()
495 if not feedback:
496 return None
498 if form_data.data:
499 feedback.data = form_data.data.model_dump()
500 if form_data.meta:
501 feedback.meta = form_data.meta
502 if form_data.snapshot:
503 feedback.snapshot = form_data.snapshot.model_dump()
505 feedback.updated_at = int(time.time())
507 await db.commit()
508 return FeedbackModel.model_validate(feedback)
510 async def delete_feedback_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
511 async with get_async_db_context(db) as db:
512 result = await db.execute(select(Feedback).filter_by(id=id))
513 feedback = result.scalars().first()
514 if not feedback:
515 return False
516 await db.delete(feedback)
517 await db.commit()
518 return True
520 async def delete_feedback_by_id_and_user_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool:
521 async with get_async_db_context(db) as db:
522 result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id))
523 feedback = result.scalars().first()
524 if not feedback:
525 return False
526 await db.delete(feedback)
527 await db.commit()
528 return True
530 async def delete_feedbacks_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
531 async with get_async_db_context(db) as db:
532 result = await db.execute(delete(Feedback).filter_by(user_id=user_id))
533 await db.commit()
534 return result.rowcount > 0
536 async def delete_all_feedbacks(self, db: Optional[AsyncSession] = None) -> bool:
537 async with get_async_db_context(db) as db:
538 result = await db.execute(delete(Feedback))
539 await db.commit()
540 return result.rowcount > 0
543Feedbacks = FeedbackTable()