Coverage for src/local_deep_research/benchmarks/web_api/benchmark_service.py: 97%

501 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""Benchmark service for handling web-based benchmark execution.""" 

2 

3import hashlib 

4import json 

5import threading 

6import time 

7from datetime import UTC, datetime 

8from enum import Enum 

9from typing import Any, Dict, List, Optional, Tuple 

10 

11from loguru import logger 

12from sqlalchemy.exc import SQLAlchemyError 

13 

14from ...api.research_functions import quick_summary 

15from ...settings.manager import SnapshotSettingsContext 

16from ...web.services.research_service import _global_research_semaphore 

17from ...database.models.benchmark import ( 

18 BenchmarkResult, 

19 BenchmarkRun, 

20 BenchmarkStatus, 

21 DatasetType, 

22) 

23from ..datasets import load_dataset 

24from ..graders import extract_answer_from_response, grade_single_result 

25from ..runners import format_query 

26from ...database.thread_local_session import thread_cleanup 

27 

28# Generic failure message surfaced to API clients in place of the raw benchmark 

29# exception. ``benchmark_run.error_message`` is set from ``str(e)`` of the 

30# benchmark run (LLM calls, search engines, grading — the same stack as 

31# research) and returned by get_benchmark_status() via 

32# GET /benchmark/api/status/<id>. The raw text can carry server-level LLM 

33# endpoints / hosts / filesystem paths (settings_snapshot includes LDR_* env 

34# overrides), so it must not reach the client (CWE-209). Full detail stays in 

35# the logger.exception calls at the failure sites. 

36_GENERIC_BENCHMARK_ERROR = ( 

37 "Benchmark run failed due to an internal error. " 

38 "Check the server logs for details." 

39) 

40_RESULT_PERSISTENCE_ERROR = { 

41 "code": "database_write_failed", 

42 "message": ( 

43 "Benchmark results could not be saved. Check server logs and " 

44 "database storage, then retry." 

45 ), 

46} 

47 

48 

49class SocketIOService: 

50 """Adapter over socketio_asgi's module-level emit functions. 

51 

52 Keeps the Flask-era ``SocketIOService`` interface (which 

53 BenchmarkService and its tests are written against) while delegating 

54 to the FastAPI-native emit path. An earlier port made this a no-op 

55 stub, silently dropping every benchmark progress / completion / 

56 rate-limit WebSocket event. 

57 

58 ``owner`` is keyword-only and REQUIRED here, where the upstream 

59 ``SocketIOService.emit_to_subscribers`` takes an optional 

60 ``username=None``. Same fix, stricter contract: a benchmark id is a 

61 per-user integer, so an omitted owner silently files the event under 

62 the wrong subscriber set. Making it required turns that into a 

63 TypeError at the call site instead of a misrouted event. 

64 """ 

65 

66 def emit_to_subscribers( 

67 self, event_base, research_id, data, *, owner, enable_logging=True 

68 ): 

69 from ...web.services.socketio_asgi import emit_to_subscribers 

70 

71 return emit_to_subscribers( 

72 event_base, 

73 research_id, 

74 data, 

75 owner=owner, 

76 enable_logging=enable_logging, 

77 ) 

78 

79 def emit_to_room(self, event, data, room=None): 

80 from ...web.services.socketio_asgi import emit_socket_event 

81 

82 return emit_socket_event(event, data, room=room) 

83 

84 

85def _required_persistence_fields( 

86 result: Any, 

87) -> tuple[Any, str, DatasetType, Any, Any]: 

88 """Extract required fields or raise a data-shape error.""" 

89 if not isinstance(result, dict): 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true

90 raise TypeError("result must be a mapping") 

91 query_hash = result["query_hash"] 

92 if not isinstance(query_hash, str) or not query_hash: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 raise ValueError("query_hash must be a non-empty string") 

94 return ( 

95 result["example_id"], 

96 query_hash, 

97 DatasetType(result["dataset_type"]), 

98 result["question"], 

99 result["correct_answer"], 

100 ) 

101 

102 

103class BenchmarkTaskStatus(Enum): 

104 """Status values for benchmark tasks in the queue tracker.""" 

105 

106 QUEUED = "queued" 

107 PROCESSING = "processing" 

108 COMPLETED = "completed" 

109 FAILED = "failed" 

110 CANCELLED = "cancelled" 

111 

112 

113class BenchmarkQueueTracker: 

114 """Simple in-memory tracker for benchmark queue status. 

115 

116 This replaces the removed memory_queue functionality for benchmarks. 

117 Since benchmarks are temporary and don't need persistence, 

118 this simple in-memory solution is sufficient. 

119 

120 Thread-safe for concurrent access from multiple benchmark threads. 

121 """ 

122 

123 def __init__(self): 

124 self.tasks = {} 

125 self._lock = threading.Lock() 

126 

127 def add_task( 

128 self, task_id: str, username: str, task_type: str = "benchmark" 

129 ): 

130 """Add a new task to tracking. 

131 

132 Also performs opportunistic cleanup of old completed tasks. 

133 """ 

134 # Cleanup old tasks before adding new one (outside lock for better performance) 

135 self.cleanup_completed_tasks() 

136 

137 with self._lock: 

138 self.tasks[task_id] = { 

139 "username": username, 

140 "task_type": task_type, 

141 "status": BenchmarkTaskStatus.QUEUED.value, 

142 "created_at": datetime.now(UTC), 

143 } 

144 

145 def update_task_status(self, task_id: str, status: BenchmarkTaskStatus): 

146 """Update the status of a task.""" 

147 with self._lock: 

148 if task_id in self.tasks: 

149 self.tasks[task_id]["status"] = status.value 

150 self.tasks[task_id]["updated_at"] = datetime.now(UTC) 

151 else: 

152 logger.warning( 

153 f"Attempted to update status for non-existent task: {task_id}" 

154 ) 

155 

156 def get_task_status(self, task_id: str) -> Optional[Dict]: 

157 """Get the current status of a task.""" 

158 with self._lock: 

159 return self.tasks.get(task_id) 

160 

161 def remove_task(self, task_id: str): 

162 """Remove a task from tracking.""" 

163 with self._lock: 

164 self.tasks.pop(task_id, None) 

165 

166 def cleanup_completed_tasks(self, max_age_seconds: int = 3600): 

167 """Remove completed tasks older than max_age_seconds. 

168 

169 Args: 

170 max_age_seconds: Maximum age in seconds for completed tasks (default 1 hour) 

