Coverage for src/local_deep_research/web/services/research_service.py: 92%

1075 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1import contextvars 

2import hashlib 

3import json 

4import re 

5import threading 

6import time 

7import uuid 

8from datetime import datetime, UTC 

9from pathlib import Path 

10 

11from loguru import logger 

12 

13from ...exceptions import DuplicateResearchError, ResearchTerminatedException 

14from ...config.llm_config import get_llm, LLMNotConfiguredError 

15from ...settings.manager import ( 

16 SnapshotSettingsContext, 

17 apply_environment_overrides_to_snapshot, 

18) 

19 

20# Output directory for research results 

21from ...config.paths import get_research_outputs_directory 

22from ...config.search_config import get_search 

23from ...security.log_sanitizer import redact_and_bound_for_log 

24from ...constants import ResearchStatus 

25from ...database.models import ResearchHistory, ResearchStrategy 

26from ...database.session_context import get_user_db_session 

27from ...database.thread_local_session import thread_cleanup 

28from ...error_handling.error_messages import TYPED_RESEARCH_ERROR_MESSAGES 

29from ...error_handling.openai_compat_errors import ( 

30 friendly_openai_compatible_error, 

31 is_openai_compat_runtime_error, 

32) 

33from ...error_handling.report_generator import ErrorReportGenerator 

34from ...llm.providers.base import normalize_provider 

35from ...utilities.thread_context import set_search_context 

36from ...report_generator import IntegratedReportGenerator 

37from ...search_system import AdvancedSearchSystem 

38from ...text_optimization import CitationFormatter, CitationMode 

39from ...text_optimization.citation_formatter import ( 

40 strip_appended_sources_sentinel, 

41) 

42from ...utilities.log_utils import log_for_research 

43from ...utilities.search_utilities import extract_links_from_search_results 

44from ...utilities.type_utils import overlay_runtime_settings 

45from ..models.database import calculate_duration 

46from ...settings.env_registry import get_env_setting 

47from .socketio_asgi import ( 

48 emit_to_subscribers as _sio_emit, 

49 remove_subscriptions_for_research as _sio_remove, 

50) 

51 

52OUTPUT_DIR = get_research_outputs_directory() 

53 

54# Providers whose runtime failures are OpenAI-compatible client errors and 

55# get rewritten by ``friendly_openai_compatible_error`` to name the provider, 

56# base URL, and model (#3878). Pinned by 

57# tests/error_handling/test_openai_compat_errors.py. 

58_OPENAI_COMPAT_RUNTIME_ERROR_PROVIDERS = frozenset( 

59 { 

60 "openai_endpoint", 

61 "lmstudio", 

62 "llamacpp", 

63 "openai", 

64 "openrouter", 

65 "requesty", 

66 "orcarouter", 

67 "atlascloud", 

68 "qwen", 

69 "deepseek", 

70 "google", 

71 "ionos", 

72 "xai", 

73 } 

74) 

75 

76 

77def _resolve_error_rewrite_base_url( 

78 provider, custom_endpoint, settings_context=None 

79): 

80 """Resolve the base URL for the friendly error rewrite. 

81 

82 ``custom_endpoint`` is the ``llm.openai_endpoint.url`` value and is only 

83 the URL the client uses when the provider is ``openai_endpoint``. Some 

84 callers pass it for every provider (it defaults to the OpenRouter URL), 

85 so it is ignored for any other provider — otherwise QwenCloud, DeepSeek, 

86 LM Studio, ... would be reported against a URL they never contacted. 

87 

88 Providers with a hardcoded endpoint (QwenCloud, DeepSeek, Atlas Cloud, 

89 ...) fall back to the provider class default so the rewritten message 

90 names the real base URL instead of ``<unknown>``. Providers whose URL is 

91 configurable via a settings key (LM Studio, llama.cpp, custom endpoints) 

92 resolve their configured URL through the settings context — their class 

93 default is just a localhost guess that would misdirect troubleshooting 

94 when an operator overrode it. 

95 """ 

96 provider = normalize_provider(provider) 

97 if not provider: 

98 return None 

99 if custom_endpoint and provider == "openai_endpoint": 

100 return custom_endpoint 

101 from ...llm.providers.auto_discovery import get_provider_class 

102 

103 provider_cls = get_provider_class(provider) 

104 if provider_cls is None: 

105 return None 

106 url_setting = getattr(provider_cls, "url_setting", None) 

107 if url_setting: 

108 if settings_context is None: 

109 # No context to read the operator's configured URL — the class 

110 # default is just a localhost guess; report "<unknown>" rather 

111 # than risk naming a URL the client is not using. 

112 return None 

113 url = settings_context.get_setting(url_setting, None) 

114 return url or None 

115 if provider == "openai" and settings_context is not None: 

116 # OpenAIProvider's create_llm honors an operator-configured 

117 # llm.openai.api_base gateway; naming the class default would 

118 # misdirect troubleshooting the same way a wrong localhost would. 

119 api_base = settings_context.get_setting("llm.openai.api_base", None) 

120 if api_base: 120 ↛ 122line 120 didn't jump to line 122 because the condition on line 120 was always true

121 return api_base 

122 return getattr(provider_cls, "default_base_url", None) 

123 

124 

125class _SocketEmitter: 

126 """Object-style adapter over ``socketio_asgi.emit_to_subscribers``. 

127 

128 Main's Chat Mode code (merged from #2953) instantiated the old 

129 Flask ``SocketIOService`` and passed it around as an object with an 

130 ``.emit_to_subscribers`` method. Under FastAPI the real emit path is 

131 the module-level ``socketio_asgi`` function (aliased ``_sio_emit``); 

132 this thin adapter preserves the object API those call sites expect. 

133 """ 

134 

135 @staticmethod 

136 def emit_to_subscribers(event, research_id, data, *, owner): 

137 # ``owner`` is forwarded, not defaulted. Subscriptions are keyed by 

138 # (owner, research_id), so a default here would send every event 

139 # through the adapter to the wrong key and deliver nothing -- 

140 # silently, because every call site sits inside a swallowing 

141 # ``except``. Keeping it required means a caller that forgets it 

142 # fails loudly at the call. 

143 return _sio_emit(event, research_id, data, owner=owner) 

144 

145 

146# Module-level singleton — the adapter is stateless. 

147_socket_emitter = _SocketEmitter() 

148 

149 

150# Global concurrent research limit (server-wide, across all users) 

151_MAX_GLOBAL_CONCURRENT = get_env_setting( 

152 "server.max_concurrent_research", default=10 

153) 

154_global_research_semaphore = threading.Semaphore(_MAX_GLOBAL_CONCURRENT) 

155 

156# Fallback used by clamp_user_max_concurrent when the stored 

157# `app.max_concurrent_researches` value can't be parsed as an int. Mirrors 

158# that setting's own JSON-schema default (see default_settings.json). 

159_DEFAULT_USER_MAX_CONCURRENT = 3 

160 

161 

162def clamp_user_max_concurrent(raw_value) -> int: 

163 """Clamp a user-supplied ``app.max_concurrent_researches`` value to the 

164 server-wide semaphore ceiling. 

165 

166 ``app.max_concurrent_researches`` is a per-user, user-editable setting 

167 (default 3) gating how many of a user's OWN researches may start 

168 immediately instead of queueing. Its JSON schema now caps it at 20 

169 (previously uncapped), but a value written to a user's DB row before 

170 that cap existed keeps its raw stored value -- schema metadata is only 

171 reconciled (value is preserved) on the next login after a version bump, 

172 see ``SettingsManager.import_settings``. Without this clamp, a user 

173 could set an arbitrarily high per-user limit and flood the global 

174 research semaphore (``server.max_concurrent_research``, default 10) 

175 with their own researches, starving every other user. Clamping at each 

176 read site is defense-in-depth on top of the JSON-schema ``max_value``. 

177 

178 ACCEPTED RESIDUAL: this clamp fixes accounting against the global 

179 ceiling, not per-user fairness. A single user whose clamped value equals 

180 the full global ceiling (the default is uncapped up to 20, clamped to 

181 10) can still legitimately occupy every global semaphore slot at once, 

182 queueing every other user's researches until one of theirs finishes. 

183 Per-user fairness / anti-monopoly scheduling (e.g. a lower default 

184 per-user share, or reserving slots across users) is intentionally out of 

185 scope here -- tracked as a known limitation, not a bug in this function. 

186 

187 Args: 

188 raw_value: The raw setting value (any type -- callers pass whatever 

189 ``SettingsManager.get_setting`` returned). 

190 

191 Returns: 

192 An int between 1 and the live global semaphore ceiling 

193 (``_MAX_GLOBAL_CONCURRENT``), inclusive. 

194 """ 

195 try: 

196 value = int(raw_value) 

197 except (TypeError, ValueError): 

198 value = _DEFAULT_USER_MAX_CONCURRENT 

199 value = max(1, value) 

200 return min(value, _MAX_GLOBAL_CONCURRENT) 

201 

202 

203# Progress allocation for detailed mode — report generation is the bulk of the work 

204_DETAILED_SEARCH_PROGRESS_CAP = 8 # Search/output phases capped here 

205_DETAILED_REPORT_PROGRESS_START = 10 # Report generation starts here 

206_DETAILED_REPORT_PROGRESS_END = 100 # Report generation ends here 

207# Quick mode: the search strategy reports up to here, then the answer is 

208# generated in the 85-95% band (see progress_callback). 

209_QUICK_OUTPUT_PROGRESS_START = 85 

210# 100% is reserved for the run's own successful completion. The frontend 

211# treats ``progress >= 100`` as "research finished" (chat.js, progress.js), 

212# so every in-progress value is capped below it -- a strategy that reports 

213# its own completion at 100%, or an overshooting phase mapping, must not 

214# end the run in the UI while the answer is still being generated. 

215_IN_PROGRESS_CAP = 99 

216# Metadata flag carried only by run_research_process's final 

217# ``phase="complete"`` emit. It is the explicit completion signal: strategies 

218# also emit ``phase="complete"`` when their analyze_topic returns, which is 

219# mid-run (quick: before answer generation; detailed: once per subsection). 

220_RUN_COMPLETE_FLAG = "_run_complete" 

221# Phases that belong to the report-generation stage. "report_generation" is 

222# emitted in this file; the other four are emitted by report_generator.py. If 

223# you add or rename a phase, update both this set and the emitter. 

224_REPORT_PHASES = frozenset( 

225 { 

226 "report_generation", 

227 "report_section_research", 

228 "report_formatting", 

229 "report_structure", 

230 "report_complete", 

231 } 

232) 

233 

234# Phases that produce user-visible step messages in chat mode. 

235# "complete" is excluded — it fires AFTER the response message is written, 

236# which would create a step with a higher sequence_number than the response. 

237_STEP_PHASES = frozenset( 

238 { 

239 "init", 

240 "setup", 

241 "search_planning", 

242 "search", 

243 "observation", 

244 "output_generation", 

245 "synthesis_error", 

246 "synthesis_fallback", 

247 "report_generation", 

248 "report_complete", 

249 "error", 

250 } 

251) 

252 

253# Socket.IO emission throttling: minimum interval between progress emissions per research 

254_EMIT_THROTTLE_SECONDS = 0.2 # 200ms 

255_EMIT_TTL_SECONDS = 3600 # 1 hour — evict stale entries from orphaned research 

256 

257# Cap on the partial-content buffer kept server-side so chat-mode termination 

258# can persist whatever was already streamed. Bounded to keep memory predictable 

259# under pathologically long answers (typical answers are a few KB). 

260_MAX_PARTIAL_BUFFER_BYTES = 256 * 1024 # 256 KB 

261_emit_cleanup_counter = 0 

262_last_emit_times: dict[str, float] = {} 

263_last_emit_lock = threading.Lock() 

264 

265 

266def _chat_step_decision( 

267 phase: str | None, 

268 last_step_phase: str | None, 

269 is_final: bool, 

270) -> tuple[bool, bool]: 

271 """Decide whether to persist + emit a chat-mode step event. 

272 

273 Encodes the symmetry invariant the progress_callback in 

274 run_research_process enforces: for chat sessions, what the live UI 

275 surfaces over the socket must equal what `loadSession` reconstructs 

276 from chat_progress_steps on reload. The repeat-phase dedup must 

277 therefore block BOTH writes, not just the DB write. 

278 

279 Returns: 

280 (persist, suppress_emit) 

281 - persist: True iff add_progress_step should be called for this event. 

282 - suppress_emit: True iff the caller should null the socket payload 

283 so the emit is dropped. Only suppressed when this is a non-final 

284 repeat — final events (error/report_complete, or the run's 

285 explicit completion) always emit so the client completion 

286 handler fires. 

287 

288 Args: 

289 phase: the phase tag from this event (e.g. "search", "observation") 

290 last_step_phase: phase tag of the previously persisted chat step 

291 (None until the first persist of this research). 

292 is_final: True if this is a "final" event the client must see to 

293 fire its completion handler (error | report_complete | the 

294 run's own completion emit, flagged with _RUN_COMPLETE_FLAG). 

295 A progress value alone never makes an event final. 

296 """ 

297 if phase not in _STEP_PHASES: 

298 # Not a chat-step phase at all (e.g. "complete"). Persist no, 

299 # and let the emit through unchanged — completion / control 

300 # events still need to reach the client. 

301 return False, False 

302 dedup_ok = phase != last_step_phase or phase == "observation" 

303 if dedup_ok: 

304 return True, False 

305 return False, not is_final 

306 

307 

308def _compose_chat_step_content( 

309 message: str, phase: str | None, metadata: dict 

310) -> str: 

311 """Build the content persisted for a chat progress step. 

312 

313 Observation events carry the (bounded) full tool output in 

314 ``metadata["content"]`` alongside the one-line ``message``. Persist it 

315 beneath the message so the chat step row can expand to show what the 

316 tool actually returned — and so a session reload reconstructs exactly 

317 what the live accordion showed (the same composition happens client-side 

318 in chat.js handleProgressUpdate; keep the two in sync). 

319 

320 Only observation events compose: other phases reuse ``metadata["content"]`` 

321 for large payloads (e.g. full synthesized answers) that must not be 

322 duplicated into step rows. 

323 """ 

324 detail = metadata.get("content") 

325 if phase == "observation" and detail: 

326 return f"{message}\n\n{detail}" 

327 return message 

328 

329 

330def _parse_research_metadata(research_meta) -> dict: 

331 """Parse research_meta into a dict, handling both dict and JSON string types.""" 

332 if isinstance(research_meta, dict): 

333 return dict(research_meta) 

334 if isinstance(research_meta, str): 

335 try: 

336 parsed = json.loads(research_meta) 

337 return dict(parsed) if isinstance(parsed, dict) else {} 

338 except (ValueError, RecursionError): 

339 # JSONDecodeError is a ValueError, but the decoder's integer 

340 # digit limit raises a plain ValueError. Keep the same fallback 

341 # for unreadable legacy metadata, including nesting beyond the 

342 # decoder's recursion limit. 

343 logger.exception("Failed to parse research_meta as JSON") 

344 return {} 

345 return {} 

346 

347 

348def _ensure_settings_snapshot(metadata: dict, settings_snapshot) -> dict: 

349 """Back-fill ``settings_snapshot`` into completion metadata when missing. 

350 

351 Chat-created (``web/routers/chat.py``) and follow-up-created 

352 (``web/routers/followup.py``) rows store only ``{"submission": ...}`` 

353 at creation, while main-form and scheduler runs persist the full 

354 snapshot. Without this, ``report_assembly_service`` view/export 

355 rendering (which resolves ``report.uncited_sources_mode`` from the 

356 row's snapshot) always falls back for those rows, diverging from 

357 what the generation-time render used. 

358 

359 Only fills when the row lacks a dict snapshot; an existing snapshot 

360 is preserved (creation-time is the faithful preference). No-op when 

361 the worker has no usable snapshot. 

362 """ 

363 if not isinstance(metadata, dict): 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 return metadata 

365 existing = metadata.get("settings_snapshot") 

366 if isinstance(existing, dict): 

367 return metadata 

368 if isinstance(settings_snapshot, dict) and settings_snapshot: 

369 metadata["settings_snapshot"] = settings_snapshot 

370 return metadata 

371 

372 

373def _merge_completion_metadata( 

374 research_meta, extra: dict, settings_snapshot 

375) -> dict: 

376 """Build the completion-time ``research_meta`` for quick + detailed runs. 

377 

378 Single choke point for both completion merges in 

379 ``run_research_process``: preserves the row's existing metadata, 

380 overlays the run's result values, and back-fills the run's settings 

381 snapshot for creation paths (chat/follow-up) that store none — so 

382 later view/export renders the same uncited-sources mode the 

383 generation-time render used. Covered directly by 

384 ``TestMergeCompletionMetadata``; removing the back-fill here fails 

385 those tests. 

386 """ 

387 metadata = _parse_research_metadata(research_meta) 

388 if isinstance(extra, dict): 388 ↛ 390line 388 didn't jump to line 390 because the condition on line 388 was always true

389 metadata.update(extra) 

390 _ensure_settings_snapshot(metadata, settings_snapshot) 

391 return metadata 

392 

393 

394def _is_error_shaped_content(content: object) -> bool: 

395 """Recognize strategy error text that must not become report content.""" 

396 return isinstance(content, str) and content.lstrip().lower().startswith( 

397 "error:" 

398 ) 

399 

400 

401def _extract_synthesized_answer(results: dict) -> str: 

402 """Pull the LLM-synthesized answer out of a strategy result dict. 

403 

404 ``report_content`` must store ONLY the synthesized answer (LLM 

405 prose with [N] inline citations). The strategy's 

406 ``formatted_findings`` is the full ``format_findings`` blob — 

407 answer + ``format_links_to_markdown`` source list + 

408 ``## SEARCH QUESTIONS BY ITERATION`` + ``## DETAILED FINDINGS`` 

409 + ``## ALL SOURCES`` — and ``format_document_split`` only knows 

410 how to strip ``## Sources`` headers, not those other sections. 

411 Saving the blob would leak sources/findings into ``report_content``. 

412 

413 Resolution order: 

414 1. ``Final synthesis`` finding (set by source_based, parallel, 

415 rapid, focused_iteration, iterdrag). 

416 2. ``current_knowledge`` (other strategies expose the answer 

417 there). 

418 3. Empty string — caller decides whether to fall back further. 

419 

420 Error-shaped content is not an answer and is skipped at both steps. 

421 """ 

