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

543 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 ...exceptions import ResearchTerminatedException 

16from ...settings.exceptions import SettingsValueError 

17from ...settings.manager import SnapshotSettingsContext 

18from ...web.services.research_service import _global_research_semaphore 

19from ...database.models.benchmark import ( 

20 BenchmarkResult, 

21 BenchmarkRun, 

22 BenchmarkStatus, 

23 DatasetType, 

24) 

25from ..datasets import load_dataset 

26from ..graders import extract_answer_from_response, grade_single_result 

27from ..runners import format_query 

28from ...database.thread_local_session import thread_cleanup 

29 

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

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

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

33# research) and returned by get_benchmark_status() via 

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

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

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

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

38_GENERIC_BENCHMARK_ERROR = ( 

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

40 "Check the server logs for details." 

41) 

42_RESULT_PERSISTENCE_ERROR = { 

43 "code": "database_write_failed", 

44 "message": ( 

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

46 "database storage, then retry." 

47 ), 

48} 

49 

50 

51class SocketIOService: 

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

53 

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

55 BenchmarkService and its tests are written against) while delegating 

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

57 stub, silently dropping every benchmark progress / completion / 

58 rate-limit WebSocket event. 

59 

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

61 ``SocketIOService.emit_to_subscribers`` takes an optional 

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

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

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

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

66 """ 

67 

68 def emit_to_subscribers( 

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

70 ): 

71 from ...web.services.socketio_asgi import emit_to_subscribers 

72 

73 return emit_to_subscribers( 

74 event_base, 

75 research_id, 

76 data, 

77 owner=owner, 

78 enable_logging=enable_logging, 

79 ) 

80 

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

82 from ...web.services.socketio_asgi import emit_socket_event 

83 

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

85 

86 

87def _required_persistence_fields( 

88 result: Any, 

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

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

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

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

93 query_hash = result["query_hash"] 

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

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

96 return ( 

97 result["example_id"], 

98 query_hash, 

99 DatasetType(result["dataset_type"]), 

100 result["question"], 

101 result["correct_answer"], 

102 ) 

103 

104 

105class BenchmarkTaskStatus(Enum): 

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

107 

108 QUEUED = "queued" 

109 PROCESSING = "processing" 

110 COMPLETED = "completed" 

111 FAILED = "failed" 

112 CANCELLED = "cancelled" 

113 

114 

115class BenchmarkQueueTracker: 

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

117 

118 This replaces the removed memory_queue functionality for benchmarks. 

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

120 this simple in-memory solution is sufficient. 

121 

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

123 """ 

124 

125 def __init__(self): 

126 self.tasks = {} 

127 self._lock = threading.Lock() 

128 

129 def add_task( 

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

131 ): 

132 """Add a new task to tracking. 

133 

134 Also performs opportunistic cleanup of old completed tasks. 

135 """ 

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

137 self.cleanup_completed_tasks() 

138 

139 with self._lock: 

140 self.tasks[task_id] = { 

141 "username": username, 

142 "task_type": task_type, 

143 "status": BenchmarkTaskStatus.QUEUED.value, 

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

145 } 

146 

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

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

149 with self._lock: 

150 if task_id in self.tasks: 

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

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

153 else: 

154 logger.warning( 

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

156 ) 

157 

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

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

160 with self._lock: 

161 return self.tasks.get(task_id) 

162 

163 def remove_task(self, task_id: str): 

164 """Remove a task from tracking.""" 

165 with self._lock: 

166 self.tasks.pop(task_id, None) 

167 

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

169 """Remove completed tasks older than max_age_seconds. 

170 

171 Args: 

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

173 """ 

174 with self._lock: 

175 now = datetime.now(UTC) 

176 to_remove = [] 

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

178 # Only cleanup completed, failed, or cancelled tasks 

179 if task_data["status"] in [ 

180 BenchmarkTaskStatus.COMPLETED.value, 

181 BenchmarkTaskStatus.FAILED.value, 

182 BenchmarkTaskStatus.CANCELLED.value, 

183 ]: 

184 # Check if task has updated_at timestamp 

185 updated_at = task_data.get( 

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

187 ) 

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

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

190 if age > max_age_seconds: 

191 to_remove.append(task_id) 

192 

193 for task_id in to_remove: 

194 self.tasks.pop(task_id, None) 

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

196 

197 if to_remove: 

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

199 

200 

201class BenchmarkService: 

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

203 

204 def __init__(self, socket_service=None): 

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

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

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

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

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

210 self.socket_service = socket_service or SocketIOService() 

211 # Track rate limiting per user benchmark run. 

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

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

214 # Serializes benchmark-result persistence. _sync_results_to_database 

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

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

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

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

219 self._results_sync_lock = threading.Lock() 

220 

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

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

223 relevant_params = { 

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

225 "questions_per_iteration": search_config.get( 

226 "questions_per_iteration" 

227 ), 

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

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

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

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

232 } 

233 # Remove None values 

234 relevant_params = { 

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

236 } 

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

238 return hashlib.md5( # DevSkim: ignore DS126858 

239 config_str.encode(), usedforsecurity=False 

240 ).hexdigest()[:8] 

241 

242 def generate_query_hash( 

243 self, question: str, dataset_type: str, example_id: str 

244 ) -> str: 

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

246 

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

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

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

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

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