171 """ 

172 with self._lock: 

173 now = datetime.now(UTC) 

174 to_remove = [] 

175 for task_id, task_data in self.tasks.items(): 

176 # Only cleanup completed, failed, or cancelled tasks 

177 if task_data["status"] in [ 

178 BenchmarkTaskStatus.COMPLETED.value, 

179 BenchmarkTaskStatus.FAILED.value, 

180 BenchmarkTaskStatus.CANCELLED.value, 

181 ]: 

182 # Check if task has updated_at timestamp 

183 updated_at = task_data.get( 

184 "updated_at", task_data.get("created_at") 

185 ) 

186 if updated_at: 186 ↛ 175line 186 didn't jump to line 175 because the condition on line 186 was always true

187 age = (now - updated_at).total_seconds() 

188 if age > max_age_seconds: 

189 to_remove.append(task_id) 

190 

191 for task_id in to_remove: 

192 self.tasks.pop(task_id, None) 

193 logger.debug(f"Cleaned up old task: {task_id}") 

194 

195 if to_remove: 

196 logger.info(f"Cleaned up {len(to_remove)} old benchmark tasks") 

197 

198 

199class BenchmarkService: 

200 """Service for managing benchmark runs through the web interface.""" 

201 

202 def __init__(self, socket_service=None): 

203 # Keyed by (username, BenchmarkRun.id). BenchmarkRun.id is a PER-USER 

204 # autoincrement, so two users can each own run id 1; keying by the 

205 # owner as well prevents one user's start_benchmark from clobbering 

206 # another's in-memory run (and the cross-user reads that enabled). 

207 self.active_runs: Dict[Tuple[str, int], Dict] = {} 

208 self.socket_service = socket_service or SocketIOService() 

209 # Track rate limiting per user benchmark run. 

210 self.rate_limit_detected: Dict[Tuple[str, int], bool] = {} 

211 self.queue_tracker = BenchmarkQueueTracker() # Initialize queue tracker 

212 # Serializes benchmark-result persistence. _sync_results_to_database 

213 # runs on the worker thread while sync_pending_results runs on the 

214 # request thread; without this they can both INSERT the same 

215 # (benchmark_run_id, query_hash) row and trip the uix_run_query 

216 # unique constraint, which rolls back the whole pending batch. 

217 self._results_sync_lock = threading.Lock() 

218 

219 def generate_config_hash(self, search_config: Dict[str, Any]) -> str: 

220 """Generate a hash for search configuration compatibility checking.""" 

221 relevant_params = { 

222 "iterations": search_config.get("iterations"), 

223 "questions_per_iteration": search_config.get( 

224 "questions_per_iteration" 

225 ), 

226 "search_tool": search_config.get("search_tool"), 

227 "search_strategy": search_config.get("search_strategy"), 

228 "model_name": search_config.get("model_name"), 

229 "provider": search_config.get("provider"), 

230 } 

231 # Remove None values 

232 relevant_params = { 

233 k: v for k, v in relevant_params.items() if v is not None 

234 } 

235 config_str = json.dumps(relevant_params, sort_keys=True) 

236 return hashlib.md5( # DevSkim: ignore DS126858 

237 config_str.encode(), usedforsecurity=False 

238 ).hexdigest()[:8] 

239 

240 def generate_query_hash( 

241 self, question: str, dataset_type: str, example_id: str 

242 ) -> str: 

243 """Generate a hash for a query to enable deduplication. 

244 

245 The example id is part of the key so two distinct examples that share 

246 the same question text (and dataset) get distinct hashes and each 

247 persist a row, instead of colliding on the ``uix_run_query`` unique 

248 constraint and dropping one. Re-hashing the same example is stable, so 

249 the sync dedup still collapses a genuine re-sync of that example. 