422 for finding in results.get("findings") or []: 

423 if finding.get("phase") == "Final synthesis": 

424 content = finding.get("content") or "" 

425 if content and not _is_error_shaped_content(content): 

426 return content 

427 knowledge = results.get("current_knowledge") or "" 

428 return "" if _is_error_shaped_content(knowledge) else knowledge 

429 

430 

431def get_citation_formatter(): 

432 """Get citation formatter with settings from thread context.""" 

433 # Import here to avoid circular imports 

434 from ...config.search_config import get_setting_from_snapshot 

435 

436 citation_format = get_setting_from_snapshot( 

437 "report.citation_format", "number_hyperlinks" 

438 ) 

439 mode_map = { 

440 "number_hyperlinks": CitationMode.NUMBER_HYPERLINKS, 

441 "domain_hyperlinks": CitationMode.DOMAIN_HYPERLINKS, 

442 "domain_id_hyperlinks": CitationMode.DOMAIN_ID_HYPERLINKS, 

443 "domain_id_always_hyperlinks": CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS, 

444 "source_tagged_hyperlinks": CitationMode.SOURCE_TAGGED_HYPERLINKS, 

445 "no_hyperlinks": CitationMode.NO_HYPERLINKS, 

446 } 

447 mode = mode_map.get(citation_format, CitationMode.NUMBER_HYPERLINKS) 

448 return CitationFormatter(mode=mode) 

449 

450 

451def export_report_to_memory( 

452 markdown_content: str, format: str, title: str | None = None 

453): 

454 """ 

455 Export a markdown report to different formats in memory. 

456 

457 Uses the modular exporter registry to support multiple formats. 

458 Available formats can be queried with ExporterRegistry.get_available_formats(). 

459 

460 Args: 

461 markdown_content: The markdown content to export 

462 format: Export format (e.g., 'pdf', 'odt', 'latex', 'quarto', 'ris') 

463 title: Optional title for the document 

464 

465 Returns: 

466 Tuple of (content_bytes, filename, mimetype) 

467 """ 

468 from ...exporters import ExporterRegistry, ExportOptions 

469 

470 # Normalize format 

471 format_lower = format.lower() 

472 

473 # Get exporter from registry 

474 exporter = ExporterRegistry.get_exporter(format_lower) 

475 

476 if exporter is None: 

477 available = ExporterRegistry.get_available_formats() 

478 raise ValueError( 

479 f"Unsupported export format: {format}. " 

480 f"Available formats: {', '.join(available)}" 

481 ) 

482 

483 # Title prepending is now handled by each exporter via _prepend_title_if_needed() 

484 # PDF and ODT exporters prepend titles; RIS and other formats ignore them 

485 

486 # Create options 

487 options = ExportOptions(title=title) 

488 

489 # Export 

490 result = exporter.export(markdown_content, options) 

491 

492 logger.info( 

493 f"Generated {format_lower} in memory, size: {len(result.content)} bytes" 

494 ) 

495 

496 return result.content, result.filename, result.mimetype 

497 

498 

499def save_research_strategy(research_id, strategy_name, *, username): 

500 """ 

501 Save the strategy used for a research to the database. 

502 

503 Args: 

504 research_id: The ID of the research 

505 strategy_name: The name of the strategy used 

506 username: The username whose encrypted DB to write. Required — 

507 without it get_user_db_session would silently fall back to 

508 the Flask session user (or fail off-request-context), so 

509 callers must state whose DB they mean. 

510 """ 

511 # ``strategy_name`` is the raw request-body ``strategy`` (the research 

512 # router passes it through unvalidated), so it goes through the bounded 

513 # preview (#6191) here and on the info line below instead of an eager 

514 # f-string (#6305). Imported at call time, not at module level, so this 

515 # service does not pull the notes router (and FastAPI) into the CLI / 

516 # MCP import tree; same pattern as the rag router import in 

517 # research_library/services/download_service.py. Inside the ``try`` 

518 # (#6939): a transient import failure joins the function's own handled 

519 # path instead of escaping it. 

520 try: 

521 from ..routers.notes import _log_value_preview 

522 

523 logger.opt(lazy=True).debug( 

524 "save_research_strategy called with research_id={}, strategy_name={}", 

525 lambda: research_id, 

526 lambda: _log_value_preview(strategy_name), 

527 ) 

528 with get_user_db_session(username) as session: 

529 # Check if a strategy already exists for this research 

530 existing_strategy = ( 

531 session.query(ResearchStrategy) 

532 .filter_by(research_id=research_id) 

533 .first() 

534 ) 

535 

536 if existing_strategy: 

537 # Update existing strategy 

538 existing_strategy.strategy_name = strategy_name 

539 logger.debug( 

540 f"Updating existing strategy for research {research_id}" 

541 ) 

542 else: 

543 # Create new strategy record 

544 new_strategy = ResearchStrategy( 

545 research_id=research_id, strategy_name=strategy_name 

546 ) 

547 session.add(new_strategy) 

548 logger.debug( 

549 f"Creating new strategy record for research {research_id}" 

550 ) 

551 

552 session.commit() 

553 logger.info( 

554 "Saved strategy {} for research {}", 

555 _log_value_preview(strategy_name), 

556 research_id, 

557 ) 

558 except Exception: 

559 logger.exception("Error saving research strategy") 

560 

561 

562def get_research_strategy(research_id, *, username): 

563 """ 

564 Get the strategy used for a research. 

565 

566 Args: 

567 research_id: The ID of the research 

568 username: The username whose encrypted DB to read. Required — 

569 without it get_user_db_session would silently fall back to 

570 the Flask session user (or fail off-request-context, which 

571 the except below would swallow into a None return), so 

572 callers must state whose DB they mean. 

573 

574 Returns: 

575 str: The strategy name or None if not found 

576 """ 

577 try: 

578 with get_user_db_session(username) as session: 

579 strategy = ( 

580 session.query(ResearchStrategy) 

581 .filter_by(research_id=research_id) 

582 .first() 

583 ) 

584 

585 return strategy.strategy_name if strategy else None 

586 except Exception: 

587 logger.exception("Error getting research strategy") 

588 return None 

589 

590 

591def start_research_process( 

592 research_id, 

593 query, 

594 mode, 

595 run_research_callback, 

596 **kwargs, 

597): 

598 """ 

599 Start a research process in a background thread. 

600 

601 Args: 

602 research_id: The ID of the research 

603 query: The research query 

604 mode: The research mode (quick/detailed) 

605 run_research_callback: The callback function to run the research 

606 **kwargs: Additional parameters to pass to the research process (model, search_engine, etc.) 

607 

608 Returns: 

609 threading.Thread: The thread running the research 

610 """ 

611 from ..research_state import check_and_start_research 

612 from ...exceptions import SystemAtCapacityError 

613 

614 # Acquire the global concurrency semaphore SYNCHRONOUSLY in the caller's 

615 # thread. Previously this happened inside the worker after the HTTP route 

616 # had already returned 200 — at capacity, the worker parked and the user 

617 # saw an infinite thinking spinner with the partial unique in-progress 

618 # index blocking retries. Surfacing capacity as an exception lets the 

619 # route return HTTP 429 before committing any DB state. 

620 if not _global_research_semaphore.acquire(blocking=False): 

621 raise SystemAtCapacityError( 

622 f"At research capacity (max {_MAX_GLOBAL_CONCURRENT} concurrent)" 

623 ) 

624 

625 # Wrap callback so the worker releases the already-held semaphore on exit. 

626 original_callback = run_research_callback 

627 

628 def _release_semaphore_on_exit(*args, **kw): 

629 try: 

630 return original_callback(*args, **kw) 

631 finally: 

632 _global_research_semaphore.release() 

633 

634 # Copy the calling request's contextvars (username, session_id) so 

635 # service code inside the worker thread sees the authenticated user 

636 # when it calls `get_current_username()`. Bare `threading.Thread` 

637 # starts with an empty context; without this, metrics/token attribution 

638 # and any code path using request_context silently fails to the 

639 # Flask-compat fallback (which returns None under FastAPI). 

640 _ctx = contextvars.copy_context() 

641 

642 def _ctx_wrapped(*a, **kw): 

643 return _ctx.run(_release_semaphore_on_exit, *a, **kw) 

644 

645 # Prepare (but do not start) the background thread. 

646 thread = threading.Thread( 

647 target=_ctx_wrapped, 

648 args=( 

649 research_id, 

650 query, 

651 mode, 

652 ), 

653 kwargs=kwargs, 

654 ) 

655 thread.daemon = True 

656 

657 # Atomic check-and-start: refuses to spawn a second live thread 

658 # for the same research_id. Guards against the double-spawn window 

659 # where a post-spawn commit failure in the queue processor could 

660 # otherwise cause the retry loop to dispatch the same research twice. 

661 try: 

662 started = check_and_start_research( 

663 research_id, 

664 { 

665 "thread": thread, 

666 "progress": 0, 

667 "status": ResearchStatus.IN_PROGRESS, 

668 "log": [], 

669 "settings": kwargs, 

670 }, 

671 ) 

672 except Exception: 

673 # check_and_start_research raised before the thread ran — the 

674 # semaphore won't be released by the worker, so release it here. 

675 _global_research_semaphore.release() 

676 raise 

677 if not started: 

678 # No thread will run → no _release_semaphore_on_exit → release here. 

679 _global_research_semaphore.release() 

680 raise DuplicateResearchError( 

681 f"Research {research_id} already has a live thread" 

682 ) 

683 

684 return thread 

685 

686 

687def _generate_report_path(query: str) -> Path: 

688 """ 

689 Generates a path for a new report file based on the query. 

690 

691 Args: 

692 query: The query used for the report. 

693 

694 Returns: 

695 The path that it generated. 

696 

697 """ 

698 # Generate a unique filename that does not contain 

699 # non-alphanumeric characters. 

700 query_hash = hashlib.md5( # DevSkim: ignore DS126858 

701 query.encode("utf-8"), usedforsecurity=False 

702 ).hexdigest()[:10] 

703 # Preventive hardening (this helper has no production caller today): 

704 # the directory is shared install-wide, and the hash+timestamp prefix 

705 # alone is deterministic across users, so the trailing random segment 

706 # keeps same-query, same-second calls from colliding. 

707 return OUTPUT_DIR / ( 

708 f"research_report_{query_hash}_" 

709 f"{int(datetime.now(UTC).timestamp())}_{uuid.uuid4().hex[:8]}.md" 

710 ) 

711 

712 

713def _save_chat_message_and_context( 

714 chat_session_id, 

715 research_id, 

716 username, 

717 report_content, 

718 streaming_enabled, 

719 streaming_state, 

720 socket_service, 

721 settings_snapshot=None, 

722): 

723 """Save assistant message to chat and update accumulated context.""" 

724 from ...chat.service import ChatService 

725 from ...chat.context import ChatContextManager 

726 

727 chat_service = ChatService(username) 

728 # chat_messages.content is NOT NULL. Write report_content 

729 # inline (snapshot pattern). Falls back to a placeholder marker only 

730 # if report_content is itself empty — the same pattern used by the 

731 # terminate path's _STOPPED_BEFORE_OUTPUT_MARKER. 

732 snapshot_content = report_content or _NO_OUTPUT_MARKER 

733 # allow_archived=True: a multi-tab race can flip the session to 

734 # archived between research.status=COMPLETED commit and this write. 

735 # Losing the final assistant answer is worse than violating the 

736 # "no writes to archived sessions" rule for a system-generated row; 

737 # user-message writes from chat/routes.py still keep the default. 

738 chat_service.add_message( 

739 session_id=chat_session_id, 

740 role="assistant", 

741 content=snapshot_content, 

742 message_type="response", 

743 research_id=research_id, 

744 allow_archived=True, 

745 ) 

746 # Mark the response row as persisted so the trailing 

747 # progress_callback("Research completed successfully", 100) — which 

748 # runs the termination check and could fire if the user clicks Stop 

749 # in the small window between this write and the final emit — does 

750 # NOT write a duplicate row via _save_partial_chat_message_on_terminate. 

751 if streaming_state is not None: 751 ↛ 753line 751 didn't jump to line 753 because the condition on line 751 was always true

752 streaming_state["_persisted"] = True 

753 logger.info(f"Added research result to chat {chat_session_id[:8]}...") 

754 

755 try: 

756 # report_content is the answer-only string; no extraction needed. 

757 chat_content = report_content or "" 

758 ctx_manager = ChatContextManager( 

759 session_id=chat_session_id, 

760 messages=[], 

761 settings_snapshot=settings_snapshot, 

762 ) 

763 updates = ctx_manager.extract_context_updates(new_content=chat_content) 

764 chat_service.update_accumulated_context( 

765 session_id=chat_session_id, **updates 

766 ) 

767 logger.info( 

768 f"Updated accumulated context for chat {chat_session_id[:8]}..." 

769 ) 

770 except Exception: 

771 # Bumped from debug to warning: a failed accumulated-context 

772 # update silently degrades multi-turn context for the next 

773 # follow-up in this chat (entities/topics/source counts are 

774 # missing). Ops need visibility to catch widespread breakage 

775 # before users notice "the AI forgot what we were talking 

776 # about" — debug-level was invisible in production. 

777 logger.opt(exception=True).warning( 

778 "Could not update accumulated context" 

779 ) 

780 

781 if streaming_enabled and streaming_state.get("chunks_sent", 0) > 0: 

782 # Flush any partial-bracket fragment held in the citation carry 

783 # buffer before sending the final empty sentinel. Without this, 

784 # a stream that ends mid-token (LLM emits "[12" as its last 

785 # bytes before closing) silently drops the leading "[" from 

786 # what the client renders — the carry would be discarded when 

787 # the callback's closure goes out of scope. 

788 flush = streaming_state.get("_flush_carry") 

789 if flush: 

790 leftover = flush() 

791 if leftover: 

792 try: 

793 socket_service.emit_to_subscribers( 

794 "response_chunk", 

795 research_id, 

796 { 

797 "chunk": leftover, 

798 "is_streaming": True, 

799 "is_final": False, 

800 }, 

801 owner=username, 

802 ) 

803 except Exception: 

804 logger.debug( 

805 "Carry-buffer flush emit failed (non-critical)" 

806 ) 

807 socket_service.emit_to_subscribers( 

808 "response_chunk", 

809 research_id, 

810 {"chunk": "", "is_streaming": True, "is_final": True}, 

811 owner=username, 

812 ) 

813 

814 

815_STOPPED_BEFORE_OUTPUT_MARKER = "[Stopped before any output was generated.]" 

816_STOPPED_FOOTER = "\n\n_— Stopped by user._" 

817_NO_OUTPUT_MARKER = "_(Research completed without producing output.)_" 

818 

819 

820# Match a trailing incomplete citation opener at the chunk boundary. 

821# Covers both ASCII "[" and the lenticular "【" (U+3010) — some LLMs 

822# emit Chinese-style brackets and the citation formatter accepts them, 

823# so we have to hold those back the same way to avoid breaking a token 

824# across the next chunk. 

825_PARTIAL_BRACKET_RE = re.compile(r"[\[【]\d*$") 

826 

827# Upper bound on the inline-citation carry buffer. A real citation token 

828# is a handful of bytes ("[123"); anything longer means the "[" wasn't a 

829# citation opener or the stream is pathological. Flush raw past this so a 

830# never-closing "[" + endless digits can't grow the buffer without limit. 

831_MAX_CARRY_BYTES = 64 

832 

833 

834def _make_chat_stream_callback( 

835 research_id, 

836 streaming_state, 

837 socket_service, 

838 username, 

839 source_resolver=None, 

840 formatter=None, 

841): 

842 """Build the chat-mode streaming callback. 

843 

844 The callback: 

845 * Counts chunks (``chunks_sent``). 

846 * Buffers RAW chunks in ``streaming_state['chunks']`` so partial 

847 content survives termination — the citation handler's local list 

848 is discarded on raise. Capped at ``_MAX_PARTIAL_BUFFER_BYTES``; 

849 once capped, ``streaming_state['_truncated']`` flips to True and 

850 further chunks aren't accumulated server-side. 

851 * Raises ``ResearchTerminatedException`` if the user clicked Stop 

852 mid-stream — fails fast instead of letting the LLM finish. 

853 ``ResearchTerminatedException`` inherits from ``BaseException``, 

854 so the citation handler's ``except Exception`` blocks naturally 

855 propagate it. 

856 * Emits ``response_chunk`` over Socket.IO for live display, with 

857 inline citation hyperlinks applied per-chunk when both 

858 ``source_resolver`` and ``formatter`` are provided — so the 

859 client sees ``[[arxiv.org-1]](url)`` appearing live rather than 

860 plain ``[1]`` brackets that only get hyperlinked after the full 

861 response saves. Bracket tokens split across chunk boundaries are 

862 held in a small carry buffer until the closing ``]`` arrives. 

863 

864 ``source_resolver`` — optional ``() -> list[dict]`` returning the 

865 current ``all_links_of_system`` (so we read it lazily; the list 

866 grows as the agent collects sources). 

867 ``formatter`` — optional :class:`CitationFormatter` instance. Its 

868 ``mode`` controls the inline format (``[[arxiv.org-1]](url)`` 

869 etc.), matching what the final-save formatter produces so the 

870 live display doesn't mode-shift when ``handleResearchComplete`` 

871 swaps in the DB-saved version. 

872 

873 Extracted to module level so it can be unit-tested without spinning 

874 up the full ``run_research_process``. 

875 """ 

876 

877 # Per-call closure state for the streaming-substitution carry 

878 # buffer (holds a trailing incomplete bracket like "[12" so the 

879 # closing "]3]" on the next chunk completes the citation token). 

880 carry = [""] 

881 

882 def _flush_carry() -> str: 