252 """ 

253 query_content = ( 

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

255 ) 

256 return hashlib.md5( # DevSkim: ignore DS126858 

257 query_content.encode(), usedforsecurity=False 

258 ).hexdigest() 

259 

260 def create_benchmark_run( 

261 self, 

262 run_name: Optional[str], 

263 search_config: Dict[str, Any], 

264 evaluation_config: Dict[str, Any], 

265 datasets_config: Dict[str, Dict], 

266 username: Optional[str] = None, 

267 user_password: Optional[str] = None, 

268 ) -> int: 

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

270 from ...database.session_context import get_user_db_session 

271 

272 with get_user_db_session(username, user_password) as session: 

273 try: 

274 config_hash = self.generate_config_hash(search_config) 

275 

276 # Calculate total examples 

277 total_examples = sum( 

278 config.get("count", 0) 

279 for config in datasets_config.values() 

280 ) 

281 

282 benchmark_run = BenchmarkRun( 

283 run_name=run_name, 

284 config_hash=config_hash, 

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

286 search_config=search_config, 

287 evaluation_config=evaluation_config, 

288 datasets_config=datasets_config, 

289 total_examples=total_examples, 

290 status=BenchmarkStatus.PENDING, 

291 ) 

292 

293 session.add(benchmark_run) 

294 session.commit() 

295 

296 logger.info( 

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

298 ) 

299 return int(benchmark_run.id) 

300 

301 except Exception: 

302 session.rollback() 

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

304 raise 

305 

306 def start_benchmark( 

307 self, 

308 benchmark_run_id: int, 

309 username: Optional[str] = None, 

310 user_password: Optional[str] = None, 

311 ) -> bool: 

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

313 from ...database.session_context import get_user_db_session 

314 

315 try: 

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

317 # This avoids database access from the background thread 

318 with get_user_db_session(username, user_password) as session: 

319 # Get benchmark run details 

320 benchmark_run = ( 

321 session.query(BenchmarkRun) 

322 .filter(BenchmarkRun.id == benchmark_run_id) 

323 .first() 

324 ) 

325 if not benchmark_run: 

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

327 f"Benchmark run {benchmark_run_id} not found" 

328 ) 

329 

330 # Create settings snapshot for thread safety + provenance. 

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

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

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

334 # failures that can legitimately escape get_all_settings: 

335 # SQLAlchemyError (transient DB issues) and LookupError / 

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

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

338 # empty snapshot — ldr_version is still recorded. Anything 

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

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

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

342 # outcome, since a settings subsystem that breaks unexpectedly 

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

344 from local_deep_research.settings import SettingsManager 

345 

346 settings_manager = SettingsManager(session) 

347 try: 

348 settings_snapshot = settings_manager.get_all_settings() 

349 except SettingsValueError: 

350 # Invalid configuration cannot run with an empty snapshot. 

351 raise 

352 except (SQLAlchemyError, LookupError, ValueError): 

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

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

355 # exception details may include user data. 

356 logger.exception( 

357 "Failed to capture settings snapshot for benchmark " 

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

359 ) 

360 settings_snapshot = {} 

361 

362 # Get user password for metrics tracking in background thread 

363 from ...utilities.request_context import ( 

364 get_current_session_id, 

365 ) 

366 from ...database.session_passwords import session_password_store 

367 

368 _user_password = None 

369 session_id = get_current_session_id() 

370 if session_id and username: 

371 _user_password = ( 

372 session_password_store.get_session_password( 

373 username, session_id 

374 ) 

375 ) 

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

377 logger.warning( 

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

379 ) 

380 

381 # Extract all data we need 

382 benchmark_data = { 

383 "benchmark_run_id": benchmark_run_id, 

384 "username": username or "benchmark_user", 

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

386 "config_hash": benchmark_run.config_hash, 

387 "datasets_config": benchmark_run.datasets_config, 

388 "search_config": benchmark_run.search_config, 

389 "evaluation_config": benchmark_run.evaluation_config, 

390 # The grader later calls get_llm using only this snapshot. 

391 # Scope it to the authenticated owner so ADAPTIVE can see 

392 # their private retriever primary and refuse cloud egress. 

393 "settings_snapshot": ( 

394 {**settings_snapshot, "_username": username} 

395 if username 

396 else settings_snapshot 

397 ), 

398 } 

399 

400 # Update status in database. Persist provenance (ldr_version 

401 # + redacted settings_snapshot) BEFORE the commit so the 

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

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

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

405 # SQLCipher protects at rest. 

406 from local_deep_research import __version__ 

407 from local_deep_research.security.data_sanitizer import ( 

408 DataSanitizer, 

409 ) 

410 

411 benchmark_run.ldr_version = __version__ 

412 benchmark_run.settings_snapshot = ( 

413 DataSanitizer.redact_settings_snapshot(settings_snapshot) 

414 ) 

415 benchmark_run.status = BenchmarkStatus.IN_PROGRESS 

416 benchmark_run.start_time = datetime.now(UTC) 

417 session.commit() 

418 

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

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

421 # this entry (see _run_key). 

422 run_key = self._run_key(username, benchmark_run_id) 

423 self.active_runs[run_key] = { 

424 "data": benchmark_data, 

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

426 "status": "running", 

427 "results": [], 

428 } 

429 

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

431 # same composite key for its own active_runs accesses. 

432 thread = threading.Thread( 

433 target=self._run_benchmark_thread, 

434 args=(username, benchmark_run_id), 

435 daemon=True, 

436 ) 

437 # Publish the handle before the worker can finish and remove its 

438 # active_runs entry during result synchronization. 

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

440 thread.start() 

441 

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

443 return True 

444 

445 except Exception as e: 

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

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

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

449 # cancel_benchmark / get_run_status calls. Recompute the key here 

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

451 self.active_runs.pop( 

452 self._run_key(username, benchmark_run_id), None 

453 ) 

454 # Update status using user database 

455 with get_user_db_session(username, user_password) as session: 

456 benchmark_run = ( 

457 session.query(BenchmarkRun) 

458 .filter(BenchmarkRun.id == benchmark_run_id) 

459 .first() 

460 ) 

461 if benchmark_run: 

462 benchmark_run.status = BenchmarkStatus.FAILED 

463 benchmark_run.error_message = str(e) 

464 session.commit() 

465 return False 

466 

467 @thread_cleanup 

468 def _run_benchmark_thread( 

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

470 ): 

471 """Main benchmark execution thread.""" 

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

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

474 

475 task_id = None 

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

477 run_key = self._run_key(username, benchmark_run_id) 

478 

479 # Get the benchmark data that was passed to us 

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

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

482 

483 try: 

484 if not benchmark_data: 

485 raise ValueError( # noqa: TRY301 

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

487 ) 

488 # Set up settings context for thread-local access 

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

490 # ``or`` (not ``.get(..., default)``) so an explicit None still 

491 # falls back — symmetric with _run_key's normalization. 

492 username = benchmark_data.get("username") or "benchmark_user" 

493 

494 # Create a settings context that threads can use 

495 settings_context = SnapshotSettingsContext( 

496 settings_snapshot, 

497 username=username, 

498 missing_key_log_level="WARNING", 

499 ) 

500 

501 # Set the context in thread-local storage 

502 from ...config.thread_settings import set_settings_context 

503 

504 set_settings_context(settings_context) 

505 

506 # Extract all the data we need 

507 datasets_config = benchmark_data["datasets_config"] 

508 search_config = benchmark_data["search_config"] 

509 evaluation_config = benchmark_data["evaluation_config"] 

510 

511 # Create task queue 

512 task_queue = self._create_task_queue( 

513 datasets_config, 

514 benchmark_run_id, 

515 ) 

516 

517 # Calculate totals 

518 total_examples = len(task_queue) 

519 completed_examples = 0 

520 

521 # Initialize task tracking 

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

523 username = benchmark_data.get("username") or "benchmark_user" 

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

525 self.queue_tracker.update_task_status( 

526 task_id, BenchmarkTaskStatus.PROCESSING 

527 ) 

528 

529 # Track progress in memory 

530 progress_info = { 

531 "total_examples": total_examples, 

532 "completed_examples": completed_examples, 

533 "failed_examples": 0, 

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

535 } 

536 

537 # Process tasks 

538 logger.info( 

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

540 ) 

541 for i, task in enumerate(task_queue): 

542 # Check if benchmark has been cancelled 

543 if ( 

544 run_key in self.active_runs 

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

546 ): 

547 logger.info( 

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

549 ) 

550 break 

551 

552 logger.info( 

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

554 ) 

555 try: 

556 # Add username and password to task for metrics tracking. 

557 # ``or`` keeps the fallback symmetric with _run_key: a 

558 # missing/None owner still maps to "benchmark_user" so 

559 # the publish below hits the same entry cancel reads. 

560 task["username"] = benchmark_data.get("username") or ( 

561 "benchmark_user" 

562 ) 

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

564 

565 # Acquire the global research semaphore so benchmark 

566 # tasks count against the server-wide concurrency limit 

567 _global_research_semaphore.acquire() 

568 try: 

569 # Re-check after the semaphore wait: Cancel may have 

570 # landed while blocked, when there was no tracking ID 

571 # to flag. Starting the task now would publish a fresh 

572 # ID the finished handshake cannot see and run to 

573 # completion after Cancel. 

574 if ( 

575 run_key in self.active_runs 

576 and self.active_runs[run_key].get("status") 

577 == "cancelled" 

578 ): 

579 logger.info( 

580 f"Benchmark {benchmark_run_id} was cancelled " 

581 f"while waiting for capacity, skipping task " 

582 f"{i + 1}/{len(task_queue)}" 

583 ) 

584 break 

585 # Process single task 

586 result = self._process_benchmark_task( 

587 task, 

588 search_config, 

589 evaluation_config, 

590 ) 

591 except ResearchTerminatedException: 

592 # Cancel arrived mid-task: the in-flight LLM call 

593 # aborted via its termination flag (published as 

594 # current_tracking_id, see _process_benchmark_task). 

595 # Stop the run without counting the aborted task as 

596 # failed — the was_cancelled check below still marks 

597 # the run CANCELLED. 

598 logger.info( 

599 f"Benchmark {benchmark_run_id} task {i + 1} " 

600 f"aborted mid-task on cancel; stopping run" 

601 ) 

602 break 

603 finally: 

604 _global_research_semaphore.release() 

605 # Drop the finished task's research scope and flag 

606 # so one task's cancellation state can never leak 

607 # into the next task's LLM calls. 

608 finished_entry = self.active_runs.get(run_key) 

609 finished_tid = ( 

610 finished_entry.pop("current_tracking_id", None) 

611 if finished_entry is not None 

612 else None 

613 ) 

614 if finished_tid: 

615 try: 

616 from ...web.research_state import ( 

617 clear_termination_flag, 

618 ) 

619 

620 clear_termination_flag(finished_tid) 

621 except Exception: 

622 logger.debug( 

623 "Failed to clear benchmark task flag " 

624 "(non-critical)" 

625 ) 

626 

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

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

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

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

631 

632 # Update progress 

633 progress_info["completed_examples"] += 1 

634 

635 logger.info( 

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

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

638 ) 

639 

640 # Send real-time update 

641 self._send_progress_update( 

642 benchmark_run_id, 

643 progress_info["completed_examples"], 

644 progress_info["total_examples"], 

645 username=username, 

646 ) 

647 

648 except Exception as e: 

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

650 progress_info["failed_examples"] += 1 

651 logger.info( 

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

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

654 ) 

655 

656 # Check if this is a rate limiting error 

657 error_str = str(e).lower() 

658 if ( 

659 "403" in error_str 

660 or "rate limit" in error_str 

661 or "forbidden" in error_str 

662 ): 

663 self.rate_limit_detected[run_key] = True 

664 # Send rate limit warning via WebSocket. Pass the 

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

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

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

668 # same id (see SocketIOService.__subscription_key). 

669 self.socket_service.emit_to_subscribers( 

670 "research_progress", 

671 benchmark_run_id, 

672 { 

673 "rate_limit_detected": True, 

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

675 }, 

676 owner=username, 

677 ) 

678 

679 # Mark as completed in memory tracker 

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

681 

682 # Check if benchmark was cancelled 

683 was_cancelled = ( 

684 run_key in self.active_runs 

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

686 ) 

687 

688 if was_cancelled: 

689 status = BenchmarkStatus.CANCELLED 

690 message = "Benchmark cancelled by user" 

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

692 self.queue_tracker.update_task_status( 

693 task_id, BenchmarkTaskStatus.CANCELLED 

694 ) 

695 else: 

696 status = BenchmarkStatus.COMPLETED 

697 message = "Benchmark completed successfully" 

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

699 self.queue_tracker.update_task_status( 

700 task_id, BenchmarkTaskStatus.COMPLETED 

701 ) 

702 

703 # Store completion info for later database update 

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

705 "status": status, 

706 "end_time": progress_info["end_time"], 

707 "completed_examples": progress_info["completed_examples"], 

708 "failed_examples": progress_info["failed_examples"], 

709 } 

710 

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

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

713 self.socket_service.emit_to_subscribers( 

714 "research_progress", 

715 benchmark_run_id, 

716 { 

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

718 "message": message, 

719 "progress": ( 

720 progress_info["completed_examples"] 

721 / progress_info["total_examples"] 

722 * 100 

723 ) 

724 if progress_info["total_examples"] > 0 

725 else 0, 

726 "benchmark_run_id": benchmark_run_id, 

727 }, 

728 owner=username, 

729 ) 

730 

731 except Exception as e: 

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

733 # Update task status if we have a task_id 

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

735 self.queue_tracker.update_task_status( 

736 task_id, BenchmarkTaskStatus.FAILED 

737 ) 

738 # Store failure info for later database update 

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

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

741 "status": BenchmarkStatus.FAILED, 

742 "error_message": str(e), 

743 } 

744 finally: 

745 # Clean up active run tracking 

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

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

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

749 

750 # Try to save results to database immediately if possible 

751 self._sync_results_to_database(username, benchmark_run_id) 

752 

753 def _create_task_queue( 

754 self, 

755 datasets_config: Dict, 

756 benchmark_run_id: int, 

757 ) -> List[Dict]: 

758 """Create list of tasks to process. 