250 """ 

251 query_content = ( 

252 f"{example_id}|{question.strip()}|{dataset_type.lower()}" 

253 ) 

254 return hashlib.md5( # DevSkim: ignore DS126858 

255 query_content.encode(), usedforsecurity=False 

256 ).hexdigest() 

257 

258 def create_benchmark_run( 

259 self, 

260 run_name: Optional[str], 

261 search_config: Dict[str, Any], 

262 evaluation_config: Dict[str, Any], 

263 datasets_config: Dict[str, Dict], 

264 username: Optional[str] = None, 

265 user_password: Optional[str] = None, 

266 ) -> int: 

267 """Create a new benchmark run in the database.""" 

268 from ...database.session_context import get_user_db_session 

269 

270 with get_user_db_session(username, user_password) as session: 

271 try: 

272 config_hash = self.generate_config_hash(search_config) 

273 

274 # Calculate total examples 

275 total_examples = sum( 

276 config.get("count", 0) 

277 for config in datasets_config.values() 

278 ) 

279 

280 benchmark_run = BenchmarkRun( 

281 run_name=run_name, 

282 config_hash=config_hash, 

283 query_hash_list=[], # Will be populated as we process 

284 search_config=search_config, 

285 evaluation_config=evaluation_config, 

286 datasets_config=datasets_config, 

287 total_examples=total_examples, 

288 status=BenchmarkStatus.PENDING, 

289 ) 

290 

291 session.add(benchmark_run) 

292 session.commit() 

293 

294 logger.info( 

295 f"Created benchmark run {benchmark_run.id} with config hash {config_hash}" 

296 ) 

297 return int(benchmark_run.id) 

298 

299 except Exception: 

300 session.rollback() 

301 logger.exception("Error creating benchmark run") 

302 raise 

303 

304 def start_benchmark( 

305 self, 

306 benchmark_run_id: int, 

307 username: Optional[str] = None, 

308 user_password: Optional[str] = None, 

309 ) -> bool: 

310 """Start a benchmark run in a background thread.""" 

311 from ...database.session_context import get_user_db_session 

312 

313 try: 

314 # Get all data from the database in the main thread 

315 # This avoids database access from the background thread 

316 with get_user_db_session(username, user_password) as session: 

317 # Get benchmark run details 

318 benchmark_run = ( 

319 session.query(BenchmarkRun) 

320 .filter(BenchmarkRun.id == benchmark_run_id) 

321 .first() 

322 ) 

323 if not benchmark_run: 

324 raise ValueError( # noqa: TRY301 — caught by except, sets FAILED status in DB 

325 f"Benchmark run {benchmark_run_id} not found" 

326 ) 

327 

328 # Create settings snapshot for thread safety + provenance. 

329 # The settings snapshot is BEST-EFFORT provenance, not a 

330 # critical path: a recoverable read failure must not break 

331 # the user's ability to start a benchmark. We catch only the 

332 # failures that can legitimately escape get_all_settings: 

333 # SQLAlchemyError (transient DB issues) and LookupError / 

334 # ValueError (a stale enum-typed settings row, e.g. a legacy 

335 # 'CHAT' value that fails enum coercion). Those degrade to an 

336 # empty snapshot — ldr_version is still recorded. Anything 

337 # else (a genuine programming error) is deliberately NOT 

338 # swallowed here: it propagates to the outer handler, which 

339 # logs a full traceback and marks the run FAILED — the right 

340 # outcome, since a settings subsystem that breaks unexpectedly 

341 # would also corrupt the benchmark's own config reads. 

342 from local_deep_research.settings import SettingsManager 

343 

344 settings_manager = SettingsManager(session) 

345 try: 

346 settings_snapshot = settings_manager.get_all_settings() 

347 except (SQLAlchemyError, LookupError, ValueError): 

348 # Use logger.exception so the traceback goes to stderr 

349 # without splatting `repr(exc)` into the message body — 

350 # exception details may include user data. 

351 logger.exception( 

352 "Failed to capture settings snapshot for benchmark " 

353 f"{benchmark_run_id}; running with empty snapshot." 

354 ) 

355 settings_snapshot = {} 

356 

357 # Get user password for metrics tracking in background thread 

358 from ...utilities.request_context import ( 

359 get_current_session_id, 

360 ) 

361 from ...database.session_passwords import session_password_store 

362 

363 _user_password = None 

364 session_id = get_current_session_id() 

365 if session_id and username: 

366 _user_password = ( 

367 session_password_store.get_session_password( 

368 username, session_id 

369 ) 

370 ) 

371 if not _user_password: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 logger.warning( 

373 f"No password found for user {username} in current session" 

374 ) 

375 

376 # Extract all data we need 

377 benchmark_data = { 

378 "benchmark_run_id": benchmark_run_id, 

379 "username": username or "benchmark_user", 

380 "user_password": _user_password, # Add password for metrics tracking 

381 "config_hash": benchmark_run.config_hash, 

382 "datasets_config": benchmark_run.datasets_config, 

383 "search_config": benchmark_run.search_config, 

384 "evaluation_config": benchmark_run.evaluation_config, 

385 "settings_snapshot": settings_snapshot, # Add settings snapshot 

386 } 

387 

388 # Update status in database. Persist provenance (ldr_version 

389 # + redacted settings_snapshot) BEFORE the commit so the 

390 # YAML download can later reproduce the run; the snapshot 

391 # already exists in memory from line 306, we just store the 

392 # redacted form so secrets don't sit in the DB even though 

393 # SQLCipher protects at rest. 

394 from local_deep_research import __version__ 

395 from local_deep_research.security.data_sanitizer import ( 

396 DataSanitizer, 

397 ) 

398 

399 benchmark_run.ldr_version = __version__ 

400 benchmark_run.settings_snapshot = ( 

401 DataSanitizer.redact_settings_snapshot(settings_snapshot) 

402 ) 

403 benchmark_run.status = BenchmarkStatus.IN_PROGRESS 

404 benchmark_run.start_time = datetime.now(UTC) 

405 session.commit() 

406 

407 # Store data in memory for the thread. Key by (username, id) so a 

408 # run another user owns under the same per-user id can't overwrite 

409 # this entry (see _run_key). 

410 run_key = self._run_key(username, benchmark_run_id) 

411 self.active_runs[run_key] = { 

412 "data": benchmark_data, 

413 "start_time": datetime.now(UTC), 

414 "status": "running", 

415 "results": [], 

416 } 

417 

418 # Start background thread. Pass the owner so the thread rebuilds the 

419 # same composite key for its own active_runs accesses. 

420 thread = threading.Thread( 

421 target=self._run_benchmark_thread, 

422 args=(username, benchmark_run_id), 

423 daemon=True, 

424 ) 

425 thread.start() 

426 

427 self.active_runs[run_key]["thread"] = thread 

428 

429 logger.info(f"Started benchmark run {benchmark_run_id}") 

430 return True 

431 

432 except Exception as e: 

433 logger.exception(f"Error starting benchmark {benchmark_run_id}") 

434 # If we populated active_runs before the spawn failed, drop the 

435 # stale entry — it has no thread and would mislead subsequent 

436 # cancel_benchmark / get_run_status calls. Recompute the key here 

437 # (the local run_key may not exist yet if we failed before it). 

438 self.active_runs.pop( 

439 self._run_key(username, benchmark_run_id), None 

440 ) 

441 # Update status using user database 

442 with get_user_db_session(username, user_password) as session: 

443 benchmark_run = ( 

444 session.query(BenchmarkRun) 

445 .filter(BenchmarkRun.id == benchmark_run_id) 

446 .first() 

447 ) 

448 if benchmark_run: 

449 benchmark_run.status = BenchmarkStatus.FAILED 

450 benchmark_run.error_message = str(e) 

451 session.commit() 

452 return False 

453 

454 @thread_cleanup 

455 def _run_benchmark_thread( 

456 self, username: Optional[str], benchmark_run_id: int 

457 ): 

458 """Main benchmark execution thread.""" 

459 # IMPORTANT: This runs in a background thread, so we cannot access the user database 

460 # Using in-memory queue tracker for benchmark status tracking 

461 

462 task_id = None 

463 # Rebuild the same composite key start_benchmark stored this run under. 

464 run_key = self._run_key(username, benchmark_run_id) 

465 

466 # Get the benchmark data that was passed to us 

467 # We need to retrieve this from the service database or from memory 

468 benchmark_data = self.active_runs.get(run_key, {}).get("data") 

469 

470 try: 

471 if not benchmark_data: 

472 raise ValueError( # noqa: TRY301 

473 f"Benchmark data for run {benchmark_run_id} not found" 

474 ) 

475 # Set up settings context for thread-local access 

476 settings_snapshot = benchmark_data.get("settings_snapshot", {}) 

477 username = benchmark_data.get("username", "benchmark_user") 

478 

479 # Create a settings context that threads can use 

480 settings_context = SnapshotSettingsContext( 

481 settings_snapshot, 

482 username=username, 

483 missing_key_log_level="WARNING", 

484 ) 

485 

486 # Set the context in thread-local storage 

487 from ...config.thread_settings import set_settings_context 

488 

489 set_settings_context(settings_context) 

490 

491 # Extract all the data we need 

492 datasets_config = benchmark_data["datasets_config"] 

493 search_config = benchmark_data["search_config"] 

494 evaluation_config = benchmark_data["evaluation_config"] 

495 

496 # Create task queue 

497 task_queue = self._create_task_queue( 

498 datasets_config, 

499 benchmark_run_id, 

500 ) 

501 

502 # Calculate totals 

503 total_examples = len(task_queue) 

504 completed_examples = 0 

505 

506 # Initialize task tracking 

507 task_id = f"benchmark_{benchmark_run_id}_{int(datetime.now(UTC).timestamp())}" 

508 username = benchmark_data.get("username", "benchmark_user") 

509 self.queue_tracker.add_task(task_id, username, "benchmark") 

510 self.queue_tracker.update_task_status( 

511 task_id, BenchmarkTaskStatus.PROCESSING 

512 ) 

513 

514 # Track progress in memory 

515 progress_info = { 

516 "total_examples": total_examples, 

517 "completed_examples": completed_examples, 

518 "failed_examples": 0, 

519 "start_time": datetime.now(UTC), 

520 } 

521 

522 # Process tasks 

523 logger.info( 

524 f"Benchmark {benchmark_run_id} starting to process {len(task_queue)} tasks" 

525 ) 

526 for i, task in enumerate(task_queue): 

527 # Check if benchmark has been cancelled 

528 if ( 

529 run_key in self.active_runs 

530 and self.active_runs[run_key].get("status") == "cancelled" 

531 ): 

532 logger.info( 

533 f"Benchmark {benchmark_run_id} was cancelled, stopping processing" 

534 ) 

535 break 

536 

537 logger.info( 

538 f"Benchmark {benchmark_run_id} processing task {i + 1}/{len(task_queue)}" 

539 ) 

540 try: 

541 # Add username and password to task for metrics tracking 

542 task["username"] = benchmark_data.get("username") 

543 task["user_password"] = benchmark_data.get("user_password") 

544 

545 # Acquire the global research semaphore so benchmark 

546 # tasks count against the server-wide concurrency limit 

547 _global_research_semaphore.acquire() 

548 try: 

549 # Process single task 

550 result = self._process_benchmark_task( 

551 task, 

552 search_config, 

553 evaluation_config, 

554 ) 

555 finally: 

556 _global_research_semaphore.release() 

557 

558 # Store result in memory for now (will be saved later) 

559 if "results" not in self.active_runs[run_key]: 559 ↛ 560line 559 didn't jump to line 560 because the condition on line 559 was never true

560 self.active_runs[run_key]["results"] = [] 

561 self.active_runs[run_key]["results"].append(result) 

562 

563 # Update progress 

564 progress_info["completed_examples"] += 1 

565 

566 logger.info( 

567 f"Benchmark {benchmark_run_id} task {i + 1}/{len(task_queue)} completed successfully. " 

568 f"Progress: {progress_info['completed_examples']}/{progress_info['total_examples']} total examples" 

569 ) 

570 

571 # Send real-time update 

572 self._send_progress_update( 

573 benchmark_run_id, 

574 progress_info["completed_examples"], 

575 progress_info["total_examples"], 

576 username=username, 

577 ) 

578 

579 except Exception as e: 

580 logger.exception(f"Error processing task {i}") 

581 progress_info["failed_examples"] += 1 

582 logger.info( 

583 f"Benchmark {benchmark_run_id} task {i + 1}/{len(task_queue)} failed. " 

584 f"Total failed: {progress_info['failed_examples']}" 

585 ) 

586 

587 # Check if this is a rate limiting error 

588 error_str = str(e).lower() 

589 if ( 

590 "403" in error_str 

591 or "rate limit" in error_str 

592 or "forbidden" in error_str 

593 ): 

594 self.rate_limit_detected[run_key] = True 

595 # Send rate limit warning via WebSocket. Pass the 

596 # run's owner so the socket service files this under 

597 # the right per-user subscriber set -- benchmark_run_id 

598 # is a per-user integer, so two users can share the 

599 # same id (see SocketIOService.__subscription_key). 

600 self.socket_service.emit_to_subscribers( 

601 "research_progress", 

602 benchmark_run_id, 

603 { 

604 "rate_limit_detected": True, 

605 "message": "SearXNG rate limiting detected", 

606 }, 

607 owner=username, 

608 ) 

609 

610 # Mark as completed in memory tracker 

611 progress_info["end_time"] = datetime.now(UTC) 

612 

613 # Check if benchmark was cancelled 

614 was_cancelled = ( 

615 run_key in self.active_runs 

616 and self.active_runs[run_key].get("status") == "cancelled" 

617 ) 

618 

619 if was_cancelled: 

620 status = BenchmarkStatus.CANCELLED 

621 message = "Benchmark cancelled by user" 

622 if task_id: 622 ↛ 635line 622 didn't jump to line 635 because the condition on line 622 was always true

623 self.queue_tracker.update_task_status( 

624 task_id, BenchmarkTaskStatus.CANCELLED 

625 ) 

626 else: 

627 status = BenchmarkStatus.COMPLETED 

628 message = "Benchmark completed successfully" 

629 if task_id: 629 ↛ 635line 629 didn't jump to line 635 because the condition on line 629 was always true

630 self.queue_tracker.update_task_status( 

631 task_id, BenchmarkTaskStatus.COMPLETED 

632 ) 

633 

634 # Store completion info for later database update 

635 self.active_runs[run_key]["completion_info"] = { 

636 "status": status, 

637 "end_time": progress_info["end_time"], 

638 "completed_examples": progress_info["completed_examples"], 

639 "failed_examples": progress_info["failed_examples"], 

640 } 

641 

642 # Send completion notification. Pass the owner so this reaches 

643 # only their subscriber set (see rate-limit emit above for why). 

644 self.socket_service.emit_to_subscribers( 

645 "research_progress", 

646 benchmark_run_id, 

647 { 

648 "status": "cancelled" if was_cancelled else "completed", 

649 "message": message, 

650 "progress": ( 

651 progress_info["completed_examples"] 

652 / progress_info["total_examples"] 

653 * 100 

654 ) 

655 if progress_info["total_examples"] > 0 

656 else 0, 

657 "benchmark_run_id": benchmark_run_id, 

658 }, 

659 owner=username, 

660 ) 

661 

662 except Exception as e: 

663 logger.exception(f"Benchmark run {benchmark_run_id} failed") 

664 # Update task status if we have a task_id 

665 if task_id: 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true

666 self.queue_tracker.update_task_status( 

667 task_id, BenchmarkTaskStatus.FAILED 

668 ) 

669 # Store failure info for later database update 

670 if run_key in self.active_runs: 670 ↛ 677line 670 didn't jump to line 677 because the condition on line 670 was always true

671 self.active_runs[run_key]["completion_info"] = { 

672 "status": BenchmarkStatus.FAILED, 

673 "error_message": str(e), 

674 } 

675 finally: 

676 # Clean up active run tracking 

677 if run_key in self.active_runs: 677 ↛ exitline 677 didn't return from function '_run_benchmark_thread' because the condition on line 677 was always true

678 # Mark that thread is done but keep data for database update 

679 self.active_runs[run_key]["thread_complete"] = True 

680 

681 # Try to save results to database immediately if possible 

682 self._sync_results_to_database(username, benchmark_run_id) 

683 

684 def _create_task_queue( 

685 self, 

686 datasets_config: Dict, 

687 benchmark_run_id: int, 

688 ) -> List[Dict]: 

689 """Create list of tasks to process. 