883 """Release and clear any held partial-bracket fragment. 

884 

885 The completion finalizer (``_save_chat_message_and_context``) 

886 lives in a different scope and can't reach ``carry`` directly, 

887 so it calls this through ``streaming_state['_flush_carry']`` to 

888 avoid silently dropping the tail of a stream that ends mid-token 

889 like ``"[12"``. 

890 """ 

891 released, carry[0] = carry[0], "" 

892 return released 

893 

894 # Expose to the completion path via the shared state dict. 

895 streaming_state["_flush_carry"] = _flush_carry 

896 

897 def _hyperlink_chunk(chunk: str) -> str: 

898 """Apply inline citation hyperlinks to a single chunk. 

899 

900 Maintains the closure-level ``carry`` buffer for incomplete 

901 ``[N`` tokens straddling chunk boundaries. The carry contains 

902 the trailing partial-bracket fragment from the previous chunk 

903 that we haven't been able to substitute yet — it's prepended 

904 to the next chunk so the regex can see the full token. The 

905 DELTA returned is what the client should append next: it 

906 includes the just-completed carry (now hyperlinked) plus the 

907 new chunk's safe portion, MINUS the new chunk's own trailing 

908 partial bracket (which becomes the new carry). 

909 """ 

910 if source_resolver is None or formatter is None: 

911 return chunk 

912 # Keep the complete raw delta before updating carry for this chunk. 

913 # The source resolver runs before that update; only formatting 

914 # can fail after it. 

915 text = carry[0] + chunk 

916 try: 

917 sources = source_resolver() or [] 

918 if not sources: 

919 # Reset carry so the leading "[" we held onto doesn't 

920 # disappear from the client's accumulated text. 

921 carry[0] = "" 

922 return text 

923 pending = _PARTIAL_BRACKET_RE.search(text) 

924 if pending: 

925 safe = text[: pending.start()] 

926 new_carry = text[pending.start() :] 

927 # Bound the carry. A well-formed citation token is a few 

928 # bytes (`[12`); if the held fragment grows past this, the 

929 # "[" was not actually opening a citation (or a hostile / 

930 # misbehaving LLM is streaming `[` + endless digits with no 

931 # closing `]`). Flush it raw rather than buffering without 

932 # limit — preserves the text, just doesn't hyperlink it. 

933 if len(new_carry) > _MAX_CARRY_BYTES: 

934 safe = text 

935 carry[0] = "" 

936 else: 

937 carry[0] = new_carry 

938 else: 

939 safe = text 

940 carry[0] = "" 

941 if not safe: 

942 return "" 

943 return formatter.apply_inline_hyperlinks(safe, sources) 

944 except Exception: 

945 # Include the previous carry exactly once even if formatting 

946 # failed after replacing it with a new incomplete bracket. 

947 logger.debug( 

948 "Inline citation hyperlinking failed; emitting raw chunk", 

949 exc_info=True, 

950 ) 

951 carry[0] = "" 

952 return text 

953 

954 def stream_callback(chunk: str): 

955 # Resolve through the module namespace each call so tests can 

956 # ``patch("local_deep_research.web.routes.globals.is_termination_requested")``. 

957 # Cached in sys.modules — negligible cost. 

958 from ..routes.globals import is_termination_requested 

959 

960 if not chunk: 

961 return 

962 streaming_state["chunks_sent"] += 1 

963 if not streaming_state["_truncated"]: 963 ↛ 984line 963 didn't jump to line 984 because the condition on line 963 was always true

964 chunk_bytes = len(chunk.encode("utf-8")) 

965 if ( 

966 streaming_state["_bytes"] + chunk_bytes 

967 <= _MAX_PARTIAL_BUFFER_BYTES 

968 ): 

969 # IMPORTANT: store the RAW chunk in the partial-content 

970 # buffer (terminate handler joins these and saves them as 

971 # the partial assistant message). If we stored the 

972 # hyperlinked version, the saved-on-terminate text would 

973 # be double-formatted when the user resumes. 

974 streaming_state["chunks"].append(chunk) 

975 streaming_state["_bytes"] += chunk_bytes 

976 else: 

977 streaming_state["_truncated"] = True 

978 logger.warning( 

979 f"Partial-content buffer hit {_MAX_PARTIAL_BUFFER_BYTES} bytes " 

980 f"for research {research_id}; further chunks won't be persisted on terminate" 

981 ) 

982 # Mid-stream interrupt — fail fast if the user clicked Stop while 

983 # the LLM is still streaming. 

984 if is_termination_requested(research_id): 

985 raise ResearchTerminatedException( # noqa: TRY301 — propagated through citation handler 

986 "Research was terminated by user during streaming" 

987 ) 

988 # Apply citation hyperlinks for the client emit only — the 

989 # client accumulates substituted text into the streaming bubble 

990 # so the user sees [[arxiv.org-1]](url) appearing live as the 

991 # model writes "According to [1]…". 

992 display_chunk = _hyperlink_chunk(chunk) 

993 if not display_chunk: 

994 return # nothing safe to emit yet (all held in carry buffer) 

995 try: 

996 socket_service.emit_to_subscribers( 

997 "response_chunk", 

998 research_id, 

999 { 

1000 "chunk": display_chunk, 

1001 "is_streaming": True, 

1002 "is_final": False, 

1003 }, 

1004 owner=username, 

1005 ) 

1006 except Exception: 

1007 logger.debug("Stream chunk emit failed (non-critical)") 

1008 

1009 return stream_callback 

1010 

1011 

1012def _save_partial_chat_message_on_terminate( 

1013 chat_session_id, 

1014 research_id, 

1015 username, 

1016 partial_content, 

1017 truncated=False, 

1018 streaming_state=None, 

1019): 

1020 """Persist a chat 'response' row capturing whatever was streamed before 

1021 termination, and emit a final ``response_chunk`` so the client strips 

1022 the streaming class from the bubble. 

1023 

1024 Must run BEFORE ``handle_termination()`` because that path runs 

1025 ``cleanup_research_resources()`` which removes the Socket.IO room 

1026 subscriptions — anything emitted afterwards goes nowhere. 

1027 

1028 Idempotent: when ``streaming_state`` is supplied, sets a ``_persisted`` 

1029 flag so duplicate calls (one from the progress callback, one from the 

1030 outer except handler when a stream_callback raises mid-stream) only 

1031 write a single row. 

1032 

1033 Skips silently when ``chat_session_id`` is falsy (single-turn case). 

1034 All failures are swallowed — termination cleanup must never crash the 

1035 worker. 

1036 """ 

1037 if not chat_session_id: 

1038 return 

1039 if streaming_state is not None and streaming_state.get("_persisted"): 

1040 return 

1041 try: 

1042 from ...chat.service import ChatService 

1043 

1044 if partial_content: 

1045 content = partial_content + _STOPPED_FOOTER 

1046 if truncated: 

1047 content += " _(output was very long; truncated.)_" 

1048 else: 

1049 content = _STOPPED_BEFORE_OUTPUT_MARKER 

1050 

1051 # allow_archived=True: same rationale as the completion path — 

1052 # the partial response on Stop is system-generated and must 

1053 # survive a concurrent archive (see _save_chat_message_and_context). 

1054 ChatService(username).add_message( 

1055 session_id=chat_session_id, 

1056 role="assistant", 

1057 content=content, 

1058 message_type="response", 

1059 research_id=research_id, 

1060 allow_archived=True, 

1061 ) 

1062 # Set the idempotency flag ONLY after the write succeeds. If 

1063 # `add_message` raises (DB lock, encryption error, archived 

1064 # session), the outer ResearchTerminatedException handler retries 

1065 # this helper — flipping the flag pre-write would short-circuit 

1066 # the retry and silently lose the partial response. 

1067 if streaming_state is not None: 

1068 streaming_state["_persisted"] = True 

1069 logger.info( 

1070 f"Persisted partial chat response for terminated research " 

1071 f"{research_id} ({len(content)} chars)" 

1072 ) 

1073 except Exception: 

1074 logger.opt(exception=True).warning( 

1075 "Failed to persist partial chat message on terminate" 

1076 ) 

1077 try: 

1078 flush = streaming_state.get("_flush_carry") if streaming_state else None 

1079 if flush: 

1080 leftover = flush() 

1081 if leftover: 

1082 try: 

1083 _socket_emitter.emit_to_subscribers( 

1084 "response_chunk", 

1085 research_id, 

1086 { 

1087 "chunk": leftover, 

1088 "is_streaming": True, 

1089 "is_final": False, 

1090 }, 

1091 owner=username, 

1092 ) 

1093 except Exception: 

1094 logger.debug( 

1095 "Carry-buffer flush emit on terminate failed (non-critical)" 

1096 ) 

1097 

1098 _socket_emitter.emit_to_subscribers( 

1099 "response_chunk", 

1100 research_id, 

1101 {"chunk": "", "is_streaming": True, "is_final": True}, 

1102 owner=username, 

1103 ) 

1104 except Exception: 

1105 logger.debug("Final-chunk emit on terminate failed (non-critical)") 

1106 

1107 

1108@log_for_research 

1109@thread_cleanup 

1110def run_research_process(research_id, query, mode, **kwargs): 

1111 """ 

1112 Run the research process in the background for a given research ID. 

1113 

1114 Args: 

1115 research_id: The ID of the research 

1116 query: The research query 

1117 mode: The research mode (quick/detailed) 

1118 **kwargs: Additional parameters for the research (model_provider, model, search_engine, etc.) 

1119 MUST include 'username' for database access 

1120 """ 

1121 from ..research_state import ( 

1122 is_research_active, 

1123 is_termination_requested, 

1124 update_progress_and_check_active, 

1125 ) 

1126 

1127 # Extract username - required for database access 

1128 username = kwargs.get("username") 

1129 if not username: 

1130 logger.error("No username provided to research thread") 

1131 raise ValueError("Username is required for research process") 

1132 # Extract user_password early so it's available for all cleanup paths 

1133 user_password = kwargs.get("user_password") 

1134 

1135 # Establish thread context FIRST so every subsequent log line in this 

1136 # thread can be attributed to the correct user/research and persisted 

1137 # to the user's encrypted ResearchLog. Otherwise the early INFO logs 

1138 # below ("Research thread started", "Research strategy", "Research 

1139 # parameters") fire before start_research_process gets to its own 

1140 # set_search_context call (~line 417) and the daemon can't open the 

1141 # encrypted DB to write them — silently dropped via the bare-except. 

1142 set_search_context( 

1143 { 

1144 "research_id": research_id, 

1145 "username": username, 

1146 "user_password": user_password, 

1147 # Settings snapshot may be absent here (early init); the 

1148 # shared context built later overwrites this with the 

1149 # populated snapshot. Defaults to {} so cache reads in any 

1150 # intermediate code path see a non-None dict. 

1151 "settings_snapshot": kwargs.get("settings_snapshot") or {}, 

1152 } 

1153 ) 

1154 

1155 logger.info(f"Research thread started with username: {username}") 

1156 

1157 try: 

1158 # Check if this research has been terminated before we even start 

1159 if is_termination_requested(research_id): 

1160 logger.info( 

1161 f"Research {research_id} was terminated before starting" 

1162 ) 

1163 cleanup_research_resources( 

1164 research_id, 

1165 username, 

1166 user_password=user_password, 

1167 final_status=ResearchStatus.SUSPENDED, 

1168 ) 

1169 return 

1170 

1171 # (#6938: the query reaches this thread unvalidated from the 

1172 # request body, so it previews instead of f-stringing — same 

1173 # conversion as the parameter lines below. Imported at call time 

1174 # for the same reason as in save_research_strategy above.) 

1175 from ..routers.notes import _log_value_preview 

1176 from ...security.ssrf_validator import redact_url_for_log 

1177 

1178 logger.info( 

1179 "Starting research process for ID {}, query: {}", 

1180 research_id, 

1181 _log_value_preview(query), 

1182 ) 

1183 

1184 # Extract key parameters 

1185 model_provider = kwargs.get("model_provider") 

1186 model = kwargs.get("model") 

1187 custom_endpoint = kwargs.get("custom_endpoint") 

1188 search_engine = kwargs.get("search_engine") 

1189 max_results = kwargs.get("max_results") 

1190 time_period = kwargs.get("time_period") 

1191 iterations = kwargs.get("iterations") 

1192 questions_per_iteration = kwargs.get("questions_per_iteration") 

1193 strategy = kwargs.get("strategy") 

1194 settings_snapshot = kwargs.get( 

1195 "settings_snapshot", {} 

1196 ) # Complete settings snapshot 

1197 # NOTE: the "_username" injection that lets the agent register the 

1198 # user's document collections is done downstream in 

1199 # AdvancedSearchSystem.__init__ (the narrowest common consumer of the 

1200 # strategy-running paths — web run and the programmatic API), not here. 

1201 # Run-start egress (below) doesn't need it: it passes username 

1202 # explicitly. See ensure_snapshot_username in search_system.py. 

1203 

1204 # Log settings snapshot to debug 

1205 from ...settings.logger import log_settings 

1206 

1207 log_settings(settings_snapshot, "Settings snapshot received in thread") 

1208 

1209 # Strategy should already be saved in the database before thread starts 

1210 # (#6938: thread-side parameters reach here unvalidated from the 

1211 # request body, so both lines preview them instead of f-stringing — 

1212 # same conversion #6756 made on the save path; the helper is 

1213 # imported at function entry above.) 

1214 logger.info("Research strategy: {}", _log_value_preview(strategy)) 

1215 

1216 # Log all parameters for debugging 

1217 logger.info( 

1218 "Research parameters: provider={}, model={}, " 

1219 "search_engine={}, max_results={}, " 

1220 "time_period={}, iterations={}, " 

1221 "questions_per_iteration={}, custom_endpoint={}, strategy={}", 

1222 _log_value_preview(model_provider), 

1223 _log_value_preview(model), 

1224 _log_value_preview(search_engine), 

1225 _log_value_preview(max_results), 

1226 _log_value_preview(time_period), 

1227 _log_value_preview(iterations), 

1228 _log_value_preview(questions_per_iteration), 

1229 # The endpoint URL can carry a credential (``?key=``, userinfo), 

1230 # so log only its scheme://host:port; the preview's cut must 

1231 # never run on the raw URL. 

1232 redact_url_for_log(custom_endpoint) 

1233 if isinstance(custom_endpoint, str) 

1234 else _log_value_preview(custom_endpoint), 

1235 _log_value_preview(strategy), 

1236 ) 

1237 

1238 # Reapply current operator environment policy at actual dispatch time. 

1239 # Queued snapshots may predate a restart or configuration change; every 

1240 # downstream consumer must see this same effective copy. 

1241 settings_snapshot = apply_environment_overrides_to_snapshot( 

1242 settings_snapshot 

1243 ) 

1244 overlay_runtime_settings( 

1245 settings_snapshot, 

1246 search_engine=search_engine, 

1247 model_provider=model_provider, 

1248 model=model, 

1249 custom_endpoint=custom_endpoint, 

1250 iterations=iterations, 

1251 questions_per_iteration=questions_per_iteration, 

1252 strategy=strategy, 

1253 max_results=max_results, 

1254 time_period=time_period, 

1255 ) 

1256 

1257 # Create a settings context that uses snapshot - no database access in threads 

1258 settings_context = SnapshotSettingsContext( 

1259 settings_snapshot, username=username 

1260 ) 

1261 

1262 if "model_provider" not in kwargs: 

1263 model_provider = settings_context.get_setting("llm.provider") 

1264 if "model" not in kwargs: 

1265 model = settings_context.get_setting("llm.model") 

1266 if ( 

1267 "custom_endpoint" not in kwargs 

1268 and normalize_provider(model_provider) == "openai_endpoint" 

1269 ): 

1270 custom_endpoint = settings_context.get_setting( 

1271 "llm.openai_endpoint.url" 

1272 ) 

1273 if "search_engine" not in kwargs: 

1274 search_engine = settings_context.get_setting("search.tool") 

1275 if "iterations" not in kwargs: 

1276 iterations = settings_context.get_setting("search.iterations") 

1277 if "questions_per_iteration" not in kwargs: 

1278 questions_per_iteration = settings_context.get_setting( 

1279 "search.questions_per_iteration" 

1280 ) 

1281 if "strategy" not in kwargs: 

1282 strategy = settings_context.get_setting( 

1283 "search.search_strategy", "source-based" 

1284 ) 

1285 

1286 # Only log settings if explicitly enabled via LDR_LOG_SETTINGS env var 

1287 from ...settings.logger import log_settings 

1288 

1289 log_settings( 

1290 settings_context.values, "SettingsContext values extracted" 

1291 ) 

1292 

1293 # Set the settings context for this thread 

1294 from ...config.thread_settings import ( 

1295 set_settings_context, 

1296 ) 

1297 

1298 set_settings_context(settings_context) 

1299 

1300 # Defense-in-depth: register an EgressContext on the audit-hook 

1301 # thread-local so socket.connect calls that bypass our explicit 

1302 # PEPs (third-party libraries, future contributors reaching for 

1303 # raw requests.get, prompt-injection-steered tools) are gated 

1304 # against the same scope. Cleared by the @thread_cleanup exit 

1305 # handler in database/thread_local_session.py when the worker 

1306 # winds down. If snapshot construction errors here, we log and 

1307 # continue — the explicit PEPs still gate the known callers, so 

1308 # the run is not less secure than before this hook existed. 

1309 try: 

1310 from ...security import set_active_context as _set_egress_ctx 

1311 from ...security.egress.policy import ( 

1312 PolicyDeniedError, 

1313 build_run_egress_context, 

1314 ) 

1315 

1316 try: 

1317 _egress_ctx = build_run_egress_context( 

1318 settings_snapshot, search_engine, username=username 

1319 ) 

1320 _egress_primary = _egress_ctx.primary_engine 

1321 _set_egress_ctx(_egress_ctx) 

1322 except (PolicyDeniedError, ValueError) as _ctx_err: 

1323 # Corrupted scope or invalid policy config. The run-start 

1324 # precheck already rejects this at the API boundary; 

1325 # if we get here it is because the run was launched 

1326 # without going through the precheck (CLI, scheduler). 

1327 # Re-raise so the worker fails fast with a clear reason 

1328 # rather than running unprotected. 