759 

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

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

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

763 is random each time. 

764 """ 

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

766 

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

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

769 # This is the local benchmark registry factory, not a Hugging Face download API. 

770 dataset = load_dataset( # nosec B615 

771 dataset_type=dataset_name, 

772 num_examples=config["count"], 

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

774 ) 

775 

776 for i, example in enumerate(dataset): 

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

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

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

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

781 

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

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

784 query_hash = self.generate_query_hash( 

785 question, dataset_name, example_id 

786 ) 

787 

788 tasks.append( 

789 { 

790 "benchmark_run_id": benchmark_run_id, 

791 "example_id": example_id, 

792 "dataset_type": dataset_name, 

793 "question": question, 

794 "correct_answer": correct_answer, 

795 "query_hash": query_hash, 

796 "task_index": len(tasks), 

797 } 

798 ) 

799 

800 return tasks 

801 

802 def _process_benchmark_task( 

803 self, task: Dict, search_config: Dict, evaluation_config: Dict 

804 ) -> Dict: 

805 """Process a single benchmark task.""" 

806 try: 

807 logger.info( 

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

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

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

811 ) 

812 

813 # Get settings context from thread-local storage 

814 from ...config.thread_settings import get_settings_context 

815 

816 settings_context = get_settings_context() 

817 

818 # Generate a unique tracking ID for this benchmark task 

819 import uuid 

820 

821 tracking_id = str(uuid.uuid4()) 

822 logger.info( 

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

824 ) 

825 

826 # Publish the in-flight research scope so cancel_benchmark can 

827 # abort this task's LLM calls mid-generation (not just between 

828 # tasks). quick_summary binds this id to its LLM (explicit 

829 # research_id) and thread context, and the grading phase below 

830 # re-arms the same scope — the termination flag then aborts the 

831 # current call within one streamed chunk. Cleared by the worker 

832 # loop after every task, so ids never outlive their task. 

833 # The key must match the worker loop's run_key: task["username"] 

834 # is set from benchmark_data (with the same "benchmark_user" 

835 # fallback) before this call, so web runs always hit. A direct 

836 # caller that bypasses the loop with a mismatched username 

837 # misses here instead of publishing to another user's same-id 

838 # run (BenchmarkRun.id is per-user) — log so the trap is visible. 

839 tracker_entry = self.active_runs.get( 

840 self._run_key(task.get("username"), task["benchmark_run_id"]) 

841 ) 

842 if tracker_entry is None: 

843 logger.warning( 

844 f"Benchmark {task['benchmark_run_id']} task " 

845 f"{task['example_id']} has no live run entry for " 

846 f"owner {task.get('username')!r}; skipping scope publish " 

847 f"(cancel will only stop between tasks)" 

848 ) 

849 else: 

850 tracker_entry["current_tracking_id"] = tracking_id 

851 # Cooperative handoff: Cancel may have landed between the 

852 # worker's status checks and this publish, seeing no ID to 

853 # flag. If the run is already cancelled, arm the flag for the 

854 # just-published ID and abort before any LLM call starts, so 

855 # neither ordering loses the stop request. 

856 if tracker_entry.get("status") == "cancelled": 

857 try: 

858 from ...web.research_state import ( 

859 set_termination_flag, 

860 ) 

861 

862 set_termination_flag(tracking_id) 

863 except Exception: 

864 logger.debug( 

865 "Failed to set benchmark termination flag " 

866 "(non-critical)" 

867 ) 

868 raise ResearchTerminatedException( # noqa: TRY301 

869 "Benchmark cancelled before task started" 

870 ) 

871 

872 # Format query 

873 formatted_query = format_query( 

874 task["question"], task["dataset_type"] 

875 ) 

876 logger.info( 

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

878 ) 

879 

880 # Run research with progress callback for WebSocket updates 

881 start_time = time.time() 

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

883 

884 def benchmark_progress_callback( 

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

886 ): 

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

888 try: 

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

890 

891 # Create research-compatible log entry 

892 log_entry = { 

893 "time": timestamp, 

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

895 "progress": progress, 

896 "metadata": { 

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

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

899 "example_id": task["example_id"], 

900 "benchmark_run_id": task["benchmark_run_id"], 

901 **data, # Include all other data 

902 }, 

903 } 

904 

905 # Determine log type based on status/message content 

906 if ( 

907 "complete" in status.lower() 

908 or "finished" in status.lower() 

909 ): 

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

911 elif ( 

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

913 ): 

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

915 elif ( 

916 "starting" in status.lower() 

917 or "begin" in status.lower() 

918 ): 

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

920 

921 # Create progress data in research format 

922 progress_data = { 

923 "progress": progress, 

924 "message": status, 

925 "status": "in_progress", 

926 "log_entry": log_entry, 

927 "progress_log": json.dumps( 

928 [log_entry] 

929 ), # Array format expected by socket.js 

930 } 

931 

932 # Emit using research_progress format that the UI expects. 

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

934 # enclosing call, see _run_benchmark_thread) so this 

935 # reaches only their subscriber set -- benchmark_run_id 

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

937 self.socket_service.emit_to_subscribers( 

938 "research_progress", 

939 task["benchmark_run_id"], 

940 progress_data, 

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

942 ) 

943 

944 except Exception: 

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

946 

947 # Get user password from task data 

948 user_password = task.get("user_password") 

949 

950 search_result = quick_summary( 

951 query=formatted_query, 

952 research_id=tracking_id, # Pass the tracking ID 

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

954 questions_per_iteration=search_config.get( 

955 "questions_per_iteration", 5 

956 ), 

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

958 search_strategy=search_config.get( 

959 "search_strategy", "focused_iteration" 

960 ), 

961 progress_callback=benchmark_progress_callback, 

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

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

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

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

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

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

968 user_password=user_password, # Pass password for metrics tracking 

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

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

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

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

973 # metrics path stays active. 

974 programmatic_mode=False, 

975 ) 

976 processing_time = time.time() - start_time 

977 logger.info( 

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

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

980 ) 

981 

982 # Extract answer 

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

984 logger.info( 

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

986 ) 

987 

988 extracted_data = extract_answer_from_response( 

989 response, task["dataset_type"] 

990 ) 

991 extracted_answer = ( 

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

993 if isinstance(extracted_data, dict) 

994 else str(extracted_data) 

995 ) 

996 logger.info( 

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

998 ) 

999 

1000 # Extract sources - handle both direct sources and all_links_of_system 

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

1002 if not sources and "all_links_of_system" in search_result: 

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

1004 

1005 # Log for debugging 

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

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

1008 

1009 # Prepare result 

1010 result = { 

1011 **task, 

1012 "response": response, 

1013 "extracted_answer": extracted_answer, 

1014 "confidence": str( 

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

1016 if isinstance(extracted_data, dict) 

1017 else "100" 

1018 ), 

1019 "processing_time": processing_time, 

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

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

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

1023 } 

1024 

1025 # Evaluate result - requires proper grading model 

1026 try: 

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

1028 eval_start_time = time.time() 

1029 

1030 # Always attempt evaluation, regardless of provider 

1031 # Modern local models like Ollama are capable of grading 

1032 # Try to evaluate with proper model 

1033 result_data = { 

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

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

1036 "correct_answer": task["correct_answer"], 

1037 "response": response, 

1038 "extracted_answer": extracted_answer, 

1039 } 

1040 

1041 # Re-arm the task's research scope around grading: 

1042 # quick_summary cleared its own context on return, and the 

1043 # grader LLM resolves cancellation from the thread context 

1044 # (graders call get_llm without a research_id). The same 

1045 # termination flag therefore aborts a long grading call 

1046 # mid-stream too. 

1047 from ...utilities.thread_context import ( 

1048 clear_search_context, 

1049 set_search_context, 

1050 ) 

1051 

1052 set_search_context( 

1053 { 

1054 "research_id": tracking_id, 

1055 "research_query": task["question"], 

1056 "username": task.get("username"), 

1057 } 

1058 ) 

1059 try: 

1060 eval_result = grade_single_result( 

1061 result_data, 

1062 task["dataset_type"], 

1063 evaluation_config, 

1064 settings_context.snapshot, 

1065 ) 

1066 finally: 

1067 clear_search_context() 

1068 eval_time = time.time() - eval_start_time 

1069 logger.info( 

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

1071 ) 

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

1073 result.update( 

1074 { 

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

1076 "graded_confidence": eval_result.get( 

1077 "graded_confidence", "0" 

1078 ), 

1079 "grader_response": eval_result.get( 

1080 "grader_response", "" 

1081 ), 

1082 } 

1083 ) 

1084 else: 

1085 error_msg = ( 

1086 eval_result.get( 

1087 "grading_error", "Unknown evaluation error" 

1088 ) 

1089 if eval_result 

1090 else "No evaluation results returned" 

1091 ) 

1092 result.update( 

1093 { 

1094 "is_correct": None, 

1095 "graded_confidence": "0", 

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

1097 "evaluation_error": error_msg, 

1098 } 

1099 ) 

1100 

1101 except Exception as e: 

1102 logger.exception("Evaluation error") 

1103 result.update( 

1104 { 

1105 "is_correct": None, 

1106 "graded_confidence": "0", 

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

1108 "evaluation_error": str(e), 

1109 } 

1110 ) 

1111 

1112 return result 

1113 

1114 except Exception as e: 

1115 logger.exception("Research error") 

1116 return { 

1117 **task, 

1118 "research_error": str(e), 

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

1120 } 

1121 

1122 def _persist_unsaved_results( 

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

1124 ) -> list[int]: 

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

1126 result indices that were staged. 

1127 

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

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

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

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

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

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

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

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

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

1137 (#4860). 

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

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

1140 flag. 

1141 

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

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

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

1145 flagged saved that were actually rolled back — silently dropped 

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

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

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

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

1150 """ 

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

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

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

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

