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

1import logging 

2import time 

3import uuid 

4from typing import Optional 

5 

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 

11 

12log = logging.getLogger(__name__) 

13 

14 

15#################### 

16# Feedback DB Schema 

17#################### 

18 

19 

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) 

31 

32 

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 

43 

44 model_config = ConfigDict(from_attributes=True) 

45 

46 

47#################### 

48# Forms 

49#################### 

50 

51 

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 

61 

62 

63class FeedbackIdResponse(BaseModel): 

64 id: str 

65 user_id: str 

66 created_at: int 

67 updated_at: int 

68 

69 

70class LeaderboardFeedbackData(BaseModel): 

71 """Minimal feedback data for leaderboard computation (excludes snapshot/meta).""" 

72 

73 id: str 

74 data: Optional[dict] = None 

75 

76 

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=()) 

84 

85 

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') 

92 

93 

94class SnapshotData(BaseModel): 

95 chat: Optional[dict] = None 

96 model_config = ConfigDict(extra='allow') 

97 

98 

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') 

106 

107 

108class UserResponse(BaseModel): 

109 id: str 

110 name: str 

111 email: str 

112 role: str = 'pending' 

113 

114 last_active_at: int # timestamp in epoch 

115 updated_at: int # timestamp in epoch 

116 created_at: int # timestamp in epoch 

117 

118 model_config = ConfigDict(from_attributes=True) 

119 

120 

121class FeedbackUserResponse(FeedbackResponse): 

122 user: Optional[UserResponse] = None 

123 

124 

125class FeedbackListResponse(BaseModel): 

126 items: list[FeedbackUserResponse] 

127 total: int 

128 

129 

130class ModelHistoryEntry(BaseModel): 

131 date: str 

132 won: int 

133 lost: int 

134 

135 

136class ModelHistoryCounts(BaseModel): 

137 date: str 

138 won: int = 0 

139 lost: int = 0 

140 

141 

142class ModelHistoryResponse(BaseModel): 

143 model_id: str 

144 history: list[ModelHistoryEntry] 

145 

146 

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 

175 

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 

186 

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 

199 

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 [] 

214 

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) 

225 

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) 

233 

234 order_by = filter.get('order_by') 

235 direction = filter.get('direction') 

236 

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()) 

257 

258 else: 

259 stmt = stmt.order_by(Feedback.created_at.desc()) 

260 

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 

264 

265 if skip: 

266 stmt = stmt.offset(skip) 

267 if limit: 

268 stmt = stmt.limit(limit) 

269 

270 result = await db.execute(stmt) 

271 items = result.all() 

272 

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)) 

278 

279 return FeedbackListResponse(items=feedbacks, total=total) 

280 

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()] 

285 

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 ] 

302 

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]]) 

313 

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()] 

319 

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 

330 

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() 

341 

342 daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0}) 

343 first_date = None 

344 

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 

350 

351 rating_str = str(data.get('rating', '')) 

352 if rating_str not in ('1', '-1'): 

353 continue 

354 

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 

360 

361 # Track first date for this model 

362 if first_date is None or date_str < first_date: 

363 first_date = date_str 

364 

365 # Generate date range 

366 result = [] 

367 today = datetime.now().date() 

368 

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) 

377 

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'])) 

383 

384 return result 

385 

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 

395 

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) 

400 

401 result = await db.execute(stmt.order_by(Feedback.created_at.asc())) 

402 rows = result.all() 

403 

404 daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0}) 

405 

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 

409 

410 rating_str = str(data.get('rating', '')) 

411 if rating_str not in ('1', '-1'): 

412 continue 

413 

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 

419 

420 return [ 

421 ModelHistoryCounts(date=date_str, won=counts['won'], lost=counts['lost']) 

422 for date_str, counts in sorted(daily_counts.items()) 

423 ] 

424 

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()] 

429 

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 ) 

444 

445 count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) 

446 total = count_result.scalar() 

447 

448 if skip: 

449 stmt = stmt.offset(skip) 

450 if limit: 

451 stmt = stmt.limit(limit) 

452 

453 result = await db.execute(stmt) 

454 items = result.all() 

455 

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)) 

461 

462 return FeedbackListResponse(items=feedbacks, total=total) 

463 

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 

472 

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() 

479 

480 feedback.updated_at = int(time.time()) 

481 

482 await db.commit() 

483 return FeedbackModel.model_validate(feedback) 

484 

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 

497 

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() 

504 

505 feedback.updated_at = int(time.time()) 

506 

507 await db.commit() 

508 return FeedbackModel.model_validate(feedback) 

509 

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 

519 

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 

529 

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 

535 

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 

541 

542 

543Feedbacks = FeedbackTable()