1329 logger.bind(policy_audit=True).warning( 

1330 "research worker refused: egress policy unevaluable", 

1331 research_id=research_id, 

1332 reason=str(_ctx_err), 

1333 ) 

1334 raise 

1335 

1336 # Stage C (ADR-0007): enforce the two-axis (sensitivity x exposure) 

1337 # rule at this shared chokepoint too. The /api/start_research route 

1338 # applies it before the run starts, but follow-up / chat / queue 

1339 # runs reach the worker without that precheck — so evaluate it here, 

1340 # where every entry point flows through, reusing the context 

1341 # resolved just above. UNPROTECTED evaluates permissively (never 

1342 # blocks); on an internal error audit_run fails CLOSED (a denying 

1343 # decision) so the run is refused, not silently degraded. A refusal 

1344 # raises PolicyDeniedError, failing the run like the 

1345 # unevaluable-config path. 

1346 from ...security.egress.run_classification import ( 

1347 audit_run_from_snapshot, 

1348 ) 

1349 

1350 _two_axis = audit_run_from_snapshot( 

1351 settings_snapshot, 

1352 _egress_ctx, 

1353 _egress_primary, 

1354 llm_provider=model_provider, 

1355 ) 

1356 if not _two_axis.allowed: 1356 ↛ 1357line 1356 didn't jump to line 1357 because the condition on line 1356 was never true

1357 logger.bind(policy_audit=True).warning( 

1358 "research worker refused: two-axis egress", 

1359 research_id=research_id, 

1360 reason=_two_axis.reason, 

1361 offending=_two_axis.offending, 

1362 ) 

1363 raise PolicyDeniedError(_two_axis, target=_egress_primary) 

1364 except ImportError: 

1365 # security module unavailable — preserve legacy behaviour. 

1366 logger.debug("egress audit hook unavailable for research worker") 

1367 

1368 # user_password already extracted above (before termination check) 

1369 

1370 # Create shared research context that can be updated during research 

1371 shared_research_context = { 

1372 "research_id": research_id, 

1373 "research_query": query, 

1374 "research_mode": mode, 

1375 "research_phase": "init", 

1376 "search_iteration": 0, 

1377 "search_engines_planned": None, 

1378 "search_engine_selected": search_engine, 

1379 "username": username, # Add username for queue operations 

1380 "user_password": user_password, # Add password for metrics access 

1381 # Thread-safe settings snapshot propagated to background search 

1382 # threads (engine config, per-user resolution, egress scope). 

1383 "settings_snapshot": settings_snapshot, 

1384 "chat_session_id": kwargs.get("chat_session_id"), 

1385 } 

1386 

1387 # If this is a follow-up research, include the parent context. It is 

1388 # read back from JSON, so the context and its findings may be of any 

1389 # JSON type. 

1390 parent_context = kwargs.get("research_context") 

1391 if isinstance(parent_context, dict) and parent_context: 

1392 past_findings = parent_context.get("past_findings", "") 

1393 findings_chars = ( 

1394 len(past_findings) if isinstance(past_findings, str) else 0 

1395 ) 

1396 logger.info( 

1397 f"Adding parent research context with {findings_chars} chars of findings" 

1398 ) 

1399 shared_research_context.update(parent_context) 

1400 elif parent_context: 

1401 logger.warning( 

1402 f"Ignoring non-dict research_context " 

1403 f"{type(parent_context).__name__}" 

1404 ) 

1405 

1406 # Do not log context keys as they may contain sensitive information 

1407 logger.info(f"Created shared_research_context for user: {username}") 

1408 

1409 # Set search context for search tracking 

1410 set_search_context(shared_research_context) 

1411 

1412 # Per-research dedup state for step message persistence 

1413 last_step_phase = None 

1414 

1415 # Pre-bind streaming_state so progress_callback's closure cell has a 

1416 # value even if an exception fires between this point and the full 

1417 # initialization later in this function. Without this, the except handler's 

1418 # call to progress_callback during a concurrent termination raises 

1419 # UnboundLocalError, silently skipping the DB FAILED update and the 

1420 # error socket emit. The full streaming_state is reassigned below 

1421 # before any real streaming starts; keys must match the canonical shape. 

1422 streaming_state: dict = { 

1423 "chunks_sent": 0, 

1424 "chunks": [], 

1425 "_bytes": 0, 

1426 "_truncated": False, 

1427 } 

1428 streaming_enabled = False 

1429 

1430 # Set up progress callback 

1431 def progress_callback(message, progress_percent, metadata): 

1432 nonlocal last_step_phase 

1433 # Frequent termination check 

1434 if is_termination_requested(research_id): 

1435 # Persist the partial chat row + emit final chunk BEFORE 

1436 # handle_termination — afterwards the Socket.IO room is gone. 

1437 _save_partial_chat_message_on_terminate( 

1438 shared_research_context.get("chat_session_id"), 

1439 research_id, 

1440 username, 

1441 "".join(streaming_state.get("chunks", [])), 

1442 truncated=streaming_state.get("_truncated", False), 

1443 streaming_state=streaming_state, 

1444 ) 

1445 handle_termination(research_id, username) 

1446 streaming_state["_termination_handled"] = True 

1447 raise ResearchTerminatedException( # noqa: TRY301 — inside nested callback, not caught by enclosing try 

1448 "Research was terminated by user" 

1449 ) 

1450 

1451 # Silent phase — no UI logging or socket emission needed 

1452 if metadata.get("phase") == "termination_check": 

1453 return 

1454 

1455 # Bind research_id AND username so the database_sink + queue 

1456 # daemon can resolve the per-user encrypted DB. Without username 

1457 # the daemon's _write_log_to_database hits "No authenticated 

1458 # user", silently swallows the error, and ResearchLog ends up 

1459 # with zero milestone rows — leaving /api/research/<id>/status 

1460 # without a log_entry to render and the frontend stuck on the 

1461 # "Performing research..." fallback. 

1462 bound_logger = logger.bind( 

1463 research_id=research_id, username=username 

1464 ) 

1465 bound_logger.log("MILESTONE", message) 

1466 

1467 if "SEARCH_PLAN:" in message: 

1468 engines = message.split("SEARCH_PLAN:")[1].strip() 

1469 metadata["planned_engines"] = engines 

1470 metadata["phase"] = "search_planning" # Use existing phase 

1471 # Update shared context for token tracking 

1472 shared_research_context["search_engines_planned"] = engines 

1473 shared_research_context["research_phase"] = "search_planning" 

1474 

1475 if "ENGINE_SELECTED:" in message: 

1476 engine = message.split("ENGINE_SELECTED:")[1].strip() 

1477 metadata["selected_engine"] = engine 

1478 metadata["phase"] = "search" # Use existing 'search' phase 

1479 # Update shared context for token tracking 

1480 shared_research_context["search_engine_selected"] = engine 

1481 shared_research_context["research_phase"] = "search" 

1482 

1483 # Capture other research phases for better context tracking 

1484 if metadata.get("phase"): 1484 ↛ 1488line 1484 didn't jump to line 1488 because the condition on line 1484 was always true

1485 shared_research_context["research_phase"] = metadata["phase"] 

1486 

1487 # Update search iteration if available 

1488 if "iteration" in metadata: 

1489 shared_research_context["search_iteration"] = metadata[ 

1490 "iteration" 

1491 ] 

1492 

1493 # Only run_research_process's own final emit carries this flag; 

1494 # pop it so it never reaches the socket payload or chat step. 

1495 run_complete = metadata.pop(_RUN_COMPLETE_FLAG, False) is True 

1496 

1497 # Adjust progress based on research mode 

1498 adjusted_progress = progress_percent 

1499 phase = metadata.get("phase", "") 

1500 

1501 if run_complete: 

1502 # The run's own completion emit is never remapped or capped. 

1503 pass 

1504 elif mode == "detailed": 

1505 # Report phases pass through (already mapped by wrapper). 

1506 # All other phases — including "complete" emitted by each 

1507 # strategy when its analyze_topic finishes (report 

1508 # generation runs analyze_topic per subsection, so a 

1509 # strategy "complete" fires mid-report and must NOT be 

1510 # treated as the end of the whole run) — are capped. 

1511 # update_progress_and_check_active enforces global 

1512 # monotonicity, so backwards jumps from non-monotonic 

1513 # strategy updates are absorbed there. 

1514 # None values (error path, sub-search relays) pass through 

1515 # unchanged. 

1516 if phase not in _REPORT_PHASES and progress_percent is not None: 

1517 adjusted_progress = min( 

1518 _DETAILED_SEARCH_PROGRESS_CAP, progress_percent 

1519 ) 

1520 elif ( 

1521 mode == "quick" 

1522 and phase == "output_generation" 

1523 and progress_percent is not None 

1524 ): 

1525 # For quick mode, scale output_generation to 85-95% range 

1526 if progress_percent > 0: 

1527 adjusted_progress = ( 

1528 _QUICK_OUTPUT_PROGRESS_START 

1529 + (progress_percent / 100) * 10 

1530 ) 

1531 else: 

1532 adjusted_progress = _QUICK_OUTPUT_PROGRESS_START 

1533 elif ( 

1534 mode == "quick" 

1535 and phase == "complete" 

1536 and progress_percent is not None 

1537 ): 

1538 # The strategy finished searching; answer generation (the 

1539 # 85-95% band) is still ahead. 

1540 adjusted_progress = min( 

1541 _QUICK_OUTPUT_PROGRESS_START, progress_percent 

1542 ) 

1543 

1544 # Reserve 100 for the run's completion signal. Written as a 

1545 # comparison (not min()) so a NaN passes through unchanged and 

1546 # is rejected by the registry instead of becoming 99. 

1547 if ( 

1548 not run_complete 

1549 and adjusted_progress is not None 

1550 and adjusted_progress > _IN_PROGRESS_CAP 

1551 ): 

1552 adjusted_progress = _IN_PROGRESS_CAP 

1553 

1554 # Atomically update progress and check if research is still active 

1555 if adjusted_progress is not None: 

1556 adjusted_progress, still_active = ( 

1557 update_progress_and_check_active( 

1558 research_id, adjusted_progress 

1559 ) 

1560 ) 

1561 else: 

1562 still_active = is_research_active(research_id) 

1563 

1564 if still_active: 

1565 # Queue the progress update to be processed in main thread 

1566 if adjusted_progress is not None: 

1567 from ..queue.processor_v2 import queue_processor 

1568 

1569 if username: 1569 ↛ 1574line 1569 didn't jump to line 1574 because the condition on line 1569 was always true

1570 queue_processor.queue_progress_update( 

1571 username, research_id, adjusted_progress 

1572 ) 

1573 else: 

1574 logger.warning( 

1575 f"Cannot queue progress update for research {research_id} - no username available" 

1576 ) 

1577 

1578 # Determine socket emit throttling. Completion is the 

1579 # explicit run_complete signal -- never a progress value 

1580 # (a clamped overshoot used to reach 100 mid-run) and never 

1581 # a bare phase="complete" (strategies emit that mid-run). 

1582 phase = metadata.get("phase", "") 

1583 is_final = run_complete or phase in ( 

1584 "error", 

1585 "report_complete", 

1586 ) 

1587 

1588 should_emit = is_final 

1589 if not is_final: 

1590 now = time.monotonic() 

1591 with _last_emit_lock: 

1592 last = _last_emit_times.get(research_id, 0) 

1593 if now - last >= _EMIT_THROTTLE_SECONDS: 

1594 _last_emit_times[research_id] = now 

1595 should_emit = True 

1596 # Periodic TTL cleanup for orphaned entries 

1597 global _emit_cleanup_counter # noqa: PLW0603 

1598 _emit_cleanup_counter += 1 

1599 if _emit_cleanup_counter % 100 == 0: 

1600 stale = [ 

1601 rid 

1602 for rid, t in _last_emit_times.items() 

1603 if now - t > _EMIT_TTL_SECONDS 

1604 ] 

1605 for rid in stale: 1605 ↛ 1606line 1605 didn't jump to line 1606 because the loop on line 1605 never started

1606 del _last_emit_times[rid] 

1607 

1608 # Decide chat-step persistence BEFORE building the event: 

1609 # a step that will be persisted must always emit, even 

1610 # inside the throttle window above — otherwise a burst of 

1611 # back-to-back observations (the agent issuing several tool 

1612 # calls in one turn) writes rows the live UI never showed, 

1613 # breaking the symmetry invariant in the live direction. 

1614 # 

1615 # Symmetry invariant: for chat sessions, what the user sees 

1616 # live must equal what `loadSession` reconstructs on reload. 

1617 # If dedup blocks persistence of a repeat step phase, drop 

1618 # the socket emit too (unless this is a final-phase event 

1619 # the completion handler depends on) so live UI doesn't 

1620 # surface events that vanish on reload. 

1621 _chat_session_id = shared_research_context.get( 

1622 "chat_session_id" 

1623 ) 

1624 _persist = False 

1625 _suppress_emit = False 

1626 if _chat_session_id: 

1627 _persist, _suppress_emit = _chat_step_decision( 

1628 phase, last_step_phase, is_final 

1629 ) 

1630 if _persist: 

1631 should_emit = True 

1632 

1633 # Build event data (before emit, shared scope) 

1634 event_data = None 

1635 if should_emit: 

1636 event_data = { 

1637 "progress": adjusted_progress, 

1638 "message": message, 

1639 "phase": phase, 

1640 } 

1641 # Include additional metadata for MCP/ReAct strategy display 

1642 if metadata.get("thought"): 1642 ↛ 1643line 1642 didn't jump to line 1643 because the condition on line 1642 was never true

1643 event_data["thought"] = metadata["thought"] 

1644 if metadata.get("tool"): 

1645 event_data["tool"] = metadata["tool"] 

1646 if metadata.get("arguments"): 1646 ↛ 1647line 1646 didn't jump to line 1647 because the condition on line 1646 was never true

1647 event_data["arguments"] = metadata["arguments"] 

1648 if metadata.get("iteration"): 1648 ↛ 1649line 1648 didn't jump to line 1649 because the condition on line 1648 was never true

1649 event_data["iteration"] = metadata["iteration"] 

1650 if metadata.get("error"): 

1651 event_data["error"] = metadata["error"] 

1652 if metadata.get("content"): 

1653 event_data["content"] = metadata["content"] 

1654 

1655 # Persist step message to chat session BEFORE emitting socket 

1656 # (ensures DB has the step when clients receive the event). 

1657 if _persist: 

1658 try: 

1659 from ...chat.service import ChatService 

1660 

1661 ChatService(username).add_progress_step( 

1662 session_id=_chat_session_id, 

1663 research_id=research_id, 

1664 content=_compose_chat_step_content( 

1665 message, phase, metadata 

1666 ), 

1667 phase=phase, 

1668 ) 

1669 last_step_phase = phase 

1670 except Exception: 

1671 logger.opt(exception=True).warning( 

1672 "Failed to persist progress step" 

1673 ) 

1674 # Symmetry invariant: if persistence failed 

1675 # (e.g. OperationalError under DB contention), 

1676 # suppress the live emit too — otherwise the 

1677 # client sees a step that vanishes on reload. 

1678 event_data = None 

1679 if _suppress_emit: 

1680 event_data = None 

1681 

1682 # Emit socket event AFTER DB persistence 

1683 if event_data is not None: 

1684 try: 

1685 _sio_emit( 

1686 "research_progress", 

1687 research_id, 

1688 event_data, 

1689 owner=username, 

1690 ) 

1691 except Exception: 

1692 logger.exception("Socket emit error (non-critical)") 

1693 

1694 # Function to check termination during long-running operations 

1695 def check_termination(): 

1696 if is_termination_requested(research_id): 

1697 _save_partial_chat_message_on_terminate( 

1698 shared_research_context.get("chat_session_id"), 

1699 research_id, 

1700 username, 

1701 "".join(streaming_state.get("chunks", [])), 

1702 truncated=streaming_state.get("_truncated", False), 

1703 streaming_state=streaming_state, 

1704 ) 

1705 handle_termination(research_id, username) 

1706 streaming_state["_termination_handled"] = True 

1707 raise ResearchTerminatedException( # noqa: TRY301 — inside nested callback, not caught by enclosing try 

1708 "Research was terminated by user during long-running operation" 

1709 ) 

1710 return False # Not terminated 

1711 

1712 # Configure the system with the specified parameters 

1713 use_llm = None 

1714 if model or search_engine or model_provider: 1714 ↛ 1727line 1714 didn't jump to line 1727 because the condition on line 1714 was always true

1715 # Log that we're overriding system settings 

1716 # (#6938: same request-derived values as the parameter lines 

1717 # above, so they preview instead of f-stringing.) 

1718 logger.info( 

1719 "Overriding system settings with: provider={}, " 

1720 "model={}, search_engine={}", 

1721 _log_value_preview(model_provider), 

1722 _log_value_preview(model), 

1723 _log_value_preview(search_engine), 

1724 ) 

1725 

1726 # Override LLM if model or model_provider specified 

1727 if model or model_provider: 

1728 try: 

1729 # Get LLM with the overridden settings. 

1730 # Pass settings_snapshot explicitly — without it the LLM 

1731 # PEP (llm_config.py:295) skips evaluate_llm_endpoint 

1732 # because the gate is "if settings_snapshot is not None 

1733 # and provider:". The snapshot is already stored on 

1734 # shared_research_context as "settings_snapshot". 

1735 use_llm = get_llm( 

1736 model_name=model, 

1737 provider=model_provider, 

1738 openai_endpoint_url=custom_endpoint, 

1739 research_id=research_id, 

1740 research_context=shared_research_context, 

1741 settings_snapshot=shared_research_context.get( 

1742 "settings_snapshot" 

1743 ), 

1744 # Thread the run's user so the LLM PEP can resolve a 

1745 # per-user registered provider and enforce local-only 

1746 # policy. The shared_research_context snapshot is captured 

1747 # before AdvancedSearchSystem injects `_username`, so 

1748 # without this the whole overridden run (question gen, 

1749 # summaries, final report) could fail open to a cloud LLM 

1750 # for a user whose primary is a private retriever. 

1751 username=username, 

1752 ) 

1753 

1754 logger.info( 

1755 "Successfully set LLM to: provider={}, model={}", 

1756 _log_value_preview(model_provider), 

1757 _log_value_preview(model), 

1758 ) 