1155 

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

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

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

1159 seen_hashes = { 

1160 row[0] 

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

1162 .filter(BenchmarkResult.benchmark_run_id == benchmark_run_id) 

1163 .all() 

1164 } 

1165 

1166 staged_indices = [] 

1167 for idx, result in enumerate(results): 

1168 if idx in saved_indices: 

1169 continue 

1170 try: 

1171 ( 

1172 example_id, 

1173 query_hash, 

1174 dataset_type, 

1175 question, 

1176 correct_answer, 

1177 ) = _required_persistence_fields(result) 

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

1179 example_id = ( 

1180 result.get("example_id") 

1181 if isinstance(result, dict) 

1182 else None 

1183 ) 

1184 task_index = ( 

1185 result.get("task_index") 

1186 if isinstance(result, dict) 

1187 else None 

1188 ) 

1189 logger.warning( 

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

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

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

1193 ) 

1194 continue 

1195 if query_hash in seen_hashes: 

1196 logger.warning( 

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

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

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

1200 ) 

1201 continue 

1202 session.add( 

1203 BenchmarkResult( 

1204 benchmark_run_id=benchmark_run_id, 

1205 example_id=example_id, 

1206 query_hash=query_hash, 

1207 dataset_type=dataset_type, 

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

1209 question=question, 

1210 correct_answer=correct_answer, 

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

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

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

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

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

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

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

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

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

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

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

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

1223 ) 

1224 ) 