690 

691 Each dataset config may carry a "seed" for reproducible sampling: 

692 the same seed and count select the same questions on every run, 

693 keeping accuracy comparable across runs. Without a seed the sample 

694 is random each time. 

695 """ 

696 tasks: List[Dict[str, Any]] = [] 

697 

698 for dataset_name, config in datasets_config.items(): 

699 if config.get("count", 0) > 0: 

700 dataset = load_dataset( 

701 dataset_type=dataset_name, 

702 num_examples=config["count"], 

703 seed=config.get("seed"), 

704 ) 

705 

706 for i, example in enumerate(dataset): 

707 # All registered datasets expose "problem"/"answer" 

708 question = example.get("problem", "") 

709 correct_answer = example.get("answer", "") 

710 example_id = example.get("id", f"example_{i}") 

711 

712 # Generate query hash (keyed on the example so distinct 

713 # examples with identical question text don't collide) 

714 query_hash = self.generate_query_hash( 

715 question, dataset_name, example_id 

716 ) 

717 

718 tasks.append( 

719 { 

720 "benchmark_run_id": benchmark_run_id, 

721 "example_id": example_id, 

722 "dataset_type": dataset_name, 

723 "question": question, 

724 "correct_answer": correct_answer, 

725 "query_hash": query_hash, 

726 "task_index": len(tasks), 

727 } 

728 ) 

729 

730 return tasks 

731 

732 def _process_benchmark_task( 

733 self, task: Dict, search_config: Dict, evaluation_config: Dict 

734 ) -> Dict: 

735 """Process a single benchmark task.""" 

736 try: 

737 logger.info( 

738 f"Starting benchmark task {task['task_index'] + 1}: " 

739 f"example_id={task['example_id']}, dataset={task['dataset_type']}, " 

740 f"question_preview='{task['question'][:100]}...'" 

741 ) 

742 

743 # Get settings context from thread-local storage 

744 from ...config.thread_settings import get_settings_context 

745 

746 settings_context = get_settings_context() 

747 

748 # Generate a unique tracking ID for this benchmark task 

749 import uuid 

750 

751 tracking_id = str(uuid.uuid4()) 

752 logger.info( 

753 f"Task {task['example_id']} assigned tracking_id: {tracking_id}" 

754 ) 

755 

756 # Format query 

757 formatted_query = format_query( 

758 task["question"], task["dataset_type"] 

759 ) 

760 logger.info( 

761 f"Task {task['example_id']} formatted query: '{formatted_query[:150]}...'" 

762 ) 

763 

764 # Run research with progress callback for WebSocket updates 

765 start_time = time.time() 

766 logger.info(f"Task {task['example_id']} starting research phase...") 

767 

768 def benchmark_progress_callback( 

769 status: str, progress: int, data: dict 

770 ): 

771 """Progress callback to emit detailed research progress via WebSocket""" 

772 try: 

773 timestamp = datetime.now(UTC).isoformat() 

774 

775 # Create research-compatible log entry 

776 log_entry = { 

777 "time": timestamp, 

778 "message": f"Example {task['example_id']}: {status}", 

779 "progress": progress, 

780 "metadata": { 

781 "phase": data.get("phase", "benchmark_processing"), 

782 "type": data.get("type", "info"), 

783 "example_id": task["example_id"], 

784 "benchmark_run_id": task["benchmark_run_id"], 

785 **data, # Include all other data 

786 }, 

787 } 

788 

789 # Determine log type based on status/message content 

790 if ( 

791 "complete" in status.lower() 

792 or "finished" in status.lower() 

793 ): 

794 log_entry["metadata"]["type"] = "milestone" 

795 elif ( 

796 "error" in status.lower() or "failed" in status.lower() 

797 ): 

798 log_entry["metadata"]["type"] = "error" 

799 elif ( 

800 "starting" in status.lower() 

801 or "begin" in status.lower() 

802 ): 

803 log_entry["metadata"]["type"] = "milestone" 

804 

805 # Create progress data in research format 

806 progress_data = { 

807 "progress": progress, 

808 "message": status, 

809 "status": "in_progress", 

810 "log_entry": log_entry, 

811 "progress_log": json.dumps( 

812 [log_entry] 

813 ), # Array format expected by socket.js 

814 } 

815 

816 # Emit using research_progress format that the UI expects. 

817 # Pass the owner (set on task before this callback's 

818 # enclosing call, see _run_benchmark_thread) so this 

819 # reaches only their subscriber set -- benchmark_run_id 

820 # is a per-user integer and can collide across users. 

821 self.socket_service.emit_to_subscribers( 

822 "research_progress", 

823 task["benchmark_run_id"], 

824 progress_data, 

825 owner=task.get("username"), 

826 ) 

827 

828 except Exception: 

829 logger.exception("Error sending benchmark progress update") 

830 

831 # Get user password from task data 

832 user_password = task.get("user_password") 

833 

834 search_result = quick_summary( 

835 query=formatted_query, 

836 research_id=tracking_id, # Pass the tracking ID 

837 iterations=search_config.get("iterations", 8), 

838 questions_per_iteration=search_config.get( 

839 "questions_per_iteration", 5 

840 ), 

841 search_tool=search_config.get("search_tool", "searxng"), 

842 search_strategy=search_config.get( 

843 "search_strategy", "focused_iteration" 

844 ), 

845 progress_callback=benchmark_progress_callback, 

846 model_name=search_config.get("model_name"), 

847 provider=search_config.get("provider"), 

848 temperature=search_config.get("temperature", 0.7), 

849 openai_endpoint_url=search_config.get("openai_endpoint_url"), 

850 settings_snapshot=settings_context.snapshot, # Pass settings snapshot for thread safety 

851 username=task.get("username"), # Pass username 

852 user_password=user_password, # Pass password for metrics tracking 

853 # The web benchmark runs against the user's encrypted DB 

854 # (it has username/password and wants search metrics 

855 # persisted). quick_summary's default is programmatic_mode=True 

856 # for true library callers; override here so the engine's 

857 # metrics path stays active. 

858 programmatic_mode=False, 

859 ) 

860 processing_time = time.time() - start_time 

861 logger.info( 

862 f"Task {task['example_id']} research completed in {processing_time:.2f}s, " 

863 f"model={search_config.get('model_name')}, provider={search_config.get('provider')}" 

864 ) 

865 

866 # Extract answer 

867 response = search_result.get("summary", "") 

868 logger.info( 

869 f"Task {task['example_id']} response length: {len(response)} chars" 

870 ) 

871 

872 extracted_data = extract_answer_from_response( 

873 response, task["dataset_type"] 

874 ) 

875 extracted_answer = ( 

876 extracted_data.get("extracted_answer", "") 

877 if isinstance(extracted_data, dict) 

878 else str(extracted_data) 

879 ) 

880 logger.info( 

881 f"Task {task['example_id']} extracted answer: '{extracted_answer[:100]}...'" 

882 ) 

883 

884 # Extract sources - handle both direct sources and all_links_of_system 

885 sources = search_result.get("sources", []) 

886 if not sources and "all_links_of_system" in search_result: 

887 sources = search_result.get("all_links_of_system", []) 

888 

889 # Log for debugging 

890 logger.debug(f"Search result keys: {list(search_result.keys())}") 

891 logger.debug(f"Sources found: {len(sources)} items") 

892 

893 # Prepare result 

894 result = { 

895 **task, 

896 "response": response, 

897 "extracted_answer": extracted_answer, 

898 "confidence": str( 

899 extracted_data.get("confidence", "100") 

900 if isinstance(extracted_data, dict) 

901 else "100" 

902 ), 

903 "processing_time": processing_time, 

904 "sources": json.dumps(sources), # Convert to JSON string 

905 "completed_at": datetime.now(UTC), 

906 "research_id": tracking_id, # Store the UUID in the research_id field 

907 } 

908 

909 # Evaluate result - requires proper grading model 

910 try: 

911 logger.info(f"Task {task['example_id']} starting evaluation...") 

912 eval_start_time = time.time() 

913 

914 # Always attempt evaluation, regardless of provider 

915 # Modern local models like Ollama are capable of grading 

916 # Try to evaluate with proper model 

917 result_data = { 

918 "id": task["example_id"], 

919 "problem": task["question"], 

920 "correct_answer": task["correct_answer"], 

921 "response": response, 

922 "extracted_answer": extracted_answer, 

923 } 

924 

925 eval_result = grade_single_result( 

926 result_data, 

927 task["dataset_type"], 

928 evaluation_config, 

929 settings_context.snapshot, 

930 ) 

931 eval_time = time.time() - eval_start_time 

932 logger.info( 

933 f"Task {task['example_id']} evaluation completed in {eval_time:.2f}s" 

934 ) 

935 if eval_result and not eval_result.get("grading_error"): 

936 result.update( 

937 { 

938 "is_correct": eval_result.get("is_correct", False), 

939 "graded_confidence": eval_result.get( 

940 "graded_confidence", "0" 

941 ), 

942 "grader_response": eval_result.get( 

943 "grader_response", "" 

944 ), 

945 } 

946 ) 

947 else: 

948 error_msg = ( 

949 eval_result.get( 

950 "grading_error", "Unknown evaluation error" 

951 ) 

952 if eval_result 

953 else "No evaluation results returned" 

954 ) 

955 result.update( 

956 { 

957 "is_correct": None, 

958 "graded_confidence": "0", 

959 "grader_response": f"Evaluation failed: {error_msg}", 

960 "evaluation_error": error_msg, 

961 } 

962 ) 

963 

964 except Exception as e: 

965 logger.exception("Evaluation error") 

966 result.update( 

967 { 

968 "is_correct": None, 

969 "graded_confidence": "0", 

970 "grader_response": f"Evaluation failed: {e!s}", 

971 "evaluation_error": str(e), 

972 } 

973 ) 

974 

975 return result 

976 

977 except Exception as e: 

978 logger.exception("Research error") 

979 return { 

980 **task, 

981 "research_error": str(e), 

982 "completed_at": datetime.now(UTC), 

983 } 

984 

985 def _persist_unsaved_results( 

986 self, session, benchmark_run_id: int, run_data: dict 

987 ) -> list[int]: 

988 """Stage INSERTs for this run's not-yet-persisted results; return the 