1759 except Exception as e: 

1760 logger.exception( 

1761 "Error setting LLM provider={}, model={}", 

1762 _log_value_preview(model_provider), 

1763 _log_value_preview(model), 

1764 ) 

1765 error_msg = str(e) 

1766 # Surface configuration errors to user instead of silently continuing 

1767 config_error_keywords = [ 

1768 "model path", 

1769 "llamacpp", 

1770 "cannot connect", 

1771 "server", 

1772 "not configured", 

1773 "not responding", 

1774 "directory", 

1775 ".gguf", 

1776 ] 

1777 if any( 

1778 keyword in error_msg.lower() 

1779 for keyword in config_error_keywords 

1780 ): 

1781 # This is a configuration error the user can fix. 

1782 # 

1783 # Preserve ``LLMNotConfiguredError`` when that is what 

1784 # ``get_llm`` raised. The keyword match below is a 

1785 # string test over ``str(e)``, and re-raising a plain 

1786 # ``ValueError`` threw the subclass away: a caller that 

1787 # distinguishes "no model configured" from "bad request" 

1788 # BY TYPE (see web/routers/notes.py's 

1789 # ``_llm_not_configured_response``) could no longer tell 

1790 # them apart downstream of this wrapper, and a fresh 

1791 # install's missing ``llm.model`` -- which matches the 

1792 # "not configured" keyword -- was exactly the case that 

1793 # lost it. 

1794 # 

1795 # Only *preserved*, never *added*: several keywords here 

1796 # ("cannot connect", "server", "not responding") match 

1797 # runtime failures of a provider that IS configured, and 

1798 # promoting those to ``LLMNotConfiguredError`` would make 

1799 # a boundary answer "open Settings and choose a model" to 

1800 # a user whose model is already chosen. Both branches 

1801 # stay ``ValueError`` instances, so every existing 

1802 # ``except ValueError`` caller is unaffected. 

1803 error_type = ( 

1804 LLMNotConfiguredError 

1805 if isinstance(e, LLMNotConfiguredError) 

1806 else ValueError 

1807 ) 

1808 raise error_type( 

1809 f"LLM Configuration Error: {error_msg}" 

1810 ) from e 

1811 # For other errors, re-raise to avoid silent failures 

1812 raise 

1813 

1814 # Create search engine first if specified, to avoid default creation without username 

1815 use_search = None 

1816 if search_engine: 1816 ↛ 1862line 1816 didn't jump to line 1862 because the condition on line 1816 was always true

1817 try: 

1818 # Create a new search object with these settings 

1819 use_search = get_search( 

1820 search_tool=search_engine, 

1821 llm_instance=use_llm, 

1822 username=username, 

1823 settings_snapshot=settings_snapshot, 

1824 ) 

1825 logger.info( 

1826 "Successfully created search engine: {}", 

1827 _log_value_preview(search_engine), 

1828 ) 

1829 except Exception as e: 

1830 logger.exception( 

1831 "Error creating search engine {}", 

1832 _log_value_preview(search_engine), 

1833 ) 

1834 error_msg = str(e) 

1835 # Surface configuration errors to user instead of silently continuing 

1836 config_error_keywords = [ 

1837 "searxng", 

1838 "instance_url", 

1839 "api_key", 

1840 "cannot connect", 

1841 "connection", 

1842 "timeout", 

1843 "not configured", 

1844 ] 

1845 if any( 

1846 keyword in error_msg.lower() 

1847 for keyword in config_error_keywords 

1848 ): 

1849 # This is a configuration error the user can fix. 

1850 # (#6938: the engine name is request-derived, so the 

1851 # raised message carries its bounded preview — the 

1852 # ValueError type and the keyword gate above it are 

1853 # unchanged.) 

1854 raise ValueError( 

1855 f"Search Engine Configuration Error " 

1856 f"({_log_value_preview(search_engine)}): {error_msg}" 

1857 ) from e 

1858 # For other errors, re-raise to avoid silent failures 

1859 raise 

1860 

1861 # Set the progress callback in the system 

1862 system = AdvancedSearchSystem( 

1863 llm=use_llm, # type: ignore[arg-type] 

1864 search=use_search, # type: ignore[arg-type] 

1865 strategy_name=strategy, 

1866 max_iterations=iterations, 

1867 questions_per_iteration=questions_per_iteration, 

1868 username=username, 

1869 settings_snapshot=settings_snapshot, 

1870 research_id=research_id, 

1871 research_context=shared_research_context, 

1872 ) 

1873 system.set_progress_callback(progress_callback) 

1874 

1875 # Chat mode: set up LLM streaming callback for real-time response chunks 

1876 streaming_enabled = False 

1877 # chunks: server-side buffer so partial content survives termination 

1878 # (the citation handler's local list is discarded on raise). 

1879 # _bytes / _truncated cap the buffer to bound memory on pathologically 

1880 # long answers; once capped we still forward to the frontend but stop 

1881 # accumulating server-side. 

1882 streaming_state = { 

1883 "chunks_sent": 0, 

1884 "chunks": [], 

1885 "_bytes": 0, 

1886 "_truncated": False, 

1887 } 

1888 chat_session_id = shared_research_context.get("chat_session_id") 

1889 

1890 if chat_session_id: 

1891 try: 

1892 socket_service = _socket_emitter 

1893 

1894 # Source resolver returns the strategy's currently-collected 

1895 # source list. Late-bound so the streaming callback can 

1896 # apply inline hyperlinks as the agent finishes adding to 

1897 # all_links_of_system (sources may still be growing when 

1898 # synthesis starts; reading via the closure picks up the 

1899 # final list at chunk-emit time, not callback-build time). 

1900 def _resolve_sources(): 

1901 if not hasattr(system, "all_links_of_system"): 1901 ↛ 1902line 1901 didn't jump to line 1902 because the condition on line 1901 was never true

1902 return [] 

1903 return list(system.all_links_of_system or []) 

1904 

1905 # Build a formatter matching the user's report.citation_format 

1906 # so live-display brackets ([[arxiv.org-1]] / [[arxiv-1]] / 

1907 # [[1]] etc.) match what the final-save formatter will emit 

1908 # — avoids a visible format-flip when handleResearchComplete 

1909 # swaps in the DB-saved version. 

1910 live_formatter = get_citation_formatter() 

1911 

1912 stream_callback = _make_chat_stream_callback( 

1913 research_id, 

1914 streaming_state, 

1915 socket_service, 

1916 username, 

1917 source_resolver=_resolve_sources, 

1918 formatter=live_formatter, 

1919 ) 

1920 

1921 # Hook into the citation handler's streaming 

1922 if hasattr(system, "strategy") and hasattr( 

1923 system.strategy, "citation_handler" 

1924 ): 

1925 handler = system.strategy.citation_handler 

1926 if hasattr(handler, "set_stream_callback"): 1926 ↛ 1941line 1926 didn't jump to line 1941 because the condition on line 1926 was always true

1927 handler.set_stream_callback(stream_callback) 

1928 streaming_enabled = True 

1929 logger.info( 

1930 f"Streaming enabled for chat {chat_session_id[:8]}..." 

1931 ) 

1932 except Exception: 

1933 # exception=True so the traceback is visible: streaming is 

1934 # non-critical (research still completes without it), but a 

1935 # silent warning would hide real bugs in the setup above. 

1936 logger.opt(exception=True).warning( 

1937 "Could not set up streaming (non-critical)" 

1938 ) 

1939 

1940 # Helper to save chat message (closes over outer scope vars) 

1941 def _maybe_save_chat_message(content): 

1942 _chat_sid = shared_research_context.get("chat_session_id") 

1943 if not _chat_sid: 

1944 return 

1945 try: 

1946 _save_chat_message_and_context( 

1947 _chat_sid, 

1948 research_id, 

1949 username, 

1950 content, 

1951 streaming_enabled, 

1952 streaming_state, 

1953 _socket_emitter, 

1954 settings_snapshot=settings_snapshot, 

1955 ) 

1956 except Exception: 

1957 # Promoted from debug→warning: at debug level this is invisible 

1958 # in production and the user sees "Research completed but no 

1959 # report available" with no operator signal. Same rationale as 

1960 # the accumulated_context handler in _save_chat_message_and_context. 

1961 logger.opt(exception=True).warning( 

1962 "Could not add message to chat session — assistant " 

1963 "response NOT persisted; user will see 'no report available'" 

1964 ) 

1965 # The DB write failed, so _save_chat_message_and_context never 

1966 # emitted its is_final response_chunk. Emit one here so the 

1967 # streaming UI clears its 'thinking' state instead of stalling. 

1968 try: 

1969 _socket_emitter.emit_to_subscribers( 

1970 "response_chunk", 

1971 research_id, 

1972 {"chunk": "", "is_streaming": True, "is_final": True}, 

1973 owner=username, 

1974 ) 

1975 except Exception: 

1976 logger.opt(exception=True).debug( 

1977 "Failed to emit final chunk after chat-persist error" 

1978 ) 

1979 

1980 # Run the search 

1981 progress_callback("Starting research process", 5, {"phase": "init"}) 

1982 

1983 try: 

1984 results = system.analyze_topic(query) 

1985 if mode == "quick": 

1986 progress_callback( 

1987 "Search complete, preparing to generate summary...", 

1988 85, 

1989 {"phase": "output_generation"}, 

1990 ) 

1991 else: 

1992 progress_callback( 

1993 "Search complete, generating output", 

1994 80, 

1995 {"phase": "output_generation"}, 

1996 ) 

1997 except Exception as search_error: 

1998 # Better handling of specific search errors 

1999 error_message = str(search_error) 

2000 error_type = "unknown" 

2001 

2002 # OpenAI-compatible runtime failures (LM Studio / vLLM / llama.cpp 

2003 # server / OpenRouter / custom endpoint) -- rewrite to a message 

2004 # that names the provider, base URL, and model (#3878). 

2005 # Normalise first: a legacy upper-case stored provider 

2006 # ("QWEN") must still get the rewrite. 

2007 rewrite_provider = normalize_provider(model_provider) 

2008 if ( 

2009 rewrite_provider in _OPENAI_COMPAT_RUNTIME_ERROR_PROVIDERS 

2010 and is_openai_compat_runtime_error(search_error) 

2011 ): 

2012 rewritten = friendly_openai_compatible_error( 

2013 search_error, 

2014 provider=rewrite_provider, 

2015 base_url=_resolve_error_rewrite_base_url( 

2016 rewrite_provider, custom_endpoint, settings_context 

2017 ), 

2018 model=model, 

2019 ) 

2020 raise RuntimeError(rewritten) from search_error 

2021 

2022 # Extract error details for common issues 

2023 if "status code: 503" in error_message: 

2024 error_message = "Ollama AI service is unavailable (HTTP 503). Please check that Ollama is running properly on your system." 

2025 error_type = "ollama_unavailable" 

2026 elif "status code: 404" in error_message: 

2027 error_message = "Ollama model not found (HTTP 404). Please check that you have pulled the required model." 

2028 error_type = "model_not_found" 

2029 elif "status code:" in error_message: 

2030 # Extract the status code for other HTTP errors 

2031 status_code = error_message.split("status code:")[1].strip() 

2032 error_message = f"API request failed with status code {status_code}. Please check your configuration." 

2033 error_type = "api_error" 

2034 elif "connection" in error_message.lower(): 

2035 error_message = "Connection error. Please check that your LLM service (Ollama/API) is running and accessible." 

2036 error_type = "connection_error" 

2037 

2038 # Raise with improved error message 

2039 raise RuntimeError( 

2040 f"{error_message} (Error type: {error_type})" 

2041 ) from search_error 

2042 

2043 # Generate output based on mode 

2044 if mode == "quick": 

2045 # Quick Summary 

2046 if results.get("findings") or results.get("formatted_findings"): 

2047 raw_formatted_findings = results["formatted_findings"] 

2048 

2049 # Check if formatted_findings contains an error message 

2050 if _is_error_shaped_content(raw_formatted_findings): 

2051 logger.error("Detected error in formatted findings") 

2052 

2053 # Determine error type for better user feedback 

2054 error_type = "unknown" 

2055 error_message = raw_formatted_findings.lower() 

2056 

2057 if ( 

2058 "token limit" in error_message 

2059 or "context length" in error_message 

2060 ): 

2061 error_type = "token_limit" 

2062 # Log specific error type 

2063 logger.warning( 

2064 "Detected token limit error in synthesis" 

2065 ) 

2066 

2067 # Update progress with specific error type 

2068 progress_callback( 

2069 "Synthesis hit token limits. Attempting fallback...", 

2070 87, 

2071 { 

2072 "phase": "synthesis_error", 

2073 "error_type": error_type, 

2074 }, 

2075 ) 

2076 elif ( 

2077 "timeout" in error_message 

2078 or "timed out" in error_message 

2079 ): 

2080 error_type = "timeout" 

2081 logger.warning("Detected timeout error in synthesis") 

2082 progress_callback( 

2083 "Synthesis timed out. Attempting fallback...", 

2084 87, 

2085 { 

2086 "phase": "synthesis_error", 

2087 "error_type": error_type, 

2088 }, 

2089 ) 

2090 elif "rate limit" in error_message: 

2091 error_type = "rate_limit" 

2092 logger.warning("Detected rate limit error in synthesis") 

2093 progress_callback( 

2094 "LLM rate limit reached. Attempting fallback...", 

2095 87, 

2096 { 

2097 "phase": "synthesis_error", 

2098 "error_type": error_type, 

2099 }, 

2100 ) 

2101 elif ( 

2102 "connection" in error_message 

2103 or "network" in error_message 

2104 ): 

2105 error_type = "connection" 

2106 logger.warning("Detected connection error in synthesis") 

2107 progress_callback( 

2108 "Connection issue with LLM. Attempting fallback...", 

2109 87, 

2110 { 

2111 "phase": "synthesis_error", 

2112 "error_type": error_type, 

2113 }, 

2114 ) 

2115 elif ( 

2116 "llm error" in error_message 

2117 or "final answer synthesis fail" in error_message 

2118 ): 

2119 error_type = "llm_error" 

2120 logger.warning( 

2121 "Detected general LLM error in synthesis" 

2122 ) 

2123 progress_callback( 

2124 "LLM error during synthesis. Attempting fallback...", 

2125 87, 

2126 { 

2127 "phase": "synthesis_error", 

2128 "error_type": error_type, 

2129 }, 

2130 ) 

2131 else: 

2132 # Generic error 

2133 logger.warning("Detected unknown error in synthesis") 

2134 progress_callback( 

2135 "Error during synthesis. Attempting fallback...", 

2136 87, 

2137 { 

2138 "phase": "synthesis_error", 

2139 "error_type": "unknown", 

2140 }, 

2141 ) 

2142 

2143 # Extract synthesized content from findings if available 

2144 synthesized_content = "" 

2145 for finding in results.get("findings", []): 

2146 if finding.get("phase") == "Final synthesis": 

2147 synthesized_content = finding.get("content", "") 

2148 break 

2149 

2150 # Use synthesized content as fallback 

2151 if synthesized_content and not _is_error_shaped_content( 

2152 synthesized_content 

2153 ): 

2154 logger.info( 

2155 "Using existing synthesized content as fallback" 

2156 ) 

2157 raw_formatted_findings = synthesized_content 

2158 

2159 # Or use current_knowledge as another fallback 

2160 elif results.get("current_knowledge") and not ( 

2161 _is_error_shaped_content(results["current_knowledge"]) 

2162 ): 

2163 logger.info("Using current_knowledge as fallback") 

2164 raw_formatted_findings = results["current_knowledge"] 

2165 

2166 # Or combine all finding contents as last resort 

2167 elif results.get("findings"): 

2168 logger.info("Combining all findings as fallback") 

2169 # First try to use any findings that are not errors 

2170 valid_findings = [ 

2171 f"## {finding.get('phase', 'Finding')}\n\n{finding.get('content', '')}" 

2172 for finding in results.get("findings", []) 

2173 if finding.get("content") 

2174 and not _is_error_shaped_content( 

2175 finding.get("content") 

2176 ) 

2177 ] 

2178 

2179 if valid_findings: 

2180 raw_formatted_findings = ( 

2181 "# Research Results (Fallback Mode)\n\n" 

2182 ) 

2183 raw_formatted_findings += "\n\n".join( 

2184 valid_findings 

2185 ) 

2186 raw_formatted_findings += "\n\n*Final synthesis failed; showing partial findings.*" 

2187 # If every finding is error-shaped, keep the failure 

2188 # as private input to ErrorReportGenerator below. Its 

2189 # authored replacements retain useful category hints 

2190 # without echoing raw provider details into the report. 

2191 

2192 progress_callback( 

2193 f"Using fallback synthesis due to {error_type} error", 

2194 88, 

2195 { 

2196 "phase": "synthesis_fallback", 

2197 "error_type": error_type, 

2198 }, 

2199 ) 

2200 

2201 logger.info( 

2202 "Found formatted_findings of length: {}", 

2203 len(str(raw_formatted_findings)), 

2204 ) 

2205 

2206 try: 

2207 # Check if we have an error in the findings and use enhanced error handling 

2208 if _is_error_shaped_content(raw_formatted_findings): 

2209 logger.info( 

2210 "Generating enhanced error report using ErrorReportGenerator" 

2211 ) 

2212 

2213 # Generate comprehensive error report 

2214 # ErrorReportGenerator does not use LLM (kept for compat) 

2215 error_generator = ErrorReportGenerator() 

2216 clean_markdown = error_generator.generate_error_report( 

2217 error_message=raw_formatted_findings, 

2218 query=query, 

2219 partial_results=results, 

2220 search_iterations=results.get("iterations", 0), 

2221 research_id=research_id, 

2222 ) 

2223 

2224 logger.info( 

2225 "Generated enhanced error report with {} characters", 

2226 len(clean_markdown), 

2227 ) 

2228 else: 

2229 # report_content stores the synthesized answer 

2230 # only (see _extract_synthesized_answer for the 

2231 # full rationale). Fall back to the formatted 

2232 # blob only when neither Final synthesis nor 

2233 # current_knowledge is populated — leaks 