1225 seen_hashes.add(query_hash) 

1226 staged_indices.append(idx) 

1227 

1228 return staged_indices 

1229 

1230 @staticmethod 

1231 def _run_key( 

1232 username: Optional[str], benchmark_run_id: int 

1233 ) -> Tuple[str, int]: 

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

1235 

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

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

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

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

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

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

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

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

1244 """ 

1245 return (username or "benchmark_user", benchmark_run_id) 

1246 

1247 @staticmethod 

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

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

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

1251 return None 

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

1253 

1254 def sync_pending_results( 

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

1256 ): 

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

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

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

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

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

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

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

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

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

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

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

1268 run_data = self.active_runs.get( 

1269 self._run_key(username, benchmark_run_id) 

1270 ) 

1271 if run_data is None: 

1272 return 0 

1273 

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

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

1276 if not username: 

1277 username = self._run_owner(run_data) 

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

1279 

1280 saved_count = 0 

1281 session = None 

1282 from ...database.session_context import ( 

1283 get_user_db_session, 

1284 safe_rollback, 

1285 ) 

1286 

1287 try: 

1288 # Serialize with the worker thread's _sync_results_to_database so 

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

1290 with self._results_sync_lock: 

1291 with get_user_db_session(username, user_password) as session: 

1292 staged = self._persist_unsaved_results( 

1293 session, benchmark_run_id, run_data 

1294 ) 

1295 if staged: 

1296 session.commit() 

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

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

1299 # instead of dropping them silently. 

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

1301 staged 

1302 ) 

1303 saved_count = len(staged) 

1304 logger.info( 

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

1306 f"{benchmark_run_id}" 

1307 ) 

1308 # Reaching the end of the transaction proves persistence 

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

1310 # when another thread already saved the pending rows. 

1311 run_data.pop("result_persistence_failed", None) 

1312 except Exception: 

1313 logger.exception( 

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

1315 ) 

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

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

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

1319 safe_rollback(session, "sync_pending_results") 

1320 run_data["result_persistence_failed"] = True 

1321 

1322 return saved_count 

1323 

1324 def get_result_persistence_error( 

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

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

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

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

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

1330 run_data = self.active_runs.get( 

1331 self._run_key(username, benchmark_run_id) 

1332 ) 

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

1334 return None 

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

1336 return dict(_RESULT_PERSISTENCE_ERROR) 

1337 

1338 def _sync_results_to_database( 

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

1340 ): 

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

1342 run_key = self._run_key(username, benchmark_run_id) 

1343 if run_key not in self.active_runs: 

1344 return 

1345 

1346 run_data = self.active_runs[run_key] 

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

1348 return 

1349 

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

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

1352 session = None 

1353 from ...database.session_context import ( 

1354 get_user_db_session, 

1355 safe_rollback, 

1356 ) 

1357 

1358 try: 

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

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

1361 with ( 

1362 self._results_sync_lock, 

1363 get_user_db_session(username, user_password) as session, 

1364 ): 

1365 # Update benchmark run status 

1366 benchmark_run = ( 

1367 session.query(BenchmarkRun) 

1368 .filter(BenchmarkRun.id == benchmark_run_id) 

1369 .first() 

1370 ) 

1371 

1372 if benchmark_run and "completion_info" in run_data: 

1373 info = run_data["completion_info"] 

1374 benchmark_run.status = info["status"] 

1375 benchmark_run.end_time = info.get( 

1376 "end_time", datetime.now(UTC) 

1377 ) 

1378 benchmark_run.completed_examples = info.get( 

1379 "completed_examples", 0 

1380 ) 

1381 benchmark_run.failed_examples = info.get( 

1382 "failed_examples", 0 

1383 ) 

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

1385 

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

1387 # against a concurrent sync_pending_results on the request 

1388 # thread — see _persist_unsaved_results). 

1389 staged = self._persist_unsaved_results( 

1390 session, benchmark_run_id, run_data 

1391 ) 

1392 

1393 # Calculate final accuracy 

1394 if benchmark_run.status == BenchmarkStatus.COMPLETED: 

1395 correct_results = [ 

1396 r 

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

1398 if r.get("is_correct") 

1399 ] 

1400 evaluated_results = [ 

1401 r 

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

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

1404 ] 

1405 

1406 if evaluated_results: 

1407 benchmark_run.overall_accuracy = ( 

1408 len(correct_results) / len(evaluated_results) 

1409 ) * 100 

1410 

1411 # Calculate processing rate 

1412 total_time = sum( 

1413 r.get("processing_time", 0) 

1414 for r in evaluated_results 

1415 ) 

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

1417 benchmark_run.processing_rate = len( 

1418 evaluated_results 

1419 ) / (total_time / 60) 

1420 

1421 session.commit() 

1422 # Mark saved only after the commit lands (see 

1423 # _persist_unsaved_results) so a failed commit retries. 

1424 if staged: 

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

1426 staged 

1427 ) 

1428 run_data.pop("result_persistence_failed", None) 

1429 logger.info( 

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

1431 ) 

1432 

1433 # Clean up memory 

1434 del self.active_runs[run_key] 

1435 

1436 except Exception: 

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

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

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

1440 if session is not None: 

1441 safe_rollback(session, "_sync_results_to_database") 

1442 run_data["result_persistence_failed"] = True 

1443 

1444 def _send_progress_update( 

1445 self, 

1446 benchmark_run_id: int, 

1447 completed: int, 

1448 total: int, 

1449 username: Optional[str] = None, 

1450 ): 

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

1452 

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

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

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

1456 and can collide across users (see 

1457 ``SocketIOService.__subscription_key``). 

1458 """ 