989 result indices that were staged. 

990 

991 Idempotent and rollback-safe. A result is skipped when its index is 

992 already known-saved (``saved_indices``), its ``query_hash`` is already 

993 committed for this run, or it repeats a ``query_hash`` staged earlier 

994 in this same batch. Malformed result entries are also skipped so one 

995 bad payload cannot make every later valid result roll back (#4861). 

996 Since ``query_hash`` covers ``example_id`` as well as the question text 

997 (#5080), a repeat no longer means "a dataset legitimately repeats a 

998 question": it is the same result entry reaching this method twice, so 

999 the skip is logged at WARNING rather than passed over in silence 

1000 (#4860). 

1001 Dedup correctness rests on the DB-backed ``seen_hashes``, which 

1002 survives a rollback (the next sync re-reads it), NOT on any in-memory 

1003 flag. 

1004 

1005 This method deliberately does NOT mark ``saved_indices``: the caller 

1006 must do that ONLY after its commit succeeds. Marking before the commit 

1007 would, on a commit failure (disk full, lock timeout, ...), leave rows 

1008 flagged saved that were actually rolled back — silently dropped 

1009 forever. Combined with ``self._results_sync_lock`` held by the caller 

1010 ACROSS the commit, this also stops the worker thread and the request 

1011 thread from both inserting the same ``(benchmark_run_id, query_hash)`` 

1012 row and tripping the ``uix_run_query`` unique constraint. 

1013 """ 

1014 results = run_data.get("results", []) 

1015 # Read-only here — marking indices saved is the caller's job, and only 

1016 # after the commit lands (see the method docstring). 

1017 saved_indices = run_data.get("saved_indices", set()) 

1018 

1019 # Every query_hash already committed for this run, fetched once. This 

1020 # is the rollback-safe source of truth for dedup; it is extended below 

1021 # so a question repeated within this batch is staged only once. 

1022 seen_hashes = { 

1023 row[0] 

1024 for row in session.query(BenchmarkResult.query_hash) 

1025 .filter(BenchmarkResult.benchmark_run_id == benchmark_run_id) 

1026 .all() 

1027 } 

1028 

1029 staged_indices = [] 

1030 for idx, result in enumerate(results): 

1031 if idx in saved_indices: 

1032 continue 

1033 try: 

1034 ( 

1035 example_id, 

1036 query_hash, 

1037 dataset_type, 

1038 question, 

1039 correct_answer, 

1040 ) = _required_persistence_fields(result) 

1041 except (KeyError, TypeError, ValueError) as exc: 

1042 example_id = ( 

1043 result.get("example_id") 

1044 if isinstance(result, dict) 

1045 else None 

1046 ) 

1047 task_index = ( 

1048 result.get("task_index") 

1049 if isinstance(result, dict) 

1050 else None 

1051 ) 

1052 logger.warning( 

1053 f"Benchmark run {benchmark_run_id}: skipping malformed " 

1054 f"result index {idx} (example_id={example_id!r}, " 

1055 f"task_index={task_index!r}): {type(exc).__name__}" 

1056 ) 

1057 continue 

1058 if query_hash in seen_hashes: 

1059 logger.warning( 

1060 f"Benchmark run {benchmark_run_id}: skipping result index " 

1061 f"{idx} (example_id={example_id!r}), its " 

1062 f"query_hash is already persisted or staged for this run" 

1063 ) 

1064 continue 

1065 session.add( 

1066 BenchmarkResult( 

1067 benchmark_run_id=benchmark_run_id, 

1068 example_id=example_id, 

1069 query_hash=query_hash, 

1070 dataset_type=dataset_type, 

1071 research_id=result.get("research_id"), 

1072 question=question, 

1073 correct_answer=correct_answer, 

1074 response=result.get("response"), 

1075 extracted_answer=result.get("extracted_answer"), 

1076 confidence=result.get("confidence"), 

1077 processing_time=result.get("processing_time"), 

1078 sources=result.get("sources"), 

1079 is_correct=result.get("is_correct"), 

1080 graded_confidence=result.get("graded_confidence"), 

1081 grader_response=result.get("grader_response"), 

1082 completed_at=result.get("completed_at"), 

1083 research_error=result.get("research_error"), 

1084 evaluation_error=result.get("evaluation_error"), 

1085 task_index=result.get("task_index"), 

1086 ) 

1087 ) 

1088 seen_hashes.add(query_hash) 

1089 staged_indices.append(idx) 

1090 

1091 return staged_indices 

1092 

1093 @staticmethod 

1094 def _run_key( 

1095 username: Optional[str], benchmark_run_id: int 

1096 ) -> Tuple[str, int]: 

1097 """Composite ``active_runs`` key: ``(username, BenchmarkRun.id)``. 

1098 

1099 ``active_runs`` is a process-global map, but ``BenchmarkRun.id`` is a 

1100 PER-USER autoincrement integer -- so two different users can each own a 

1101 run with the same id (e.g. both have id == 1). Keying by the owner too 

1102 means a request can only ever reach the caller's own in-flight run: 

1103 one user's ``start_benchmark`` cannot clobber another's entry, and no 

1104 user-facing lookup can name another user's run. ``username`` is 

1105 normalized the same way ``start_benchmark`` records it so the key a run 

1106 is stored under matches the key every later lookup computes. 

1107 """ 

1108 return (username or "benchmark_user", benchmark_run_id) 

1109 

1110 @staticmethod 

1111 def _run_owner(run_data: Optional[Dict]) -> Optional[str]: 

1112 """Username that started the given in-memory run entry, if any.""" 

1113 if not run_data: 1113 ↛ 1114line 1113 didn't jump to line 1114 because the condition on line 1113 was never true

1114 return None 

1115 return run_data.get("data", {}).get("username") 

1116 

1117 def sync_pending_results( 

1118 self, benchmark_run_id: int, username: Optional[str] = None 

1119 ): 

1120 """Sync any pending results to database. Can be called from main thread.""" 

1121 # Read outside _results_sync_lock, so the worker thread's 

1122 # ``del self.active_runs[run_key]`` (in _sync_results_to_database, 

1123 # also unlocked) can land between the membership check and the subscript. 

1124 # A plain ``active_runs[key]`` would then raise KeyError, which escapes 

1125 # before the try below and surfaces as a spurious 500 on /api/results. 

1126 # Fetch once so the check and the read see the same state. See #4859. 

1127 # active_runs is keyed by (username, id) (see _run_key), so this lookup 

1128 # can only ever return the caller's own run -- a caller naming another 

1129 # user's per-user id simply misses. That keeps one user's results (and, 

1130 # via the run's stored password, their credentials) unreachable here. 

1131 run_data = self.active_runs.get( 

1132 self._run_key(username, benchmark_run_id) 

1133 ) 

1134 if run_data is None: 

1135 return 0 

1136 

1137 # For an anonymous/internal caller (username None) fall back to the 

1138 # owner recorded on the run for the DB session below. 

1139 if not username: 

1140 username = self._run_owner(run_data) 

1141 user_password = run_data.get("data", {}).get("user_password") 

1142 

1143 saved_count = 0 

1144 session = None 

1145 from ...database.session_context import ( 

1146 get_user_db_session, 

1147 safe_rollback, 

1148 ) 

1149 

1150 try: 

1151 # Serialize with the worker thread's _sync_results_to_database so 

1152 # the two can't insert the same (benchmark_run_id, query_hash) row. 

1153 with self._results_sync_lock: 

1154 with get_user_db_session(username, user_password) as session: 

1155 staged = self._persist_unsaved_results( 

1156 session, benchmark_run_id, run_data 

1157 ) 

1158 if staged: 

1159 session.commit() 

1160 # Mark saved ONLY after the commit succeeds, so a 

1161 # failed commit leaves these for the next sync to retry 

1162 # instead of dropping them silently. 

1163 run_data.setdefault("saved_indices", set()).update( 

1164 staged 

1165 ) 

1166 saved_count = len(staged) 

1167 logger.info( 

1168 f"Saved {saved_count} new results for benchmark " 

1169 f"{benchmark_run_id}" 

1170 ) 

1171 # Reaching the end of the transaction proves persistence 

1172 # is healthy again. Clear a prior transient failure even 

1173 # when another thread already saved the pending rows. 

1174 run_data.pop("result_persistence_failed", None) 

1175 except Exception: 

1176 logger.exception( 

1177 f"Error syncing pending results for benchmark {benchmark_run_id}" 

1178 ) 

1179 # Thread-local sessions are reused, so a failed flush/commit must be 

1180 # rolled back explicitly or the next use raises PendingRollbackError. 

1181 if session is not None: 1181 ↛ 1183line 1181 didn't jump to line 1183 because the condition on line 1181 was always true

1182 safe_rollback(session, "sync_pending_results") 

1183 run_data["result_persistence_failed"] = True 

1184 

1185 return saved_count 

1186 

1187 def get_result_persistence_error( 

1188 self, benchmark_run_id: int, username: Optional[str] = None 

1189 ) -> Optional[Dict[str, str]]: 

1190 """Return a safe client-facing result persistence error, if active.""" 

1191 # Keyed by (username, id) (see _run_key): a caller can only ever read 

1192 # the state of their own run, never one that belongs to someone else. 

1193 run_data = self.active_runs.get( 

1194 self._run_key(username, benchmark_run_id) 

1195 ) 

1196 if not run_data or not run_data.get("result_persistence_failed"): 

1197 return None 

1198 # Return a copy so callers cannot mutate the shared constant. 

1199 return dict(_RESULT_PERSISTENCE_ERROR) 

1200 

1201 def _sync_results_to_database( 

1202 self, username: Optional[str], benchmark_run_id: int 

1203 ): 

1204 """Sync benchmark results from memory to database after thread completes.""" 

1205 run_key = self._run_key(username, benchmark_run_id) 

1206 if run_key not in self.active_runs: 

1207 return 

1208 

1209 run_data = self.active_runs[run_key] 

1210 if not run_data.get("thread_complete"): 

1211 return 

1212 

1213 username = run_data.get("data", {}).get("username") 

1214 user_password = run_data.get("data", {}).get("user_password") 

1215 session = None 

1216 from ...database.session_context import ( 

1217 get_user_db_session, 

1218 safe_rollback, 

1219 ) 

1220 

1221 try: 

1222 # Serialize with sync_pending_results (request thread) so the two 

1223 # can't insert the same (benchmark_run_id, query_hash) row. 

1224 with ( 

1225 self._results_sync_lock, 

1226 get_user_db_session(username, user_password) as session, 

1227 ): 

1228 # Update benchmark run status 

1229 benchmark_run = ( 

1230 session.query(BenchmarkRun) 

1231 .filter(BenchmarkRun.id == benchmark_run_id) 

1232 .first() 

1233 ) 

1234 

1235 if benchmark_run and "completion_info" in run_data: 

1236 info = run_data["completion_info"] 

1237 benchmark_run.status = info["status"] 

1238 benchmark_run.end_time = info.get( 

1239 "end_time", datetime.now(UTC) 

1240 ) 

1241 benchmark_run.completed_examples = info.get( 

1242 "completed_examples", 0 

1243 ) 

1244 benchmark_run.failed_examples = info.get( 

1245 "failed_examples", 0 

1246 ) 

1247 benchmark_run.error_message = info.get("error_message") 

1248 

1249 # Stage any results not yet persisted (idempotent; safe 

1250 # against a concurrent sync_pending_results on the request 

1251 # thread — see _persist_unsaved_results). 

1252 staged = self._persist_unsaved_results( 

1253 session, benchmark_run_id, run_data 

1254 ) 

1255 

1256 # Calculate final accuracy 

1257 if benchmark_run.status == BenchmarkStatus.COMPLETED: 

1258 correct_results = [ 

1259 r 

1260 for r in run_data.get("results", []) 

1261 if r.get("is_correct") 

1262 ] 

1263 evaluated_results = [ 

1264 r 

1265 for r in run_data.get("results", []) 

1266 if r.get("is_correct") is not None 

1267 ] 

1268 

1269 if evaluated_results: 

1270 benchmark_run.overall_accuracy = ( 

1271 len(correct_results) / len(evaluated_results) 

1272 ) * 100 

1273 

1274 # Calculate processing rate 

1275 total_time = sum( 

1276 r.get("processing_time", 0) 

1277 for r in evaluated_results 

1278 ) 

1279 if total_time > 0: 1279 ↛ 1284line 1279 didn't jump to line 1284 because the condition on line 1279 was always true

1280 benchmark_run.processing_rate = len( 

1281 evaluated_results 

1282 ) / (total_time / 60) 

1283 

1284 session.commit() 

1285 # Mark saved only after the commit lands (see 

1286 # _persist_unsaved_results) so a failed commit retries. 

1287 if staged: 

1288 run_data.setdefault("saved_indices", set()).update( 

1289 staged 

1290 ) 

1291 run_data.pop("result_persistence_failed", None) 

1292 logger.info( 

1293 f"Successfully synced results for benchmark {benchmark_run_id}" 

1294 ) 

1295 

1296 # Clean up memory 

1297 del self.active_runs[run_key] 

1298 

1299 except Exception: 

1300 logger.exception("Error syncing benchmark results to database") 

1301 # Thread-local session is reused — roll back a failed flush/commit 

1302 # so the next use doesn't raise PendingRollbackError. 

1303 if session is not None: 

1304 safe_rollback(session, "_sync_results_to_database") 

1305 run_data["result_persistence_failed"] = True 

1306 

1307 def _send_progress_update( 

1308 self, 

1309 benchmark_run_id: int, 

1310 completed: int, 

1311 total: int, 

1312 username: Optional[str] = None, 

1313 ): 

1314 """Send real-time progress update via websocket. 

1315 

1316 ``username`` is the run's owner and is passed through to 

1317 ``emit_to_subscribers`` so the event reaches only that user's 

1318 socket subscriber set -- ``benchmark_run_id`` is a per-user integer 

1319 and can collide across users (see 

1320 ``SocketIOService.__subscription_key``). 

1321 """ 

1322 try: 

1323 percentage = (completed / total * 100) if total > 0 else 0 

1324 

1325 # Create log entry for milestone progress 

1326 log_entry = { 

1327 "time": datetime.now(UTC).isoformat(), 

1328 "message": f"Completed {completed}/{total} examples ({percentage:.1f}%)", 

1329 "progress": percentage, 

1330 "metadata": { 

1331 "phase": "benchmark_progress", 

1332 "type": "milestone", 

1333 "completed": completed, 

1334 "total": total, 

1335 "benchmark_run_id": benchmark_run_id, 

1336 }, 

1337 } 

1338 

1339 progress_data = { 

1340 "status": "in_progress", 

1341 "message": f"Processing examples: {completed}/{total}", 

1342 "progress": percentage, 

1343 "completed": completed, 

1344 "total": total, 

1345 "benchmark_run_id": benchmark_run_id, 

1346 "log_entry": log_entry, 

1347 "progress_log": json.dumps([log_entry]), 

1348 } 

1349 

1350 self.socket_service.emit_to_subscribers( 

1351 "research_progress", 

1352 benchmark_run_id, 

1353 progress_data, 

1354 owner=username, 

1355 ) 

1356 

1357 except Exception: 

1358 logger.exception("Error sending progress update") 

1359 

1360 def _calculate_final_accuracy( 

1361 self, benchmark_run_id: int, username: Optional[str] = None 

1362 ): 

1363 """Calculate and save final accuracy metrics.""" 

1364 from ...database.session_context import get_user_db_session 

1365 

1366 with get_user_db_session(username) as session: 

1367 try: 

1368 # Get all results for this run 

1369 results = ( 

1370 session.query(BenchmarkResult) 

1371 .filter( 

1372 BenchmarkResult.benchmark_run_id == benchmark_run_id 

1373 ) 

1374 .filter(BenchmarkResult.is_correct.isnot(None)) 

1375 .all() 

1376 ) 

1377 

1378 if results: 

1379 correct_count = sum(1 for r in results if r.is_correct) 

1380 overall_accuracy = (correct_count / len(results)) * 100 

1381 

1382 # Calculate processing rate 

1383 total_time = sum(r.processing_time or 0 for r in results) 

1384 processing_rate = ( 

1385 (len(results) / (total_time / 60)) 

1386 if total_time > 0 

1387 else 0 

1388 ) 

1389 

1390 # Update benchmark run 

1391 benchmark_run = ( 

1392 session.query(BenchmarkRun) 

1393 .filter(BenchmarkRun.id == benchmark_run_id) 

1394 .first() 

1395 ) 

1396 if benchmark_run: 1396 ↛ exitline 1396 didn't jump to the function exit

1397 benchmark_run.overall_accuracy = overall_accuracy 

1398 benchmark_run.processing_rate = processing_rate 

1399 session.commit() 

1400 

1401 except Exception: 

1402 logger.exception("Error calculating final accuracy") 

1403 

1404 def update_benchmark_status( 

1405 self, 

1406 benchmark_run_id: int, 

1407 status: BenchmarkStatus, 

1408 error_message: Optional[str] = None, 

1409 username: Optional[str] = None, 

1410 ): 

1411 """Update benchmark run status.""" 

1412 from ...database.session_context import get_user_db_session 

1413 

1414 with get_user_db_session(username) as session: 

1415 try: 

1416 benchmark_run = ( 

1417 session.query(BenchmarkRun) 

1418 .filter(BenchmarkRun.id == benchmark_run_id) 

1419 .first() 

1420 ) 

1421 if benchmark_run: 

1422 benchmark_run.status = status 

1423 benchmark_run.updated_at = datetime.now(UTC) 

1424 

1425 if error_message: 

1426 benchmark_run.error_message = error_message 

1427 

1428 if ( 

1429 status == BenchmarkStatus.IN_PROGRESS 

1430 and not benchmark_run.start_time 

1431 ): 

1432 benchmark_run.start_time = datetime.now(UTC) 

1433 elif ( 

1434 status 

1435 in [BenchmarkStatus.COMPLETED, BenchmarkStatus.FAILED] 

1436 and not benchmark_run.end_time 

1437 ): 

1438 benchmark_run.end_time = datetime.now(UTC) 

1439 

1440 session.commit() 

1441 

1442 except Exception: 

1443 session.rollback() 

1444 logger.exception("Error updating benchmark status") 

1445 

1446 def get_benchmark_status( 

1447 self, benchmark_run_id: int, username: str = None 

1448 ) -> Optional[Dict]: 

1449 """Get current status of a benchmark run.""" 

1450 from ...database.session_context import get_user_db_session 

1451 

1452 with get_user_db_session(username) as session: 

1453 try: 

1454 benchmark_run = ( 

1455 session.query(BenchmarkRun) 

1456 .filter(BenchmarkRun.id == benchmark_run_id) 

1457 .first() 

1458 ) 

1459 if not benchmark_run: 

1460 return None 

1461 

1462 # Calculate running accuracy from this run's evaluated results 

1463 results = ( 

1464 session.query(BenchmarkResult) 

1465 .filter( 

1466 BenchmarkResult.benchmark_run_id == benchmark_run_id 

1467 ) 

1468 .filter(BenchmarkResult.is_correct.isnot(None)) 

1469 .all() 

1470 ) 

1471 

1472 running_accuracy = None 

1473 # Dynamic per-dataset accuracy tracking 

1474 dataset_accuracies = {} 

1475 

1476 if results: 

1477 # Overall running accuracy 

1478 correct_count = sum(1 for r in results if r.is_correct) 

1479 running_accuracy = (correct_count / len(results)) * 100 

1480 

1481 # Calculate accuracy for each dataset type dynamically 

1482 from collections import defaultdict 

1483 

1484 dataset_results = defaultdict(list) 

1485 

1486 # Group results by dataset type 

1487 for r in results: 

1488 dataset_results[r.dataset_type.value].append(r) 

1489 

1490 # Calculate accuracy for each dataset 

1491 for ( 

1492 dataset_type, 

1493 dataset_result_list, 

1494 ) in dataset_results.items(): 

1495 if dataset_result_list: 1495 ↛ 1491line 1495 didn't jump to line 1491 because the condition on line 1495 was always true

1496 correct = sum( 

1497 1 for r in dataset_result_list if r.is_correct 

1498 ) 

1499 accuracy = ( 

1500 correct / len(dataset_result_list) 

1501 ) * 100 

1502 # Store with _accuracy suffix for consistency 

1503 dataset_accuracies[f"{dataset_type}_accuracy"] = ( 

1504 accuracy 

1505 ) 

1506 

1507 # Calculate time estimates and reliability metrics 

1508 estimated_time_remaining = None 

1509 total_elapsed_time = None 

1510 avg_time_per_example = None 

1511 accuracy_confidence = None 

1512 

1513 # Get ALL results for timing calculation (including those pending evaluation) 

1514 all_results_for_timing = ( 

1515 session.query(BenchmarkResult) 

1516 .filter( 

1517 BenchmarkResult.benchmark_run_id == benchmark_run_id 

1518 ) 

1519 .all() 

1520 ) 

1521 

1522 if benchmark_run.start_time and all_results_for_timing: 

1523 # Calculate elapsed time 

1524 current_time = datetime.now(UTC) 

1525 total_elapsed_time = ( 

1526 current_time - benchmark_run.start_time 

1527 ).total_seconds() 

1528 

1529 # Calculate average processing time per example using actual count 

1530 avg_time_per_example = total_elapsed_time / len( 

1531 all_results_for_timing 

1532 ) 

1533 

1534 logger.info( 

1535 f"Time calculation - elapsed: {total_elapsed_time:.2f}s, " 

1536 f"results_count: {len(all_results_for_timing)}, " 

1537 f"avg_per_example: {avg_time_per_example:.2f}s" 

1538 ) 

1539 

1540 # Estimate remaining time 

1541 remaining_examples = benchmark_run.total_examples - len( 

1542 all_results_for_timing 

1543 ) 

1544 if remaining_examples > 0: 

1545 estimated_time_remaining = ( 

1546 avg_time_per_example * remaining_examples 

1547 ) 

1548 logger.info( 

1549 f"Time estimation - total: {benchmark_run.total_examples}, " 

1550 f"completed: {len(all_results_for_timing)}, remaining: {remaining_examples}, " 

1551 f"avg_time: {avg_time_per_example:.2f}s, " 

1552 f"estimated_remaining: {estimated_time_remaining:.2f}s" 

1553 ) 

1554 

1555 # Calculate accuracy confidence interval (Wilson score, 95%) 

1556 if results and len(results) >= 3: 

1557 from local_deep_research.benchmarks.metrics.statistics import ( 

1558 wilson_score_interval, 

1559 ) 

1560 

1561 n = len(results) 

1562 correct = sum(1 for r in results if r.is_correct) 

1563 ci = wilson_score_interval(correct, n) 

1564 accuracy_confidence = { 

1565 "lower_bound": ci["lower"] * 100, 

1566 "upper_bound": ci["upper"] * 100, 

1567 "margin_of_error": ci["margin_of_error"] * 100, 

1568 "sample_size": n, 

1569 } 

1570 

1571 status_data = { 

1572 "id": benchmark_run.id, 

1573 "run_name": benchmark_run.run_name, 

1574 "status": benchmark_run.status.value, 

1575 "completed_examples": len( 

1576 all_results_for_timing 

1577 ), # Use actual count from DB 

1578 "total_examples": benchmark_run.total_examples, 

1579 "failed_examples": benchmark_run.failed_examples, 

1580 "overall_accuracy": benchmark_run.overall_accuracy 

1581 or running_accuracy, # Use running accuracy if final not calculated 

1582 "running_accuracy": running_accuracy, # Current running accuracy 

1583 "processing_rate": benchmark_run.processing_rate, 

1584 "estimated_time_remaining": estimated_time_remaining, # seconds 

1585 "total_elapsed_time": total_elapsed_time, # seconds 

1586 "avg_time_per_example": avg_time_per_example, # seconds 

1587 "accuracy_confidence": accuracy_confidence, # confidence interval 

1588 "created_at": benchmark_run.created_at.isoformat() 

1589 if benchmark_run.created_at 

1590 else None, 

1591 "start_time": benchmark_run.start_time.isoformat() 

1592 if benchmark_run.start_time 

1593 else None, 

1594 "end_time": benchmark_run.end_time.isoformat() 

1595 if benchmark_run.end_time 

1596 else None, 

1597 # Genericized at this boundary (CWE-209) — see 

1598 # _GENERIC_BENCHMARK_ERROR. Returns None when there is no 

1599 # failure so non-error states are unaffected. 

1600 "error_message": ( 

1601 _GENERIC_BENCHMARK_ERROR 

1602 if benchmark_run.error_message 

1603 else None 

1604 ), 

1605 # Add all per-dataset accuracies dynamically 

1606 **dataset_accuracies, 

1607 } 

1608 

1609 logger.info( 

1610 f"Benchmark {benchmark_run_id} status - completed: {benchmark_run.completed_examples}, " 

1611 f"running_acc: {running_accuracy}, dataset_accuracies: {dataset_accuracies}, " 

1612 f"avg_time: {avg_time_per_example}" 

1613 ) 

1614 

1615 return status_data 

1616 

1617 except Exception: 

1618 logger.exception("Error getting benchmark status") 

1619 return None 

1620 

1621 def cancel_benchmark( 

1622 self, benchmark_run_id: int, username: Optional[str] = None 

1623 ) -> bool: 

1624 """Cancel a running benchmark.""" 

1625 try: 

1626 # active_runs is keyed by (username, id) (see _run_key), so this 

1627 # lookup only ever finds the requester's own run -- one user can no 

1628 # longer set another user's in-flight run to "cancelled" and stop 

1629 # it (cross-user DoS). The DB update below is already scoped to the 

1630 # caller's own database. 

1631 run = self.active_runs.get( 

1632 self._run_key(username, benchmark_run_id) 

1633 ) 

1634 if run is not None: 

1635 run["status"] = "cancelled" 

1636 

1637 self.update_benchmark_status( 

1638 benchmark_run_id, BenchmarkStatus.CANCELLED, username=username 

1639 ) 

1640 logger.info(f"Cancelled benchmark run {benchmark_run_id}") 

1641 return True 

1642 

1643 except Exception: 

1644 logger.exception(f"Error cancelling benchmark {benchmark_run_id}") 

1645 return False 

1646 

1647 

1648# Global service instance 

1649benchmark_service = BenchmarkService()