2234 # sources, but at least we save *something*. 

2235 clean_markdown = ( 

2236 _extract_synthesized_answer(results) 

2237 or raw_formatted_findings 

2238 ) 

2239 

2240 # Pull sources from the search-system's accumulated link 

2241 # buffer first — same source the detailed-report path 

2242 # uses at the equivalent point below. Wrapper 

2243 # strategies (e.g. EnhancedContextualFollowUpStrategy, 

2244 # IterativeRefinementStrategy) delegate the actual 

2245 # search to an inner strategy that populates 

2246 # `self.all_links_of_system`, but they don't bubble 

2247 # that list back into the result dict's `findings`. So 

2248 # the legacy `findings[*].search_results` extraction 

2249 # below stays empty for chat follow-ups, leaving the 

2250 # citation formatter with no urls to hyperlink. Prefer 

2251 # the system-level accumulator; fall back to the 

2252 # legacy extraction so direct strategies that bypass 

2253 # the system buffer still work. 

2254 all_links = list( 

2255 getattr(system, "all_links_of_system", None) or [] 

2256 ) 

2257 if not all_links: 

2258 for finding in results.get("findings", []): 

2259 search_results = finding.get("search_results", []) 

2260 if search_results: 

2261 try: 

2262 links = extract_links_from_search_results( 

2263 search_results 

2264 ) 

2265 all_links.extend(links) 

2266 except Exception: 

2267 logger.exception( 

2268 "Error processing search results/links" 

2269 ) 

2270 

2271 logger.info( 

2272 "Successfully converted to clean markdown of length: {}", 

2273 len(clean_markdown), 

2274 ) 

2275 

2276 # First send a progress update for generating the summary 

2277 progress_callback( 

2278 "Generating clean summary from research data...", 

2279 90, 

2280 {"phase": "output_generation"}, 

2281 ) 

2282 

2283 # Send progress update for saving report 

2284 progress_callback( 

2285 "Saving research report to database...", 

2286 95, 

2287 {"phase": "report_complete"}, 

2288 ) 

2289 

2290 # Format citations in the markdown content. The 

2291 # split returns the answer-with-hyperlinks half 

2292 # separately from the trailing sources section the 

2293 # LLM may have emitted (which is discarded — sources 

2294 # live in research_resources, the canonical store). 

2295 # When no Sources section is found, fall back to 

2296 # structured-source hyperlinking — never re-parse 

2297 # concatenated formatter output downstream. 

2298 formatter = get_citation_formatter() 

2299 try: 

2300 # Quick-mode (chat summary) input is raw LLM 

2301 # output; the report generator never wraps it 

2302 # in the appended-sources sentinel, so disable 

2303 # sentinel trust. Otherwise an LLM that quotes 

2304 # the marker verbatim would silently trip the 

2305 # over-strip safety bypass on a path where the 

2306 # bypass can only ever fire as a false-positive. 

2307 answer_with_links, llm_sources, on_sentinel = ( 

2308 formatter.format_document_split( 

2309 clean_markdown, trust_sentinel=False 

2310 ) 

2311 ) 

2312 if not llm_sources: 

2313 answer_with_links = ( 

2314 formatter.apply_inline_hyperlinks( 

2315 clean_markdown, all_links 

2316 ) 

2317 ) 

2318 # Safety check: a >50% strip on a long input 

2319 # likely means the regex over-stripped on a 

2320 # "Sources:" header inside the answer body. 

2321 # Fall back to structured-source hyperlinking 

2322 # on the full text. Min-length floor prevents 

2323 # false-fires on legitimately short answers. 

2324 # Skip the check when the boundary came from 

2325 # the unique appended-sources sentinel emitted 

2326 # by IntegratedReportGenerator — that boundary 

2327 # is correct by construction (e.g. a 1380-source 

2328 # detailed-mode run legitimately pushes the 

2329 # answer/sources ratio below 50% without any 

2330 # over-strip having occurred). Quick-mode 

2331 # always passes trust_sentinel=False above, so 

2332 # ``on_sentinel`` can never be True here. 

2333 SAFETY_MIN_LEN = 800 

2334 if ( 2334 ↛ 2341line 2334 didn't jump to line 2341 because the condition on line 2334 was never true

2335 not on_sentinel 

2336 and llm_sources 

2337 and len(clean_markdown) > SAFETY_MIN_LEN 

2338 and len(answer_with_links) 

2339 < len(clean_markdown) * 0.5 

2340 ): 

2341 logger.warning( 

2342 "format_document_split appears to have " 

2343 "over-stripped (answer={} chars, " 

2344 "original={} chars) for research {}. " 

2345 "Falling back to structured-source " 

2346 "hyperlinking on full input.", 

2347 len(answer_with_links), 

2348 len(clean_markdown), 

2349 research_id, 

2350 ) 

2351 answer_with_links = ( 

2352 formatter.apply_inline_hyperlinks( 

2353 clean_markdown, all_links 

2354 ) 

2355 ) 

2356 except Exception: 

2357 # Hyperlinking is quality-of-life, not a hard 

2358 # requirement. If anything blows up, save the 

2359 # raw LLM text rather than fail the research. 

2360 logger.exception( 

2361 "Citation formatter failed for research {}; saving raw answer", 

2362 research_id, 

2363 ) 

2364 answer_with_links = clean_markdown 

2365 

2366 # report_content stores ONLY the synthesized answer. 

2367 # The legacy "answer + ## Sources + ## Research 

2368 # Metrics" view is reconstructed at render time by 

2369 # report_assembly_service.assemble_full_report. 

2370 full_report_content = answer_with_links 

2371 

2372 # Save report FIRST, then sources: 

2373 # a chat read between commits sees a report with no 

2374 # sources (assembler renders just the answer) — better 

2375 # failure mode than partial assembly with sources but 

2376 # no answer body. 

2377 from ...storage import get_report_storage 

2378 

2379 with get_user_db_session(username) as db_session: 

2380 storage = get_report_storage(session=db_session) 

2381 

2382 # Prepare metadata 

2383 metadata = { 

2384 "iterations": results["iterations"], 

2385 "generated_at": datetime.now(UTC).isoformat(), 

2386 } 

2387 

2388 # Save report using storage abstraction 

2389 success = storage.save_report( 

2390 research_id=research_id, 

2391 content=full_report_content, 

2392 metadata=metadata, 

2393 username=username, 

2394 ) 

2395 

2396 if not success: 

2397 raise RuntimeError("Failed to save research report") # noqa: TRY301 — triggers research failure handling in outer except 

2398 

2399 # Save sources to database (non-fatal - report 

2400 # already saved; sources missing is recoverable 

2401 # because the assembler omits empty Sources blocks) 

2402 try: 

2403 from .research_sources_service import ( 

2404 ResearchSourcesService, 

2405 ) 

2406 

2407 sources_service = ResearchSourcesService() 

2408 if all_links: 

2409 logger.info( 

2410 f"Quick summary: Saving {len(all_links)} sources to database" 

2411 ) 

2412 sources_saved = ( 

2413 sources_service.save_research_sources( 

2414 research_id=research_id, 

2415 sources=all_links, 

2416 username=username, 

2417 ) 

2418 ) 

2419 logger.info( 

2420 f"Quick summary: Saved {sources_saved} sources for research {research_id}" 

2421 ) 

2422 except Exception: 

2423 logger.exception( 

2424 f"Failed to save sources for research {research_id} (continuing with report save)" 

2425 ) 

2426 

2427 logger.info(f"Report saved for research_id: {research_id}") 

2428 

2429 # Skip export to additional formats - we're storing in database only 

2430 

2431 # Update research status in database 

2432 completed_at = datetime.now(UTC).isoformat() 

2433 

2434 with get_user_db_session(username) as db_session: 

2435 research = ( 

2436 db_session.query(ResearchHistory) 

2437 .filter_by(id=research_id) 

2438 .first() 

2439 ) 

2440 

2441 # Preserve existing metadata and update with new values 

2442 # (snapshot back-fill included — see helper). 

2443 metadata = _merge_completion_metadata( 

2444 research.research_meta, 

2445 { 

2446 "iterations": results["iterations"], 

2447 "generated_at": datetime.now(UTC).isoformat(), 

2448 }, 

2449 settings_snapshot, 

2450 ) 

2451 

2452 # Use the helper function for consistent duration calculation 

2453 duration_seconds = calculate_duration( 

2454 research.created_at, completed_at 

2455 ) 

2456 

2457 research.status = ResearchStatus.COMPLETED 

2458 research.progress = 100 

2459 research.completed_at = completed_at 

2460 research.duration_seconds = duration_seconds 

2461 # report_content was already saved above via the report 

2462 # storage abstraction; this block only updates 

2463 # status/metadata. report_path is not used in the 

2464 # encrypted-database version. 

2465 

2466 # Generate headline and topics only for news searches 

2467 if ( 

2468 metadata.get("is_news_search") 

2469 or metadata.get("search_type") == "news_analysis" 

2470 ): 

2471 try: 

2472 from ...news.utils.headline_generator import ( 

2473 generate_headline, 

2474 ) 

2475 from ...news.utils.topic_generator import ( 

2476 generate_topics, 

2477 ) 

2478 from ...search_system import ( 

2479 ensure_snapshot_username, 

2480 ) 

2481 

2482 # Scope the snapshot to this run's owner so 

2483 # the LLM PEP resolves a per-user provider 

2484 # and honors local-only policy, mirroring 

2485 # the scheduler's _store_research_result fix 

2486 # (background.py). Without it, headline/topic 

2487 # generation silently no-ops for cloud- 

2488 # provider users here (fails closed, not a 

2489 # security hole, but inconsistent). 

2490 headline_topic_snapshot = ( 

2491 ensure_snapshot_username( 

2492 settings_snapshot, username 

2493 ) 

2494 ) 

2495 

2496 # Get the report content from database for better headline/topic generation 

2497 report_content = "" 

2498 try: 

2499 research = ( 

2500 db_session.query(ResearchHistory) 

2501 .filter_by(id=research_id) 

2502 .first() 

2503 ) 

2504 if research and research.report_content: 2504 ↛ 2510line 2504 didn't jump to line 2510 because the condition on line 2504 was always true

2505 report_content = research.report_content 

2506 logger.info( 

2507 f"Retrieved {len(report_content)} chars from database for headline generation" 

2508 ) 

2509 else: 

2510 logger.warning( 

2511 f"No report content found in database for research_id: {research_id}" 

2512 ) 

2513 except Exception: 

2514 logger.warning( 

2515 "Could not retrieve report content from database" 

2516 ) 

2517 

2518 # Generate headline 

2519 logger.info( 

2520 "Generating headline for query: {}", 

2521 # (#6938: redacted before it is cut.) 

2522 redact_and_bound_for_log(query, 100), 

2523 ) 

2524 headline = generate_headline( 

2525 query, 

2526 report_content, 

2527 settings_snapshot=headline_topic_snapshot, 

2528 ) 

2529 metadata["generated_headline"] = headline 

2530 

2531 # Generate topics 

2532 # (#6938: the category comes from the 

2533 # request body's metadata.) 

2534 logger.info( 

2535 "Generating topics with category: {}", 

2536 _log_value_preview( 

2537 metadata.get("category", "News") 

2538 ), 

2539 ) 

2540 topics = generate_topics( 

2541 query=query, 

2542 findings=report_content, 

2543 category=metadata.get("category", "News"), 

2544 max_topics=6, 

2545 settings_snapshot=headline_topic_snapshot, 

2546 ) 

2547 metadata["generated_topics"] = topics 

2548 

2549 logger.info(f"Generated headline: {headline}") 

2550 logger.info(f"Generated topics: {topics}") 

2551 

2552 except Exception: 

2553 logger.warning( 

2554 "Could not generate headline/topics" 

2555 ) 

2556 

2557 research.research_meta = metadata 

2558 

2559 db_session.commit() 

2560 logger.info( 

2561 f"Database commit completed for research_id: {research_id}" 

2562 ) 

2563 

2564 # Update subscription if this was triggered by a subscription 

2565 if metadata.get("subscription_id"): 

2566 try: 

2567 from ...news.subscription_runner import ( 

2568 advance_refresh_schedule_by_id, 

2569 ) 

2570 

2571 subscription_id = metadata["subscription_id"] 

2572 if advance_refresh_schedule_by_id( 2572 ↛ 2584line 2572 didn't jump to line 2584

2573 db_session, subscription_id 

2574 ): 

2575 db_session.commit() 

2576 logger.info( 

2577 f"Updated subscription {subscription_id} refresh times" 

2578 ) 

2579 except Exception: 

2580 logger.warning( 

2581 "Could not update subscription refresh time" 

2582 ) 

2583 

2584 logger.info( 

2585 f"Database updated successfully for research_id: {research_id}" 

2586 ) 

2587 

2588 _maybe_save_chat_message(full_report_content) 

2589 

2590 # Send the final completion message 

2591 progress_callback( 

2592 "Research completed successfully", 

2593 100, 

2594 {"phase": "complete", _RUN_COMPLETE_FLAG: True}, 

2595 ) 

2596 

2597 # Clean up resources 

2598 logger.info( 

2599 "Cleaning up resources for research_id: {}", research_id 

2600 ) 

2601 cleanup_research_resources( 

2602 research_id, username, user_password=user_password 

2603 ) 

2604 logger.info( 

2605 "Resources cleaned up for research_id: {}", research_id 

2606 ) 

2607 

2608 except Exception as inner_e: 

2609 logger.exception("Error during quick summary generation") 

2610 raise RuntimeError( 

2611 f"Error generating quick summary: {inner_e!s}" 

2612 ) 

2613 else: 

2614 raise RuntimeError( # noqa: TRY301 — triggers research failure handling in outer except 

2615 "No research findings were generated. Please try again." 

2616 ) 

2617 else: 

2618 # Full Report 

2619 progress_callback( 

2620 "Generating detailed report...", 

2621 _DETAILED_REPORT_PROGRESS_START, 

2622 {"phase": "report_generation"}, 

2623 ) 

2624 

2625 # Extract the search system from the results if available 

2626 search_system = results.get("search_system", None) 

2627 

2628 # Wrapper that maps report generator's 0-100% to the configured 

2629 # detailed-mode range and relays cancellation checks through the 

2630 # outer progress_callback 

2631 _report_range = ( 

2632 _DETAILED_REPORT_PROGRESS_END - _DETAILED_REPORT_PROGRESS_START 

2633 ) 

2634 

2635 def report_progress_callback(message, progress_percent, metadata): 

2636 if progress_percent is not None: 

2637 adjusted = ( 

2638 _DETAILED_REPORT_PROGRESS_START 

2639 + (progress_percent / 100) * _report_range 

2640 ) 

2641 else: 

2642 adjusted = progress_percent 

2643 progress_callback(message, adjusted, metadata) 

2644 

2645 # Pass the existing search system to maintain citation indices 

2646 report_generator = IntegratedReportGenerator( 

2647 search_system=search_system, 

2648 settings_snapshot=settings_snapshot, 

2649 ) 

2650 final_report = report_generator.generate_report( 

2651 results, query, progress_callback=report_progress_callback 

2652 ) 

2653 

2654 progress_callback( 

2655 "Report generation complete", 

2656 _DETAILED_REPORT_PROGRESS_END, 

2657 {"phase": "report_complete"}, 

2658 ) 

2659 

2660 # Format citations and split off the trailing Sources 

2661 # section. Save only the answer half — sources are 

2662 # persisted structurally to research_resources. 

2663 all_links = ( 

2664 getattr(search_system, "all_links_of_system", None) or [] 

2665 ) 

2666 formatter = get_citation_formatter() 

2667 try: 

2668 # Detailed-mode input comes from 

2669 # ``IntegratedReportGenerator._format_final_report``, 

2670 # which wraps the appended ``## Sources`` block in the 

2671 # unique sentinel. Trust the sentinel so a legitimate 

2672 # large-tail run (e.g. 1380 sources) doesn't trip the 

2673 # over-strip safety check on the right answer half. 

2674 answer_with_links, llm_sources, on_sentinel = ( 

2675 formatter.format_document_split(final_report["content"]) 

2676 ) 

2677 if not llm_sources: 

2678 answer_with_links = formatter.apply_inline_hyperlinks( 

2679 final_report["content"], all_links 

2680 ) 

2681 # Skip the over-strip safety check when the boundary 

2682 # came from the unique appended-sources sentinel emitted 

2683 # by IntegratedReportGenerator — that boundary is 

2684 # correct by construction (e.g. a 1380-source detailed 

2685 # run legitimately pushes the answer/sources ratio 

2686 # below 50% without any over-strip having occurred). 

2687 SAFETY_MIN_LEN = 800 

2688 if ( 

2689 not on_sentinel 

2690 and llm_sources 

2691 and len(final_report["content"]) > SAFETY_MIN_LEN 

2692 and len(answer_with_links) 

2693 < len(final_report["content"]) * 0.5 

2694 ): 

2695 logger.warning( 

2696 "format_document_split appears to have over-stripped " 

2697 "(answer={} chars, original={} chars) for research {}.", 

2698 len(answer_with_links), 

2699 len(final_report["content"]), 

2700 research_id, 

2701 ) 

2702 answer_with_links = formatter.apply_inline_hyperlinks( 

2703 final_report["content"], all_links 

2704 ) 

2705 except Exception: 

2706 logger.exception( 

2707 "Citation formatter failed for research {}; saving raw answer", 

2708 research_id, 

2709 ) 

2710 answer_with_links = final_report["content"] 

2711 # The answer keeps the generator's appended-sources sentinel 

2712 # whenever the split above kept the Sources block with it 

2713 # (NO_HYPERLINKS mode, or the formatter raised). It is an HTML 

2714 # comment, which renderMarkdown() shows as literal text. 

2715 formatted_content = strip_appended_sources_sentinel( 

2716 answer_with_links 

2717 ) 

2718 

2719 # Save report FIRST, sources after. 

2720 # See quick-summary path for rationale. 

2721 from ...storage import get_report_storage 

2722 

2723 with get_user_db_session(username) as db_session: 

2724 storage = get_report_storage(session=db_session) 

2725 

2726 # Update metadata. Include generated_at like the quick path so 