1459 try: 

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

1461 

1462 # Create log entry for milestone progress 

1463 log_entry = { 

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

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

1466 "progress": percentage, 

1467 "metadata": { 

1468 "phase": "benchmark_progress", 

1469 "type": "milestone", 

1470 "completed": completed, 

1471 "total": total, 

1472 "benchmark_run_id": benchmark_run_id, 

1473 }, 

1474 } 

1475 

1476 progress_data = { 

1477 "status": "in_progress", 

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

1479 "progress": percentage, 

1480 "completed": completed, 

1481 "total": total, 

1482 "benchmark_run_id": benchmark_run_id, 

1483 "log_entry": log_entry, 

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

1485 } 

1486 

1487 self.socket_service.emit_to_subscribers( 

1488 "research_progress", 

1489 benchmark_run_id, 

1490 progress_data, 

1491 owner=username, 

1492 ) 

1493 

1494 except Exception: 

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

1496 

1497 def _calculate_final_accuracy( 

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

1499 ): 

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

1501 from ...database.session_context import get_user_db_session 

1502 

1503 with get_user_db_session(username) as session: 

1504 try: 

1505 # Get all results for this run 

1506 results = ( 

1507 session.query(BenchmarkResult) 

1508 .filter( 

1509 BenchmarkResult.benchmark_run_id == benchmark_run_id 

1510 ) 

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

1512 .all() 

1513 ) 