2727 # the detailed file backup's _metadata.json has parity with 

2728 # quick-mode (the detailed path previously omitted it). 

2729 metadata = final_report["metadata"] 

2730 metadata["iterations"] = results["iterations"] 

2731 metadata["generated_at"] = datetime.now(UTC).isoformat() 

2732 

2733 # Save the report through the storage abstraction, exactly as 

2734 # the quick-summary branch does, so detailed reports also honor 

2735 # the report.enable_file_backup setting. This previously did a 

2736 # raw ORM write of report_content that silently skipped the 

2737 # file backup, so a user who enabled file backup got it for 

2738 # quick research but never for detailed research. 

2739 success = storage.save_report( 

2740 research_id=research_id, 

2741 content=formatted_content, 

2742 metadata=metadata, 

2743 username=username, 

2744 ) 

2745 

2746 if not success: 2746 ↛ 2747line 2746 didn't jump to line 2747 because the condition on line 2746 was never true

2747 raise RuntimeError("Failed to save research report") # noqa: TRY301 — triggers research failure handling in outer except 

2748 

2749 logger.info( 

2750 f"Report saved to database for research_id: {research_id}" 

2751 ) 

2752 

2753 # Save sources AFTER report (non-fatal; assembler omits 

2754 # empty Sources blocks if this fails). 

2755 try: 

2756 from .research_sources_service import ResearchSourcesService 

2757 

2758 sources_service = ResearchSourcesService() 

2759 if all_links: 

2760 logger.info(f"Saving {len(all_links)} sources to database") 

2761 sources_saved = sources_service.save_research_sources( 

2762 research_id=research_id, 

2763 sources=all_links, 

2764 username=username, 

2765 ) 

2766 logger.info( 

2767 f"Saved {sources_saved} sources for research {research_id}" 

2768 ) 

2769 except Exception: 

2770 logger.exception( 

2771 f"Failed to save sources for research {research_id} (continuing)" 

2772 ) 

2773 

2774 # Update research status in database 

2775 completed_at = datetime.now(UTC).isoformat() 

2776 

2777 with get_user_db_session(username) as db_session: 

2778 research = ( 

2779 db_session.query(ResearchHistory) 

2780 .filter_by(id=research_id) 

2781 .first() 

2782 ) 

2783 

2784 # Preserve existing metadata and merge with report metadata 

2785 # (snapshot back-fill included — see helper). 

2786 completion_extra = dict(final_report["metadata"]) 

2787 completion_extra["iterations"] = results["iterations"] 

2788 metadata = _merge_completion_metadata( 

2789 research.research_meta, completion_extra, settings_snapshot 

2790 ) 

2791 

2792 # Use the helper function for consistent duration calculation 

2793 duration_seconds = calculate_duration( 

2794 research.created_at, completed_at 

2795 ) 

2796 

2797 research.status = ResearchStatus.COMPLETED 

2798 research.progress = 100 

2799 research.completed_at = completed_at 

2800 research.duration_seconds = duration_seconds 

2801 # report_content was already saved above via the report storage 

2802 # abstraction; this block only updates status/metadata. 

2803 # report_path is not used in the encrypted-database version. 

2804 

2805 # Generate headline and topics only for news searches 

2806 if ( 

2807 metadata.get("is_news_search") 

2808 or metadata.get("search_type") == "news_analysis" 

2809 ): 

2810 try: 

2811 from ...news.utils.headline_generator import ( 

2812 generate_headline, # type: ignore[no-redef] 

2813 ) 

2814 from ...news.utils.topic_generator import ( 

2815 generate_topics, # type: ignore[no-redef] 

2816 ) 

2817 from ...search_system import ( 

2818 ensure_snapshot_username, 

2819 ) 

2820 

2821 # Scope the snapshot to this run's owner so the LLM 

2822 # PEP resolves a per-user provider and honors 

2823 # local-only policy. Mirrors the quick-summary 

2824 # branch above and the scheduler's 

2825 # _store_research_result fix (background.py). 

2826 headline_topic_snapshot = ensure_snapshot_username( 

2827 settings_snapshot, username 

2828 ) 

2829 

2830 # Get the report content from database for better headline/topic generation 

2831 report_content = "" 

2832 try: 

2833 research = ( 

2834 db_session.query(ResearchHistory) 

2835 .filter_by(id=research_id) 

2836 .first() 

2837 ) 

2838 if research and research.report_content: 2838 ↛ 2841line 2838 didn't jump to line 2841 because the condition on line 2838 was always true

2839 report_content = research.report_content 

2840 else: 

2841 logger.warning( 

2842 f"No report content found in database for research_id: {research_id}" 

2843 ) 

2844 except Exception: 

2845 logger.warning( 

2846 "Could not retrieve report content from database" 

2847 ) 

2848 

2849 # Generate headline 

2850 headline = generate_headline( 

2851 query, 

2852 report_content, 

2853 settings_snapshot=headline_topic_snapshot, 

2854 ) 

2855 metadata["generated_headline"] = headline 

2856 

2857 # Generate topics 

2858 topics = generate_topics( 

2859 query=query, 

2860 findings=report_content, 

2861 category=metadata.get("category", "News"), 

2862 max_topics=6, 

2863 settings_snapshot=headline_topic_snapshot, 

2864 ) 

2865 metadata["generated_topics"] = topics 

2866 

2867 logger.info(f"Generated headline: {headline}") 

2868 logger.info(f"Generated topics: {topics}") 

2869 

2870 except Exception: 

2871 logger.warning("Could not generate headline/topics") 

2872 

2873 research.research_meta = metadata 

2874 

2875 db_session.commit() 

2876 

2877 # Update subscription if this was triggered by a subscription 

2878 if metadata.get("subscription_id"): 2878 ↛ 2879line 2878 didn't jump to line 2879 because the condition on line 2878 was never true

2879 try: 

2880 from ...news.subscription_runner import ( 

2881 advance_refresh_schedule_by_id, 

2882 ) 

2883 

2884 subscription_id = metadata["subscription_id"] 

2885 if advance_refresh_schedule_by_id( 

2886 db_session, subscription_id 

2887 ): 

2888 db_session.commit() 

2889 logger.info( 

2890 f"Updated subscription {subscription_id} refresh times" 

2891 ) 

2892 except Exception: 

2893 logger.warning( 

2894 "Could not update subscription refresh time" 

2895 ) 

2896 

2897 _maybe_save_chat_message(formatted_content) 

2898 

2899 progress_callback( 

2900 "Research completed successfully", 

2901 100, 

2902 {"phase": "complete", _RUN_COMPLETE_FLAG: True}, 

2903 ) 

2904 

2905 # Clean up resources 

2906 cleanup_research_resources( 

2907 research_id, username, user_password=user_password 

2908 ) 

2909 

2910 except ResearchTerminatedException: 

2911 logger.info(f"Research {research_id} terminated by user") 

2912 # Fallback path: when termination was raised from the streaming 

2913 # callback (mid-stream interrupt), progress_callback hasn't run 

2914 # since the flag was set, so handle_termination() was NOT called 

2915 # and the partial row hasn't been persisted yet. The helper is 

2916 # idempotent via streaming_state["_persisted"]; if the in-callback 

2917 # path already ran, both calls are no-ops. 

2918 _save_partial_chat_message_on_terminate( 

2919 shared_research_context.get("chat_session_id"), 

2920 research_id, 

2921 username, 

2922 "".join(streaming_state.get("chunks", [])), 

2923 truncated=streaming_state.get("_truncated", False), 

2924 streaming_state=streaming_state, 

2925 ) 

2926 # Ensure the SUSPENDED status update + cleanup runs even when the 

2927 # exception was raised mid-stream. The in-callback termination paths 

2928 # set "_termination_handled"; only run here when they did NOT, so a 

2929 # single termination doesn't queue two SUSPENDED updates, emit two 

2930 # final socket messages, and (in test mode) sleep twice. 

2931 if not streaming_state.get("_termination_handled"): 

2932 try: 

2933 handle_termination(research_id, username) 

2934 except Exception: 

2935 logger.opt(exception=True).debug( 

2936 "handle_termination in except block failed" 

2937 ) 

2938 

2939 except Exception as e: 

2940 # Handle error 

2941 error_message = f"Research failed: {e!s}" 

2942 logger.exception(error_message) 

2943 

2944 try: 

2945 # Check for common Ollama error patterns in the exception and provide more user-friendly errors 

2946 user_friendly_error = str(e) 

2947 error_context = {} 

2948 

2949 # Egress policy refusal (two-axis rule, or a fail-closed audit_error 

2950 # from the worker chokepoint) — surface the reason and the override 

2951 # instead of a generic "unexpected error". 

2952 from ...security.egress.policy import PolicyDeniedError 

2953 

2954 if isinstance(e, PolicyDeniedError): 2954 ↛ 2955line 2954 didn't jump to line 2955 because the condition on line 2954 was never true

2955 _reason = getattr(e.decision, "reason", "") or "egress_denied" 

2956 if _reason == "audit_error": 

2957 user_friendly_error = ( 

2958 "Egress policy could not verify this run, so it was " 

2959 "refused (fail-closed)." 

2960 ) 

2961 elif _reason.startswith("sensitive_to_exposing"): 

2962 # The two-axis rule specifically: a sensitive source would 

2963 # reach an exposing sink. 

2964 user_friendly_error = ( 

2965 "Egress policy refused this run: a sensitive source " 

2966 f"would reach an exposing destination ({_reason})." 

2967 ) 

2968 else: 

2969 # Other egress PEPs (scope mismatch, require-local 

2970 # inference, etc.) — no "sensitive source" is necessarily 

2971 # involved, so don't claim one. 

2972 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: Client error message 

2973 user_friendly_error = ( 

2974 f"Egress policy refused this run ({_reason})." 

2975 ) 

2976 error_context = { 

2977 "solution": "Set Egress Scope to 'Unprotected' to override, or use local inference / non-sensitive sources." 

2978 } 

2979 elif "Error type: ollama_unavailable" in user_friendly_error: 

2980 user_friendly_error = "Ollama AI service is unavailable. Please check that Ollama is running properly on your system." 

2981 error_context = { 

2982 "solution": "Start Ollama with 'ollama serve' or check if it's installed correctly." 

2983 } 

2984 elif "Error type: model_not_found" in user_friendly_error: 

2985 user_friendly_error = "Required Ollama model not found. Please pull the model first." 

2986 error_context = { 

2987 "solution": "Run 'ollama pull mistral' to download the required model." 

2988 } 

2989 elif "Error type: connection_error" in user_friendly_error: 

2990 user_friendly_error = "Connection error with LLM service. Please check that your AI service is running." 

2991 error_context = { 

2992 "solution": "Ensure Ollama or your API service is running and accessible." 

2993 } 

2994 elif "Error type: api_error" in user_friendly_error: 

2995 user_friendly_error = ( 

2996 "The language model API rejected the request." 

2997 ) 

2998 error_context = { 

2999 "solution": "Check API configuration and credentials." 

3000 } 

3001 # OpenAI-compatible runtime tokens (#3878). The friendly message 

3002 # built by friendly_openai_compatible_error() names the provider, 

3003 # base URL and model and appends the raw provider error (only 

3004 # credential-scrubbed). The base URL can be a server-level endpoint 

3005 # (settings_snapshot bakes in LDR_* env overrides) and the appended 

3006 # detail can carry internal hosts/paths, so it must not reach the 

3007 # client (CWE-209). Replace it with a safe category message; full 

3008 # detail stays in the logs above and the actionable hint is in 

3009 # ``solution``. 

3010 elif "Error type: openai_connection_refused" in user_friendly_error: 

3011 user_friendly_error = TYPED_RESEARCH_ERROR_MESSAGES[ 

3012 "openai_connection_refused" 

3013 ] 

3014 error_context = { 

3015 "solution": "Check that the configured LLM endpoint is reachable (a local server such as LM Studio, vLLM or llama.cpp must be running) and verify its base URL in Settings -> LLM Providers." 

3016 } 

3017 elif "Error type: openai_timeout" in user_friendly_error: 

3018 user_friendly_error = TYPED_RESEARCH_ERROR_MESSAGES[ 

3019 "openai_timeout" 

3020 ] 

3021 error_context = { 

3022 "solution": "The endpoint is reachable but slow (the model may still be loading). Retry, or increase the request timeout." 

3023 } 

3024 elif "Error type: openai_auth" in user_friendly_error: 

3025 user_friendly_error = ( 

3026 "Authentication with the configured LLM provider failed." 

3027 ) 

3028 error_context = { 

3029 "solution": "Check that the API key for this provider is valid and set in Settings -> LLM Providers." 

3030 } 

3031 elif "Error type: openai_permission_denied" in user_friendly_error: 

3032 user_friendly_error = ( 

3033 "The configured LLM provider denied access to the model." 

3034 ) 

3035 error_context = { 

3036 "solution": "Your API key is valid but lacks access to this model. Pick a model your account is permitted to use." 

3037 } 

3038 elif "Error type: openai_model_not_found" in user_friendly_error: 

3039 user_friendly_error = TYPED_RESEARCH_ERROR_MESSAGES[ 

3040 "openai_model_not_found" 

3041 ] 

3042 error_context = { 

3043 "solution": "The provider does not offer (or has not loaded) this model id. Pick a model from the provider's model list." 

3044 } 

3045 elif "Error type: openai_bad_request" in user_friendly_error: 

3046 user_friendly_error = TYPED_RESEARCH_ERROR_MESSAGES[ 

3047 "openai_bad_request" 

3048 ] 

3049 error_context = { 

3050 "solution": "The provider rejected the request. Check the model id and any provider-specific parameters." 

3051 } 

3052 elif "Error type: openai_unknown" in user_friendly_error: 

3053 user_friendly_error = ( 

3054 "The configured LLM provider returned an error." 

3055 ) 

3056 error_context = { 

3057 "solution": "Check the provider's logs for the full error and verify the base URL / model id." 

3058 } 

3059 elif "Error type: openai_rate_limit" in user_friendly_error: 

3060 user_friendly_error = ( 

3061 "The LLM provider rate-limited the request." 

3062 ) 

3063 error_context = { 

3064 "solution": "The provider rate-limited the request. Wait a moment and retry, or enable LLM Rate Limiting in Settings." 

3065 } 

3066 elif "LLM Configuration Error:" in user_friendly_error: 

3067 # The raw text here is str(e) from LLM setup and can carry 

3068 # server-level endpoints/paths (settings_snapshot includes 

3069 # LDR_* env overrides), so it must not be surfaced to the client 

3070 # (CWE-209) -- it is captured in the logs above. Keep only the 

3071 # safe category + actionable hint. 

3072 user_friendly_error = ( 

3073 "There was a problem with the LLM configuration." 

3074 ) 

3075 error_context = { 

3076 "solution": "Review your LLM model settings (or, on a shared server, contact your administrator) and ensure they are correct." 

3077 } 

3078 elif "Search Engine Configuration Error" in user_friendly_error: 

3079 # Same rationale as the LLM config branch above. 

3080 user_friendly_error = ( 

3081 "There was a problem with the search engine configuration." 

3082 ) 

3083 error_context = { 

3084 "solution": "Review your search engine settings (or, on a shared server, contact your administrator) and ensure they are correct." 

3085 } 

3086 else: 

3087 # Unrecognized exception. The raw str(e) here is server-side 

3088 # internal detail (file paths, DB/driver text, Python tracebacks) 

3089 # with no curated, user-actionable form, so it must not be 

3090 # surfaced to the client (CWE-209) — it is already captured by 

3091 # the logger.exception above. The branches above classify known 

3092 # errors and replace the message with a safe category string + 

3093 # hint; this branch handles everything that wasn't classified. 

3094 user_friendly_error = ( 

3095 "Research failed due to an unexpected error. Contact your " 

3096 "administrator or check the server logs for details." 

3097 ) 

3098 

3099 # Generate enhanced error report for failed research 

3100 enhanced_report_content = None 

3101 try: 

3102 # Get partial results if they exist 

3103 partial_results = results if "results" in locals() else None 

3104 search_iterations = ( 

3105 results.get("iterations", 0) if partial_results else 0 

3106 ) 

3107 

3108 # Generate comprehensive error report 

3109 # ErrorReportGenerator does not use LLM (kept for compat) 

3110 error_generator = ErrorReportGenerator() 

3111 enhanced_report_content = error_generator.generate_error_report( 

3112 # Use the sanitized user_friendly_error (curated for known 

3113 # errors, generic for unexpected ones) instead of raw {e!s}: 

3114 # this report is persisted and retrievable via the report 

3115 # routes, so embedding raw exception text would leak server 

3116 # internals (CWE-209). Full detail stays in the logs. 

3117 error_message=f"Research failed: {user_friendly_error}", 

3118 query=query, 

3119 partial_results=partial_results, 

3120 search_iterations=search_iterations, 

3121 research_id=research_id, 

3122 ) 

3123 

3124 logger.info( 

3125 "Generated enhanced error report for failed research (length: {})", 

3126 len(enhanced_report_content), 

3127 ) 

3128 

3129 # Save enhanced error report to encrypted database 

3130 try: 

3131 # username already available from function scope (line 281) 

3132 if username: 3132 ↛ 3154line 3132 didn't jump to line 3154 because the condition on line 3132 was always true

3133 from ...storage import get_report_storage 

3134 

3135 with get_user_db_session(username) as db_session: 

3136 storage = get_report_storage(session=db_session) 

3137 success = storage.save_report( 

3138 research_id=research_id, 

3139 content=enhanced_report_content, 

3140 metadata={"error_report": True}, 

3141 username=username, 

3142 ) 

3143 if success: 

3144 logger.info( 

3145 "Saved enhanced error report to encrypted database for research {}", 

3146 research_id, 

3147 ) 

3148 else: 

3149 logger.warning( 

3150 "Failed to save enhanced error report to database for research {}", 

3151 research_id, 

3152 ) 

3153 else: 

3154 logger.warning( 

3155 "Cannot save error report: username not available" 

3156 ) 

3157 

3158 except Exception as report_error: 

3159 logger.exception( 

3160 "Failed to save enhanced error report: {}", report_error 

3161 ) 