1514 

1515 if results: 

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

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

1518 

1519 # Calculate processing rate 

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

1521 processing_rate = ( 

1522 (len(results) / (total_time / 60)) 

1523 if total_time > 0 

1524 else 0 

1525 ) 

1526 

1527 # Update benchmark run 

1528 benchmark_run = ( 

1529 session.query(BenchmarkRun) 

1530 .filter(BenchmarkRun.id == benchmark_run_id) 

1531 .first() 

1532 ) 

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

1534 benchmark_run.overall_accuracy = overall_accuracy 

1535 benchmark_run.processing_rate = processing_rate 

1536 session.commit() 

1537 

1538 except Exception: 

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

1540 

1541 def update_benchmark_status( 

1542 self, 

1543 benchmark_run_id: int, 

1544 status: BenchmarkStatus, 

1545 error_message: Optional[str] = None, 

1546 username: Optional[str] = None, 

1547 ): 

1548 """Update benchmark run status.""" 

1549 from ...database.session_context import get_user_db_session 

1550 

1551 with get_user_db_session(username) as session: 

1552 try: 

1553 benchmark_run = ( 

1554 session.query(BenchmarkRun) 

1555 .filter(BenchmarkRun.id == benchmark_run_id) 

1556 .first() 

1557 ) 

1558 if benchmark_run: 

1559 benchmark_run.status = status 

1560 benchmark_run.updated_at = datetime.now(UTC) 

1561 

1562 if error_message: 

1563 benchmark_run.error_message = error_message 

1564 

1565 if ( 

1566 status == BenchmarkStatus.IN_PROGRESS 

1567 and not benchmark_run.start_time 

1568 ): 

1569 benchmark_run.start_time = datetime.now(UTC) 

1570 elif ( 

1571 status 

1572 in [BenchmarkStatus.COMPLETED, BenchmarkStatus.FAILED] 

1573 and not benchmark_run.end_time 

1574 ): 

1575 benchmark_run.end_time = datetime.now(UTC) 

1576 

1577 session.commit() 

1578 

1579 except Exception: 

1580 session.rollback() 

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

1582 

1583 def get_benchmark_status( 

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

1585 ) -> Optional[Dict]: 

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

1587 from ...database.session_context import get_user_db_session 

1588 

1589 with get_user_db_session(username) as session: 

1590 try: 

1591 benchmark_run = ( 

1592 session.query(BenchmarkRun) 

1593 .filter(BenchmarkRun.id == benchmark_run_id) 

1594 .first() 

1595 ) 

1596 if not benchmark_run: 

1597 return None 

1598 

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

1600 results = ( 

1601 session.query(BenchmarkResult) 

1602 .filter( 

1603 BenchmarkResult.benchmark_run_id == benchmark_run_id 

1604 ) 

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

1606 .all() 

1607 ) 

1608 

1609 running_accuracy = None 

1610 # Dynamic per-dataset accuracy tracking 

1611 dataset_accuracies = {} 

1612 

1613 if results: 

1614 # Overall running accuracy 

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

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

1617 

1618 # Calculate accuracy for each dataset type dynamically 

1619 from collections import defaultdict 

1620 

1621 dataset_results = defaultdict(list) 

1622 

1623 # Group results by dataset type 

1624 for r in results: 

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