3162 

3163 except Exception as error_gen_error: 

3164 logger.exception( 

3165 "Failed to generate enhanced error report: {}", 

3166 error_gen_error, 

3167 ) 

3168 enhanced_report_content = None 

3169 

3170 # Get existing metadata from database first 

3171 existing_metadata = {} 

3172 try: 

3173 # username already available from function scope (line 281) 

3174 if username: 3174 ↛ 3187line 3174 didn't jump to line 3187 because the condition on line 3174 was always true

3175 with get_user_db_session(username) as db_session: 

3176 research = ( 

3177 db_session.query(ResearchHistory) 

3178 .filter_by(id=research_id) 

3179 .first() 

3180 ) 

3181 if research and research.research_meta: 

3182 existing_metadata = dict(research.research_meta) 

3183 except Exception: 

3184 logger.exception("Failed to get existing metadata") 

3185 

3186 # Update metadata with more context about the error while preserving existing values 

3187 metadata = existing_metadata 

3188 metadata.update({"phase": "error", "error": user_friendly_error}) 

3189 if error_context: 

3190 metadata.update(error_context) 

3191 if enhanced_report_content: 3191 ↛ 3195line 3191 didn't jump to line 3195 because the condition on line 3191 was always true

3192 metadata["has_enhanced_report"] = True 

3193 

3194 # If we still have an active research record, update its log 

3195 if is_research_active(research_id): 

3196 progress_callback(user_friendly_error, None, metadata) 

3197 

3198 # If termination was requested, mark as suspended instead of failed 

3199 status = ( 

3200 ResearchStatus.SUSPENDED 

3201 if is_termination_requested(research_id) 

3202 else ResearchStatus.FAILED 

3203 ) 

3204 message = ( 

3205 "Research was terminated by user" 

3206 if status == ResearchStatus.SUSPENDED 

3207 else user_friendly_error 

3208 ) 

3209 

3210 # A subscription-triggered run that FAILED must be made due again. 

3211 # run_subscription_now / the overdue sweep advance next_refresh at 

3212 # spawn time (to avoid the scheduler double-running an in-flight 

3213 # subscription), but the completion-time advance only runs on 

3214 # success. Without this reset a failed run would leave next_refresh 

3215 # pushed a full interval out, silently hiding the subscription from 

3216 # the scheduler. Skipped for SUSPENDED (user-terminated) runs. 

3217 if ( 

3218 status == ResearchStatus.FAILED 

3219 and username 

3220 and metadata.get("subscription_id") 

3221 ): 

3222 try: 

3223 from ...news.subscription_runner import ( 

3224 mark_subscription_due_by_id, 

3225 ) 

3226 

3227 with get_user_db_session(username) as sub_db: 

3228 if mark_subscription_due_by_id( 3228 ↛ 3243line 3228 didn't jump to line 3243

3229 sub_db, metadata["subscription_id"] 

3230 ): 

3231 sub_db.commit() 

3232 logger.info( 

3233 f"Reset subscription {metadata['subscription_id']} " 

3234 "to due after failed run" 

3235 ) 

3236 except Exception: 

3237 logger.warning( 

3238 "Could not reset subscription refresh time after " 

3239 "failed run" 

3240 ) 

3241 

3242 # Calculate duration up to termination point - using UTC consistently 

3243 now = datetime.now(UTC) 

3244 completed_at = now.isoformat() 

3245 

3246 # NOTE: Database updates from threads are handled by queue processor 

3247 # The queue_processor.queue_error_update() method is already being used below 

3248 # to safely update the database from the main thread 

3249 

3250 # Queue the error update to be processed in main thread 

3251 # Using the queue processor v2 system 

3252 from ..queue.processor_v2 import queue_processor 

3253 

3254 if username: 3254 ↛ 3268line 3254 didn't jump to line 3268 because the condition on line 3254 was always true

3255 queue_processor.queue_error_update( 

3256 username=username, 

3257 research_id=research_id, 

3258 status=status, 

3259 error_message=message, 

3260 metadata=metadata, 

3261 completed_at=completed_at, 

3262 report_path=None, 

3263 ) 

3264 logger.info( 

3265 f"Queued error update for research {research_id} with status '{status}'" 

3266 ) 

3267 else: 

3268 logger.error( 

3269 f"Cannot queue error update for research {research_id} - no username provided. " 

3270 f"Status: '{status}', Message: {message}" 

3271 ) 

3272 

3273 try: 

3274 _sio_emit( 

3275 "research_progress", 

3276 research_id, 

3277 {"status": status, "error": message}, 

3278 owner=username, 

3279 ) 

3280 except Exception: 

3281 logger.exception("Failed to emit error via socket") 

3282 

3283 # Add error message to chat session if applicable. 

3284 # Read chat_session_id from shared_research_context (the canonical 

3285 # source — kept consistent with the six other reads in this file). 

3286 chat_session_id = shared_research_context.get("chat_session_id") 

3287 if chat_session_id and username: 

3288 try: 

3289 from ...chat.service import ChatService 

3290 

3291 chat_service = ChatService(username) 

3292 # allow_archived=True: same multi-tab race rationale as 

3293 # the completion / stop-and-partial paths — if the user 

3294 # archived (or deleted) the session between research 

3295 # start and failure, the error message would otherwise 

3296 # be silently dropped by the active-only insert guard 

3297 # and the user sees nothing in the chat. 

3298 chat_service.add_message( 

3299 session_id=chat_session_id, 

3300 role="assistant", 

3301 content=f"Sorry, the research failed: {message}", 

3302 message_type="response", 

3303 allow_archived=True, 

3304 ) 

3305 except Exception: 

3306 # Promoted from debug → warning to match the success-path 

3307 # rationale: if this write fails the user never sees the 

3308 # error and operators get no signal at debug-off level. 

3309 logger.opt(exception=True).warning( 

3310 "Could not add error message to chat session" 

3311 ) 

3312 

3313 except Exception: 

3314 logger.exception("Error in error handler") 

3315 

3316 # Clean up resources. This is the error path, so report FAILED on 

3317 # the final socket message rather than a spurious "completed". 

3318 cleanup_research_resources( 

3319 research_id, 

3320 username, 

3321 user_password=user_password, 

3322 final_status=ResearchStatus.FAILED, 

3323 ) 

3324 

3325 finally: 

3326 # RESOURCE CLEANUP: Close search engine HTTP sessions. 

3327 # 

3328 # Search engines (created via get_search()) may hold HTTP connection 

3329 # pools. Currently only SemanticScholarSearchEngine creates a 

3330 # persistent SafeSession; other engines use stateless safe_get()/ 

3331 # safe_post() utility functions. However, BaseSearchEngine.close() 

3332 # is safe to call on any engine — it checks for a 'session' 

3333 # attribute and is fully idempotent (SemanticScholar sets 

3334 # self.session = None after close). 

3335 # 

3336 # Neither @thread_cleanup nor cleanup_research_resources() close 

3337 # the search engine — @thread_cleanup only handles database sessions 

3338 # and context cleanup, and cleanup_research_resources() only handles 

3339 # status updates, notifications, and tracking dict removal. 

3340 # 

3341 # Without this explicit close, search engine sessions rely on 

3342 # Python's non-deterministic garbage collection (__del__) for 

3343 # cleanup, which can cause file descriptor exhaustion under 

3344 # sustained load. 

3345 from ...utilities.resource_utils import safe_close 

3346 

3347 if "use_search" in locals(): 

3348 safe_close(use_search, "research search engine") 

3349 # Close search system (cascades to strategy thread pools). 

3350 # See AdvancedSearchSystem.close() for details. 

3351 if "system" in locals(): 

3352 safe_close(system, "research system") 

3353 # Close the LLM instance created for model/provider overrides. 

3354 # system.close() does NOT close the LLM passed to it via system.model, 

3355 # so we must close it explicitly here. 

3356 if "use_llm" in locals(): 

3357 safe_close(use_llm, "research LLM") 

3358 

3359 

3360def cleanup_research_resources( 

3361 research_id, 

3362 username=None, 

3363 user_password=None, 

3364 final_status=ResearchStatus.COMPLETED, 

3365 preserve_termination_flag=False, 

3366): 

3367 """ 

3368 Clean up resources for a completed research. 

3369 

3370 Args: 

3371 research_id: The ID of the research 

3372 username: The username for database access (required for thread context) 

3373 final_status: The terminal status to report on the final socket 

3374 message. Callers that end a research for a reason other than 

3375 normal completion MUST pass the real status (e.g. SUSPENDED on 

3376 user termination, FAILED on error) so the final ``progress`` 

3377 event matches reality. Defaulting this to COMPLETED — and 

3378 previously hard-coding it — caused the stop/error paths to emit 

3379 a spurious "completed" signal to subscribers. 

3380 preserve_termination_flag: Keep a cancellation signal visible after 

3381 removing the active registry entry. Used only by the HTTP cancel 

3382 handoff; the worker's own termination cleanup uses the default and 

3383 removes the flag. 

3384 """ 

3385 from ..research_state import cleanup_research 

3386 

3387 logger.info("Cleaning up resources for research {}", research_id) 

3388 

3389 # For testing: Add a small delay to simulate research taking time 

3390 # This helps test concurrent research limits 

3391 from ...settings.env_registry import is_test_mode 

3392 

3393 if is_test_mode(): 

3394 import time 

3395 

3396 logger.info( 

3397 f"Test mode: Adding 5 second delay before cleanup for {research_id}" 

3398 ) 

3399 time.sleep(5) 

3400 

3401 # The terminal status to report on the final socket message. This comes 

3402 # from the caller (which knows why the research ended) rather than a 

3403 # hard-coded COMPLETED, so termination (SUSPENDED) and error (FAILED) 

3404 # paths no longer emit a false "completed" signal to subscribers. 

3405 current_status = final_status 

3406 

3407 # NOTE: Queue processor already handles database updates from the main thread 

3408 # The notify_research_completed() method is called at the end of this function 

3409 # which safely updates the database status 

3410 

3411 # Notify queue processor that research completed 

3412 # This uses processor_v2 which handles database updates in the main thread 

3413 # avoiding the Flask request context issues that occur in background threads 

3414 from ..queue.processor_v2 import queue_processor 

3415 

3416 if username: 

3417 queue_processor.notify_research_completed( 

3418 username, research_id, user_password=user_password 

3419 ) 

3420 logger.info( 

3421 f"Notified queue processor of completion for research {research_id} (user: {username})" 

3422 ) 

3423 else: 

3424 logger.warning( 

3425 f"Cannot notify completion for research {research_id} - no username provided" 

3426 ) 

3427 

3428 # Remove from active research and termination flags atomically 

3429 if preserve_termination_flag: 

3430 cleanup_research(research_id, preserve_termination_flag=True) 

3431 else: 

3432 cleanup_research(research_id) 

3433 

3434 # Clean up throttle state for this research 

3435 with _last_emit_lock: 

3436 _last_emit_times.pop(research_id, None) 

3437 

3438 # Send a final message to subscribers 

3439 try: 

3440 # Send a final message to any remaining subscribers with explicit status 

3441 # Use the proper status message based on database status 

3442 if current_status in ( 

3443 ResearchStatus.SUSPENDED, 

3444 ResearchStatus.FAILED, 

3445 ): 

3446 final_message = { 

3447 "status": current_status, 

3448 "message": f"Research was {current_status}", 

3449 "progress": 0, # For suspended research, show 0% not 100% 

3450 } 

3451 else: 

3452 final_message = { 

3453 "status": ResearchStatus.COMPLETED, 

3454 "message": "Research process has ended and resources have been cleaned up", 

3455 "progress": 100, 

3456 } 

3457 

3458 logger.info( 

3459 "Sending final {} socket message for research {}", 

3460 current_status, 

3461 research_id, 

3462 ) 

3463 

3464 _sio_emit( 

3465 "research_progress", research_id, final_message, owner=username 

3466 ) 

3467 

3468 # Clean up socket subscriptions for this research 

3469 _sio_remove(research_id, username) 

3470 

3471 except Exception: 

3472 logger.exception("Error sending final cleanup message") 

3473 

3474 

3475def handle_termination( 

3476 research_id, username=None, *, preserve_termination_flag=False 

3477): 

3478 """ 

3479 Handle the termination of a research process. 

3480 

3481 Args: 

3482 research_id: The ID of the research 

3483 username: The username for database access (required for thread context) 

3484 """ 

3485 logger.info(f"Handling termination for research {research_id}") 

3486 

3487 # Queue the status update to be processed in the main thread 

3488 # This avoids Flask request context errors in background threads 

3489 try: 

3490 from ..queue.processor_v2 import queue_processor 

3491 

3492 now = datetime.now(UTC) 

3493 completed_at = now.isoformat() 

3494 

3495 # Queue the suspension update 

3496 queue_processor.queue_error_update( 

3497 username=username, 

3498 research_id=research_id, 

3499 status=ResearchStatus.SUSPENDED, 

3500 error_message="Research was terminated by user", 

3501 metadata={"terminated_at": completed_at}, 

3502 completed_at=completed_at, 

3503 report_path=None, 

3504 ) 

3505 

3506 logger.info(f"Queued suspension update for research {research_id}") 

3507 except Exception: 

3508 logger.exception( 

3509 f"Error queueing termination update for research {research_id}" 

3510 ) 

3511 

3512 # Clean up resources (this already handles things properly). 

3513 # Pass SUSPENDED so the final socket message reports the real terminal 

3514 # status — not a spurious "completed" — to chat/progress subscribers. 

3515 cleanup_kwargs = {"final_status": ResearchStatus.SUSPENDED} 

3516 if preserve_termination_flag: 

3517 cleanup_kwargs["preserve_termination_flag"] = True 

3518 cleanup_research_resources(research_id, username, **cleanup_kwargs) 

3519 

3520 

3521def cancel_research(research_id, username): 

3522 """ 

3523 Cancel/terminate a research process using ORM. 

3524 

3525 Args: 

3526 research_id: The ID of the research to cancel 

3527 username: The username of the user cancelling the research 

3528 

3529 Returns: 

3530 bool: True if the research was found and cancelled, False otherwise 

3531 """ 

3532 try: 

3533 from ..research_state import is_research_active, set_termination_flag 

3534 

3535 # Ownership gate. research ids are per-user, but the global termination 

3536 # registry (_termination_flags / _active_research, keyed by id alone) is 

3537 # shared across all users. Without this check any signed-in user could 

3538 # terminate ANOTHER user's active research by id: the active branch 

3539 # below sets the flag and calls handle_termination before the only 

3540 # pre-existing ownership query (which lives in the not-active branch). 

3541 # Confirm the caller owns this research in their OWN database first. 

3542 with get_user_db_session(username) as _owner_check_session: 

3543 owns_research = ( 

3544 _owner_check_session.query(ResearchHistory.id) 

3545 .filter_by(id=research_id) 

3546 .first() 

3547 is not None 

3548 ) 

3549 if not owns_research: 

3550 # (#6938: research_id is the raw path parameter on this 

3551 # not-found branch.) 

3552 from ..routers.notes import _log_value_preview 

3553 

3554 logger.info( 

3555 "Research {} not found for this user; refusing to cancel.", 

3556 _log_value_preview(research_id), 

3557 ) 

3558 return False 

3559 

3560 # Set termination flag 

3561 set_termination_flag(research_id) 

3562 

3563 # Check if the research is active 

3564 if is_research_active(research_id): 

3565 # Update the database and release the active slot immediately, but 

3566 # keep the signal set until the still-running worker sees it. The 

3567 # worker's own handle_termination call performs final flag cleanup. 

3568 handle_termination( 

3569 research_id, 

3570 username, 

3571 preserve_termination_flag=True, 

3572 ) 

3573 return True 

3574 try: 

3575 with get_user_db_session(username) as db_session: 

3576 research = ( 

3577 db_session.query(ResearchHistory) 

3578 .filter_by(id=research_id) 

3579 .first() 

3580 ) 

3581 if not research: 

3582 logger.info(f"Research {research_id} not found in database") 

3583 return False 

3584 

3585 # Check if already in a terminal state 

3586 if research.status in ( 

3587 ResearchStatus.COMPLETED, 

3588 ResearchStatus.SUSPENDED, 

3589 ResearchStatus.FAILED, 

3590 ResearchStatus.ERROR, 

3591 ): 

3592 logger.info( 

3593 f"Research {research_id} already in terminal state: {research.status}" 

3594 ) 

3595 return True # Consider this a success since it's already stopped 

3596 

3597 # If it exists but isn't in active_research, still update status 

3598 if research.status == ResearchStatus.QUEUED: 

3599 from ...database.models import QueuedResearch 

3600 

3601 claimed_queue_row = ( 

3602 db_session.query(QueuedResearch.id) 

3603 .filter( 

3604 QueuedResearch.research_id == research_id, 

3605 QueuedResearch.is_processing.is_(True), 

3606 ) 

3607 .exists() 

3608 ) 

3609 suspended = ( 

3610 db_session.query(ResearchHistory) 

3611 .filter( 

3612 ResearchHistory.id == research_id, 

3613 ResearchHistory.status == ResearchStatus.QUEUED, 

3614 ~claimed_queue_row, 

3615 ) 

3616 .update( 

3617 {ResearchHistory.status: ResearchStatus.SUSPENDED}, 

3618 synchronize_session=False, 

3619 ) 

3620 ) 

3621 if suspended: 

3622 from ..queue.lifecycle_cleanup import ( 

3623 cleanup_queued_research_state, 

3624 ) 

3625 

3626 cleanup_queued_research_state( 

3627 db_session, [research_id], acting_user=username 

3628 ) 

3629 else: 

3630 research.status = ResearchStatus.SUSPENDED 

3631 db_session.commit() 

3632 logger.info(f"Successfully suspended research {research_id}") 

3633 except Exception: 

3634 logger.exception( 

3635 f"Error accessing database for research {research_id}" 

3636 ) 

3637 return False 

3638 

3639 return True 

3640 except Exception: 

3641 from ..routers.notes import _log_value_preview 

3642 

3643 logger.exception( 

3644 "Unexpected error in cancel_research for {}", 

3645 _log_value_preview(research_id), 

3646 ) 

3647 return False