1626 

1627 # Calculate accuracy for each dataset 

1628 for ( 

1629 dataset_type, 

1630 dataset_result_list, 

1631 ) in dataset_results.items(): 

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

1633 correct = sum( 

1634 1 for r in dataset_result_list if r.is_correct 

1635 ) 

1636 accuracy = ( 

1637 correct / len(dataset_result_list) 

1638 ) * 100 

1639 # Store with _accuracy suffix for consistency 

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

1641 accuracy 

1642 ) 

1643 

1644 # Calculate time estimates and reliability metrics 

1645 estimated_time_remaining = None 

1646 total_elapsed_time = None 

1647 avg_time_per_example = None 

1648 accuracy_confidence = None 

1649 

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

1651 all_results_for_timing = ( 

1652 session.query(BenchmarkResult) 

1653 .filter( 

1654 BenchmarkResult.benchmark_run_id == benchmark_run_id 

1655 ) 

1656 .all() 

1657 ) 

1658 

1659 if benchmark_run.start_time and all_results_for_timing: 

1660 # Calculate elapsed time 

1661 current_time = datetime.now(UTC) 

1662 total_elapsed_time = ( 

1663 current_time - benchmark_run.start_time 

1664 ).total_seconds() 

1665 

1666 # Calculate average processing time per example using actual count 

1667 avg_time_per_example = total_elapsed_time / len( 

1668 all_results_for_timing 

1669 ) 

1670 

1671 logger.info( 

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

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

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

1675 ) 

1676 

1677 # Estimate remaining time 

1678 remaining_examples = benchmark_run.total_examples - len( 

1679 all_results_for_timing 

1680 ) 

1681 if remaining_examples > 0: 

1682 estimated_time_remaining = ( 

1683 avg_time_per_example * remaining_examples 

1684 ) 

1685 logger.info( 

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

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

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

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

1690 ) 

1691 

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

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

1694 from local_deep_research.benchmarks.metrics.statistics import ( 

1695 wilson_score_interval, 

1696 ) 

1697 

1698 n = len(results) 

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

1700 ci = wilson_score_interval(correct, n) 

1701 accuracy_confidence = { 

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

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

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

1705 "sample_size": n, 

1706 } 

1707 

1708 status_data = { 

1709 "id": benchmark_run.id, 

1710 "run_name": benchmark_run.run_name, 

1711 "status": benchmark_run.status.value, 

1712 "completed_examples": len( 

1713 all_results_for_timing 

1714 ), # Use actual count from DB 

1715 "total_examples": benchmark_run.total_examples, 

1716 "failed_examples": benchmark_run.failed_examples, 

1717 "overall_accuracy": benchmark_run.overall_accuracy 

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

1719 "running_accuracy": running_accuracy, # Current running accuracy 

1720 "processing_rate": benchmark_run.processing_rate, 

1721 "estimated_time_remaining": estimated_time_remaining, # seconds 

1722 "total_elapsed_time": total_elapsed_time, # seconds 

1723 "avg_time_per_example": avg_time_per_example, # seconds 

1724 "accuracy_confidence": accuracy_confidence, # confidence interval 

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

1726 if benchmark_run.created_at 

1727 else None, 

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

1729 if benchmark_run.start_time 

1730 else None, 

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

1732 if benchmark_run.end_time 

1733 else None, 

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

1735 # _GENERIC_BENCHMARK_ERROR. Returns None when there is no 

1736 # failure so non-error states are unaffected. 

1737 "error_message": ( 

1738 _GENERIC_BENCHMARK_ERROR 

1739 if benchmark_run.error_message 

1740 else None 

1741 ), 

1742 # Add all per-dataset accuracies dynamically 

1743 **dataset_accuracies, 

1744 } 

1745 

1746 logger.info( 

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

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

1749 f"avg_time: {avg_time_per_example}" 

1750 ) 

1751 

1752 return status_data 

1753 

1754 except Exception: 

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

1756 return None 

1757 

1758 def cancel_benchmark( 

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

1760 ) -> bool: 

1761 """Cancel a running benchmark.""" 

1762 try: 

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

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

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

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

1767 # caller's own database. 

1768 run = self.active_runs.get( 

1769 self._run_key(username, benchmark_run_id) 

1770 ) 

1771 if run is not None: 

1772 run["status"] = "cancelled" 

1773 # Abort the in-flight task's LLM calls mid-generation, not 

1774 # just between tasks (see _process_benchmark_task): the 

1775 # worker publishes each task's research scope here, and the 

1776 # LLM wrapper aborts on the flag within one streamed chunk. 

1777 # Scoped to the caller's own run via _run_key (above), and 

1778 # the id is an unguessable per-task UUID besides. 

1779 tracking_id = run.get("current_tracking_id") 

1780 if tracking_id: 

1781 try: 

1782 from ...web.research_state import ( 

1783 clear_termination_flag, 

1784 set_termination_flag, 

1785 ) 

1786 

1787 set_termination_flag(tracking_id) 

1788 # The worker pops the id then clears its flag in a 

1789 # per-task finally. If it finished between our read 

1790 # and set, we just created a stale process-global 

1791 # flag for a fresh UUID that will never be reused 

1792 # (cosmetic leak). Re-check and reclaim it. 

1793 if run.get("current_tracking_id") != tracking_id: 1793 ↛ 1794line 1793 didn't jump to line 1794 because the condition on line 1793 was never true

1794 clear_termination_flag(tracking_id) 

1795 except Exception: 

1796 logger.debug( 

1797 "Failed to set benchmark termination flag " 

1798 "(non-critical)" 

1799 ) 

1800 

1801 self.update_benchmark_status( 

1802 benchmark_run_id, BenchmarkStatus.CANCELLED, username=username 

1803 ) 

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

1805 return True 

1806 

1807 except Exception: 

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

1809 return False 

1810 

1811 

1812# Global service instance 

1813benchmark_service = BenchmarkService()