Coverage for src/local_deep_research/web/routers/chat.py: 99%

588 statements  

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

1""" 

2FastAPI routes for the chat API. 

3 

4Provides endpoints for: 

5- Chat page rendering 

6- Session management (create, list, get, archive, delete) 

7- Message management (send, list) 

8- Research triggering from chat 

9 

10Ported from the pre-FastAPI Flask blueprint ``chat/routes.py``: Flask 

11``session``/``@login_required`` became ``Depends(require_auth)``, 

12``request.get_json`` became ``await request.json()``, ``jsonify(...), N`` 

13became ``JSONResponse(..., status_code=N)``, and the blocking DB/service 

14work runs in the threadpool via ``run_db_sync`` so it never blocks the 

15uvicorn event loop. The framework-agnostic helpers and all concurrency / 

16cleanup logic are preserved verbatim. 

17""" 

18 

19import threading 

20import unicodedata 

21import uuid 

22from datetime import datetime, timedelta, UTC 

23from urllib.parse import quote 

24 

25from fastapi import APIRouter, Depends, HTTPException, Request 

26from fastapi.responses import JSONResponse 

27from loguru import logger 

28from sqlalchemy import update as sa_update 

29from sqlalchemy.exc import IntegrityError, SQLAlchemyError 

30 

31from ...chat.service import ( 

32 ArchiveBlockedError, 

33 AttemptInProgress, 

34 AttemptNotFound, 

35 ChatCursorError, 

36 ChatService, 

37 ChatSessionNotFound, 

38 DB_EXCEPTIONS, 

39) 

40from ...chat.context import ChatContextManager 

41from ...constants import ResearchStatus 

42from ...database.models import ( 

43 ChatMessage, 

44 ChatSession, 

45 ChatSessionStatus, 

46 ResearchHistory, 

47 UserActiveResearch, 

48) 

49from ...database.session_context import get_user_db_session 

50from ...exceptions import DuplicateResearchError, SystemAtCapacityError 

51from ...llm.providers.base import normalize_provider 

52from ...settings.manager import SettingsManager 

53from ...utilities.formatting import short_id 

54from ..auth.password_utils import get_user_password, resolve_user_password 

55from ..routes.globals import ( 

56 cleanup_research, 

57 is_research_thread_alive, 

58 reclaim_stale_user_active_research, 

59) 

60from ..research_state import ( 

61 get_user_research_start_lock, 

62 user_research_start_gate, 

63) 

64from ..services.research_service import ( 

65 clamp_user_max_concurrent, 

66 run_research_process, 

67 start_research_process, 

68) 

69from ..dependencies.auth import require_auth 

70from ..dependencies.rate_limit import limiter, _get_client_ip 

71from ..dependencies.template_helpers import render_template 

72from ..dependencies.threadpool import run_db_sync 

73from typing import Annotated 

74 

75router = APIRouter(tags=["chat"]) 

76 

77# Valid status values for sessions (built from the enum so a typo never 

78# silently passes validation; the literal "all" sentinel widens the list 

79# filter to every status without bypassing the whitelist). 

80VALID_UPDATE_STATUSES = { 

81 ChatSessionStatus.ACTIVE.value, 

82 ChatSessionStatus.ARCHIVED.value, 

83} 

84VALID_LIST_STATUSES = {*(s.value for s in ChatSessionStatus), "all"} 

85 

86# Input length limits 

87MAX_QUERY_LENGTH = 10_000 

88MAX_TITLE_LENGTH = 500 

89MAX_MESSAGE_LENGTH = 10_000 

90# Hard cap on `offset` to prevent server-side DoS: get_session_messages 

91# fetches `limit + offset` rows from BOTH chat_messages and chat_progress_steps 

92# tables, so unbounded offset means unbounded SQL LIMIT. With cursor-based 

93# pagination (`before_created_at`) as the recommended path, offset above a few 

94# pages is not a normal access pattern. 

95MAX_OFFSET = 1_000 

96 

97# Wider exception tuple used by HTTP route handlers (subsumes 

98# service.DB_EXCEPTIONS plus the attribute/type errors that can escape 

99# request-shape coercion code). DB_EXCEPTIONS itself is single-sourced 

100# from chat.service so the two never drift. 

101ROUTE_EXCEPTIONS = ( 

102 ValueError, 

103 RuntimeError, 

104 SQLAlchemyError, 

105 AttributeError, 

106 TypeError, 

107) 

108 

109 

110def _bad_body() -> JSONResponse: 

111 """Build the 400 response for a non-JSON / non-object request body. 

112 

113 Returns a FRESH ``JSONResponse`` each call — a shared module-level 

114 instance can't be reused safely across concurrent requests. This is the 

115 FastAPI equivalent of the old ``@require_json_body(error_format="success", 

116 ...)`` decorator. 

117 

118 It does NOT reject form-encoded posts, and an earlier version of this 

119 docstring wrongly claimed it "hardens CSRF" by doing so. Flask's 

120 ``get_json(silent=True)`` returned ``None`` for a non-JSON Content-Type, 

121 which had that side effect; ``request.json()`` parses the body regardless 

122 of Content-Type, so a form-encoded POST carrying a JSON object body is 

123 accepted here. Verified: ``Content-Type: text/plain`` with a JSON object 

124 creates a session. 

125 

126 That is safe, but for a different reason: ``CSRFMiddleware`` 

127 (web/dependencies/csrf.py) is the fail-closed choke point and rejects 

128 these requests with 403 before they reach any handler. Recording the 

129 correction because a reviewer could otherwise rely on a guarantee this 

130 function does not provide. 

131 """ 

132 return JSONResponse( 

133 {"success": False, "error": "Request body must be a JSON object"}, 

134 status_code=400, 

135 ) 

136 

137 

138def _chat_user_key(request: Request) -> str: 

139 """Per-user rate-limit key (default slowapi key is per-IP). 

140 

141 Without this, users behind a shared NAT/proxy share one bucket and can 

142 DoS each other for legitimate chat use. Falls back to the client IP for 

143 any unauthenticated request that somehow reaches a limited route. 

144 

145 The username branch is prefixed (``user:<name>``, the shape 

146 ``_user_key`` uses) so a username shaped like an address is not the 

147 same bucket as an anonymous caller from that address; the IP branch is 

148 always a validated address or a fixed token, never ``user:...``. 

149 """ 

150 username = ( 

151 request.session.get("username") if "session" in request.scope else None 

152 ) 

153 return f"user:{username}" if username else _get_client_ip(request) 

154 

155 

156async def _json_object_body(request: Request): 

157 """Parse the request body as a JSON object, or return a 400 response. 

158 

159 Returns the parsed dict on success, or a ``JSONResponse`` (400) the 

160 caller must return as-is. Mirrors the old ``@require_json_body`` gate. 

161 """ 

162 try: 

163 data = await request.json() 

164 except Exception: 

165 return _bad_body() 

166 if not isinstance(data, dict): 

167 return _bad_body() 

168 return data 

169 

170 

171def _load_settings(username): 

172 """Load all settings for a user. 

173 

174 ``bypass_cache=True`` matches the call pattern in 

175 ``research_routes.start_research``: a setting changed via the UI 

176 moments before the user sends a chat message must take effect on the 

177 next research, not be served from a stale cache. 

178 """ 

179 with get_user_db_session(username) as db: 

180 snapshot = SettingsManager(db_session=db).get_all_settings( 

181 bypass_cache=True 

182 ) 

183 # Inject the username so snapshot-driven consumers (get_llm, and ADAPTIVE 

184 # egress-scope resolution for a per-user retriever primary) can resolve it. 

185 # Without it, chat follow-up summarization builds get_llm() with 

186 # username=None -> a private-retriever primary is invisible -> scope falls 

187 # open to BOTH and a cloud LLM summarizes private-corpus turns. Mirrors 

188 # AdvancedSearchSystem.ensure_snapshot_username. Ported from #5539, which 

189 # landed on the Flask chat/routes.py this router replaced. 

190 from ...search_system import ensure_snapshot_username 

191 

192 return ensure_snapshot_username(snapshot, username) 

193 

194 

195def _parse_int_param( 

196 value: str | None, 

197 default: int, 

198 min_val: int = 0, 

199 max_val: int | None = None, 

200) -> int: 

201 """Safely parse an integer parameter with bounds checking.""" 

202 try: 

203 result = int(value) if value is not None else default 

204 if result < min_val: 

205 return min_val 

206 if max_val is not None and result > max_val: 

207 return max_val 

208 return result 

209 except (ValueError, TypeError): 

210 return default 

211 

212 

213_INVISIBLE_UNICODE_CATEGORIES = {"Cf", "Zl", "Zp"} 

214 

215 

216def _validate_title(title) -> tuple[str, int] | None: 

217 """Return (error_message, http_status) when *title* is invalid, else None. 

218 

219 A title is invalid when it is not a non-empty string or exceeds 

220 ``MAX_TITLE_LENGTH``. Callers that allow ``None`` (e.g. create_session 

221 where omitting the title is fine) should short-circuit on ``None`` 

222 before calling this helper. 

223 

224 Strips Unicode format / line-separator characters (``Cf``/``Zl``/``Zp``, 

225 including zero-width spaces U+200B-U+200D and BOM U+FEFF) before the 

226 emptiness check so an "invisible" title like 500 zero-width chars is 

227 rejected instead of saving a session that looks blank in the UI. 

228 """ 

229 if not isinstance(title, str): 

230 return ("Title cannot be empty", 400) 

231 visible = "".join( 

232 c 

233 for c in title 

234 if unicodedata.category(c) not in _INVISIBLE_UNICODE_CATEGORIES 

235 ) 

236 if not visible.strip(): 

237 return ("Title cannot be empty", 400) 

238 if len(title) > MAX_TITLE_LENGTH: 

239 return ( 

240 f"Title too long (max {MAX_TITLE_LENGTH} characters)", 

241 400, 

242 ) 

243 return None 

244 

245 

246def _cleanup_chat_send_rows( 

247 username, 

248 research_id, 

249 message_id, 

250 session_id, 

251 reason: str, 

252 *, 

253 preserve_attempt=False, 

254) -> None: 

255 """Undo the user-message + research_history rows committed by send_message 

256 when ``start_research_process`` rejects the spawn. 

257 

258 Used by the ``DuplicateResearchError`` (409), ``SystemAtCapacityError`` 

259 (429), and generic spawn-failure (500) paths. Failure to clean up is logged 

260 at ERROR level so orphan rows + inflated message_count are visible to ops. 

261 Retry preserves the replacement query as a failed attempt because the old 

262 attempt has already been removed. Its active claim is still released. 

263 """ 

264 try: 

265 with get_user_db_session(username) as cleanup_db: 

266 if preserve_attempt: 

267 research = ( 

268 cleanup_db.query(ResearchHistory) 

269 .filter_by(id=research_id, chat_session_id=session_id) 

270 .first() 

271 ) 

272 if research is not None: 272 ↛ 293line 272 didn't jump to line 293 because the condition on line 272 was always true

273 research.status = ResearchStatus.FAILED.value 

274 research.completed_at = datetime.now(UTC).isoformat() 

275 # The failed replacement has no assistant response to 

276 # expose its ID. Link its retained query directly so the 

277 # reloaded timeline can offer Retry and Delete actions. 

278 message = ( 

279 cleanup_db.query(ChatMessage) 

280 .filter_by(id=message_id, session_id=session_id) 

281 .first() 

282 ) 

283 if message is not None: 283 ↛ 293line 283 didn't jump to line 293 because the condition on line 283 was always true

284 message.research_id = research_id 

285 else: 

286 cleanup_db.query(ResearchHistory).filter_by( 

287 id=research_id 

288 ).delete() 

289 cleanup_db.query(ChatMessage).filter_by(id=message_id).delete() 

290 # Drop the per-user-cap tracking row too (the spawn never 

291 # started, so no live thread owns it). research_id is a fresh 

292 # UUID, so this only ever matches our own just-inserted row. 

293 cleanup_db.query(UserActiveResearch).filter_by( 

294 username=username, research_id=research_id 

295 ).delete() 

296 if not preserve_attempt: 

297 cleanup_db.execute( 

298 sa_update(ChatSession) 

299 .where(ChatSession.id == session_id) 

300 .values(message_count=ChatSession.message_count - 1) 

301 ) 

302 cleanup_db.commit() 

303 except DB_EXCEPTIONS: 

304 logger.exception( 

305 f"Cleanup after {reason} chat-send rejection FAILED " 

306 f"for research {short_id(research_id)} in chat " 

307 f"{short_id(session_id)}; orphan rows + inflated " 

308 f"message_count may persist until next sweep." 

309 ) 

310 

311 

312# ============================================================================ 

313# Page Routes 

314# ============================================================================ 

315 

316# Sec-Fetch-Site values that mean another origin started the navigation. 

317# "same-site" is still another origin, such as a sibling subdomain or 

318# another port on the same host. 

319_FOREIGN_FETCH_SITES = frozenset({"cross-site", "same-site"}) 

320 

321 

322def _drop_foreign_chat_query(request: Request) -> None: 

323 """Redirect a chat link from another origin to the same page without ``q``. 

324 

325 chat.js pre-fills the chat box from ``?q=`` and never sends that text 

326 by itself. The SameSite=Lax session cookie lets a link from another site 

327 open the page signed in, so when the browser says that another origin 

328 started the navigation, the text is not even pre-filled. Browsers send 

329 ``Sec-Fetch-Site`` to HTTPS and localhost origins. ``same-origin`` and 

330 ``none`` (a typed or bookmarked URL, or a link opened from another app 

331 such as a mail or chat client) keep ``q``, and so does a request without 

332 the header. After a login the browser arrives here through this app's 

333 own redirect, so the login page drops a chat ``q`` from its ``next`` 

334 itself (``routers/auth.py::_drop_chat_query``). 

335 

336 Declared as ``chat_page``'s first dependency parameter, ahead of 

337 ``username``. FastAPI solves dependencies in parameter order, so this 

338 runs before ``require_auth``: a signed-out user is sent on to the login 

339 page without ``q`` as well. 

340 """ 

341 if "q" not in request.query_params: 

342 return 

343 site = request.headers.get("sec-fetch-site", "").strip().lower() 

344 if site not in _FOREIGN_FETCH_SITES: 

345 return 

346 session_id = request.path_params.get("session_id") 

347 location = "/chat/" + (quote(session_id, safe="") if session_id else "") 

348 remaining = request.url.remove_query_params("q").query 

349 if remaining: 

350 location += "?" + remaining 

351 raise HTTPException(status_code=303, headers={"Location": location}) 

352 

353 

354@router.get("/chat/", include_in_schema=False) 

355@router.get("/chat/{session_id}", include_in_schema=False) 

356def chat_page( 

357 request: Request, 

358 # A parameter, not a decorator ``dependencies=[...]`` entry, so every 

359 # dependency of this route stays in its signature (where the 

360 # cross-user isolation census reads auth). It must stay ahead of 

361 # ``username``: dependencies run in parameter order. 

362 _foreign_query_dropped: Annotated[None, Depends(_drop_foreign_chat_query)], 

363 username: Annotated[str, Depends(require_auth)], 

364 session_id: str | None = None, 

365): 

366 """Render the chat page. 

367 

368 Args: 

369 session_id: Optional session ID to load existing session 

370 """ 

371 return render_template( 

372 request, "pages/chat.html", {"session_id": session_id} 

373 ) 

374 

375 

376# ============================================================================ 

377# Session API Routes 

378# ============================================================================ 

379 

380 

381@router.post("/api/chat/sessions") 

382# Per-user keying (default is per-IP). Without this, users behind a shared 

383# NAT/proxy share one bucket and can DoS each other for legitimate chat use. 

384@limiter.limit("20 per minute", key_func=_chat_user_key) 

385async def create_session( 

386 request: Request, username: Annotated[str, Depends(require_auth)] 

387): 

388 """Create a new chat session.""" 

389 data = await _json_object_body(request) 

390 if isinstance(data, JSONResponse): 

391 return data 

392 

393 def _impl(): 

394 try: 

395 # Validate input lengths 

396 initial_query = data.get("initial_query") 

397 title = data.get("title") 

398 

399 # Reject non-string initial_query early so len() / downstream 

400 # string ops don't raise TypeError → 500. 

401 if initial_query is not None and not isinstance(initial_query, str): 

402 return JSONResponse( 

403 { 

404 "success": False, 

405 "error": "initial_query must be a string", 

406 }, 

407 status_code=400, 

408 ) 

409 

410 if initial_query and len(initial_query) > MAX_QUERY_LENGTH: 

411 return JSONResponse( 

412 { 

413 "success": False, 

414 "error": f"Initial query too long (max {MAX_QUERY_LENGTH} characters)", 

415 }, 

416 status_code=400, 

417 ) 

418 

419 if title is not None: 

420 err = _validate_title(title) 

421 if err is not None: 

422 msg, status = err 

423 return JSONResponse( 

424 {"success": False, "error": msg}, status_code=status 

425 ) 

426 

427 settings_snapshot = _load_settings(username) 

428 

429 service = ChatService(username) 

430 session_id = service.create_session( 

431 initial_query=initial_query, 

432 title=title, 

433 settings_snapshot=settings_snapshot, 

434 ) 

435 

436 # Get the created session 

437 try: 

438 session_data = service.get_session(session_id) 

439 except ChatSessionNotFound: 

440 # Session was just created in this request — getting "not 

441 # found" here means a delete-race or storage failure. 

442 logger.exception( 

443 "Just-created chat session missing on read-back" 

444 ) 

445 return JSONResponse( 

446 { 

447 "success": False, 

448 "error": "Failed to load created session", 

449 }, 

450 status_code=500, 

451 ) 

452 

453 return JSONResponse( 

454 { 

455 "success": True, 

456 "session_id": session_id, 

457 "session": session_data, 

458 } 

459 ) 

460 

461 except ROUTE_EXCEPTIONS: 

462 logger.exception("Error creating chat session") 

463 return JSONResponse( 

464 { 

465 "success": False, 

466 "error": "Failed to create chat session", 

467 }, 

468 status_code=500, 

469 ) 

470 

471 return await run_db_sync(_impl) 

472 

473 

474@router.post("/api/chat/sessions/{session_id}/generate-title") 

475# Per-user keying + lower limit than create_session because each call is a 

476# real LLM round-trip on a server-paid endpoint (vs create_session which is 

477# zero-LLM DB work). Without per-user keying, shared-IP users share the bucket. 

478@limiter.limit("10 per minute", key_func=_chat_user_key) 

479async def generate_session_title( 

480 request: Request, 

481 session_id: str, 

482 username: Annotated[str, Depends(require_auth)], 

483): 

484 """Regenerate the session title using the configured LLM. 

485 

486 Fire-and-forget endpoint the frontend calls asynchronously right after 

487 creating a session, so the synchronous POST /api/chat/sessions response 

488 isn't blocked on an LLM round-trip. 

489 """ 

490 data = await _json_object_body(request) 

491 if isinstance(data, JSONResponse): 

492 return data 

493 

494 def _impl(): 

495 try: 

496 query = data.get("query") 

497 

498 if not query: 

499 return JSONResponse( 

500 {"success": False, "error": "query is required"}, 

501 status_code=400, 

502 ) 

503 if not isinstance(query, str) or len(query) > MAX_QUERY_LENGTH: 

504 return JSONResponse( 

505 { 

506 "success": False, 

507 "error": f"query must be a string up to {MAX_QUERY_LENGTH} chars", 

508 }, 

509 status_code=400, 

510 ) 

511 

512 service = ChatService(username) 

513 try: 

514 service.get_session(session_id) 

515 except ChatSessionNotFound: 

516 return JSONResponse( 

517 {"success": False, "error": "Session not found"}, 

518 status_code=404, 

519 ) 

520 

521 settings_snapshot = _load_settings(username) 

522 new_title = service.regenerate_title_with_llm( 

523 session_id, query, settings_snapshot 

524 ) 

525 if not new_title: 

526 # LLM disabled, or LLM call failed — keep existing fallback 

527 return JSONResponse({"success": False, "title": None}) 

528 

529 return JSONResponse({"success": True, "title": new_title}) 

530 

531 except ROUTE_EXCEPTIONS: 

532 logger.exception("Error regenerating chat title") 

533 return JSONResponse( 

534 {"success": False, "error": "Failed to regenerate title"}, 

535 status_code=500, 

536 ) 

537 

538 return await run_db_sync(_impl) 

539 

540 

541@router.get("/api/chat/sessions") 

542async def list_sessions( 

543 request: Request, username: Annotated[str, Depends(require_auth)] 

544): 

545 """List chat sessions for the current user. 

546 

547 Query params: status (active/archived/deleted/all, default active), 

548 limit (default 20), offset (default 0). 

549 """ 

550 

551 def _impl(): 

552 try: 

553 status = request.query_params.get( 

554 "status", ChatSessionStatus.ACTIVE.value 

555 ) 

556 # Validate status parameter 

557 if status not in VALID_LIST_STATUSES: 

558 status = ChatSessionStatus.ACTIVE.value 

559 limit = _parse_int_param( 

560 request.query_params.get("limit"), 20, min_val=1, max_val=100 

561 ) 

562 offset = _parse_int_param( 

563 request.query_params.get("offset"), 

564 0, 

565 min_val=0, 

566 max_val=MAX_OFFSET, 

567 ) 

568 

569 service = ChatService(username) 

570 sessions = service.list_sessions( 

571 status=status, limit=limit, offset=offset 

572 ) 

573 

574 return JSONResponse({"success": True, "sessions": sessions}) 

575 

576 except ROUTE_EXCEPTIONS: 

577 logger.exception("Error listing chat sessions") 

578 return JSONResponse( 

579 {"success": False, "error": "Failed to list chat sessions"}, 

580 status_code=500, 

581 ) 

582 

583 return await run_db_sync(_impl) 

584 

585 

586@router.get("/api/chat/sessions/{session_id}") 

587async def get_session( 

588 request: Request, 

589 session_id: str, 

590 username: Annotated[str, Depends(require_auth)], 

591): 

592 """Get a specific chat session.""" 

593 

594 def _impl(): 

595 try: 

596 service = ChatService(username) 

597 try: 

598 session_data = service.get_session(session_id) 

599 except ChatSessionNotFound: 

600 return JSONResponse( 

601 {"success": False, "error": "Session not found"}, 

602 status_code=404, 

603 ) 

604 

605 return JSONResponse({"success": True, "session": session_data}) 

606 

607 except ROUTE_EXCEPTIONS: 

608 logger.exception("Error getting chat session") 

609 return JSONResponse( 

610 {"success": False, "error": "Failed to get chat session"}, 

611 status_code=500, 

612 ) 

613 

614 return await run_db_sync(_impl) 

615 

616 

617@router.patch("/api/chat/sessions/{session_id}") 

618# Per-user keying, like the other state-changing chat routes. 

619@limiter.limit("30 per minute", key_func=_chat_user_key) 

620async def update_session( 

621 request: Request, 

622 session_id: str, 

623 username: Annotated[str, Depends(require_auth)], 

624): 

625 """Update a chat session (title, archive, delete).""" 

626 data = await _json_object_body(request) 

627 if isinstance(data, JSONResponse): 

628 return data 

629 

630 def _impl(): 

631 try: 

632 # Require at least one valid field 

633 valid_fields = {"title", "status"} 

634 if not any(field in data for field in valid_fields): 

635 return JSONResponse( 

636 { 

637 "success": False, 

638 "error": "Request must include at least one of: title, status", 

639 }, 

640 status_code=400, 

641 ) 

642 

643 service = ChatService(username) 

644 

645 try: 

646 service.get_session(session_id) 

647 except ChatSessionNotFound: 

648 return JSONResponse( 

649 {"success": False, "error": "Session not found"}, 

650 status_code=404, 

651 ) 

652 

653 ops_ok = True 

654 

655 if "title" in data: 

656 title = data["title"] 

657 err = _validate_title(title) 

658 if err is not None: 

659 msg, status = err 

660 return JSONResponse( 

661 {"success": False, "error": msg}, status_code=status 

662 ) 

663 ops_ok = ( 

664 service.update_session_title(session_id, title) and ops_ok 

665 ) 

666 

667 if "status" in data: 

668 new_status = data["status"] 

669 if new_status not in VALID_UPDATE_STATUSES: 

670 return JSONResponse( 

671 {"success": False, "error": "Invalid status value"}, 

672 status_code=400, 

673 ) 

674 if new_status == ChatSessionStatus.ACTIVE.value: 

675 ops_ok = service.reactivate_session(session_id) and ops_ok 

676 elif new_status == ChatSessionStatus.ARCHIVED.value: 676 ↛ 690line 676 didn't jump to line 690 because the condition on line 676 was always true

677 try: 

678 ops_ok = service.archive_session(session_id) and ops_ok 

679 except ArchiveBlockedError: 

680 # Symmetric with send-to-archived (also 409). Hard-coded 

681 # message — never echo str(exc) here (CWE-209). 

682 return JSONResponse( 

683 { 

684 "success": False, 

685 "error": "Cannot archive: research in_progress. Stop it first.", 

686 }, 

687 status_code=409, 

688 ) 

689 

690 try: 

691 session_data = service.get_session(session_id) 

692 except ChatSessionNotFound: 

693 # Session was deleted by a concurrent request between the 

694 # update above and this read-back. Treat as 404. 

695 return JSONResponse( 

696 {"success": False, "error": "Session not found"}, 

697 status_code=404, 

698 ) 

699 

700 if not ops_ok: 

701 # The read-back above succeeded, so the session still exists, 

702 # yet an update reported failure — a DB write error swallowed 

703 # into a False return. Surface it instead of reporting success. 

704 logger.error( 

705 f"Chat session update failed at DB layer for " 

706 f"{session_id[:8]}..." 

707 ) 

708 return JSONResponse( 

709 {"success": False, "error": "Failed to update session"}, 

710 status_code=500, 

711 ) 

712 

713 return JSONResponse({"success": True, "session": session_data}) 

714 

715 except ROUTE_EXCEPTIONS: 

716 logger.exception("Error updating chat session") 

717 return JSONResponse( 

718 {"success": False, "error": "Failed to update chat session"}, 

719 status_code=500, 

720 ) 

721 

722 return await run_db_sync(_impl) 

723 

724 

725@router.delete("/api/chat/sessions/{session_id}") 

726# Per-user keying, like the other state-changing chat routes. 

727@limiter.limit("30 per minute", key_func=_chat_user_key) 

728async def delete_session( 

729 request: Request, 

730 session_id: str, 

731 username: Annotated[str, Depends(require_auth)], 

732): 

733 """Delete a chat session permanently.""" 

734 

735 def _impl(): 

736 try: 

737 service = ChatService(username) 

738 success = service.delete_session(session_id) 

739 

740 if not success: 

741 return JSONResponse( 

742 {"success": False, "error": "Session not found"}, 

743 status_code=404, 

744 ) 

745 

746 return JSONResponse({"success": True}) 

747 

748 except ROUTE_EXCEPTIONS: 

749 logger.exception("Error deleting chat session") 

750 return JSONResponse( 

751 {"success": False, "error": "Failed to delete chat session"}, 

752 status_code=500, 

753 ) 

754 

755 return await run_db_sync(_impl) 

756 

757 

758# ============================================================================ 

759# Message API Routes 

760# ============================================================================ 

761 

762 

763@router.get("/api/chat/sessions/{session_id}/messages") 

764async def get_messages( 

765 request: Request, 

766 session_id: str, 

767 username: Annotated[str, Depends(require_auth)], 

768): 

769 """Get messages for a chat session (cursor-paginated, ASC).""" 

770 

771 def _impl(): 

772 try: 

773 limit = _parse_int_param( 

774 request.query_params.get("limit"), 50, min_val=1, max_val=100 

775 ) 

776 offset = _parse_int_param( 

777 request.query_params.get("offset"), 

778 0, 

779 min_val=0, 

780 max_val=MAX_OFFSET, 

781 ) 

782 before_created_at = ( 

783 request.query_params.get("before_created_at") or None 

784 ) 

785 before_id = request.query_params.get("before_id") or None 

786 

787 service = ChatService(username) 

788 

789 try: 

790 service.get_session(session_id) 

791 except ChatSessionNotFound: 

792 return JSONResponse( 

793 {"success": False, "error": "Session not found"}, 

794 status_code=404, 

795 ) 

796 

797 # Fetch one extra row so we can tell the client whether more 

798 # older entries exist without a second round-trip. 

799 peek_limit = limit + 1 

800 page = service.get_session_messages( 

801 session_id, 

802 limit=peek_limit, 

803 offset=offset, 

804 before_created_at=before_created_at, 

805 before_id=before_id, 

806 ) 

807 has_more = len(page) > limit 

808 messages = page[-limit:] if has_more else page 

809 

810 # The client (chat.js loadSession) restores the live "thinking" 

811 # indicator from this field. O(1) via the partial-unique index 

812 # ux_research_history_chat_session_in_progress. 

813 in_progress_research_id = service.get_in_progress_research_id( 

814 session_id 

815 ) 

816 

817 return JSONResponse( 

818 { 

819 "success": True, 

820 "messages": messages, 

821 "has_more": has_more, 

822 "in_progress_research_id": in_progress_research_id, 

823 } 

824 ) 

825 

826 except ChatCursorError: 

827 return JSONResponse( 

828 { 

829 "success": False, 

830 "error": "Invalid pagination cursor. before_created_at must " 

831 "include a timezone; before_id must identify an entry in " 

832 "this session at before_created_at.", 

833 }, 

834 status_code=400, 

835 ) 

836 except ROUTE_EXCEPTIONS: 

837 logger.exception("Error getting chat messages") 

838 return JSONResponse( 

839 {"success": False, "error": "Failed to get chat messages"}, 

840 status_code=500, 

841 ) 

842 

843 return await run_db_sync(_impl) 

844 

845 

846@router.post("/api/chat/sessions/{session_id}/messages") 

847# Per-user keying. send_message launches a full research run, so this is the 

848# heaviest chat endpoint; shared-IP users sharing the bucket would lock each 

849# other out. 

850@limiter.limit("10 per minute", key_func=_chat_user_key) 

851async def send_message( 

852 request: Request, 

853 session_id: str, 

854 username: Annotated[str, Depends(require_auth)], 

855): 

856 """Send a message in a chat session, optionally triggering research. 

857 

858 Research mode is always "quick" in chat (intentional v1 scope). 

859 """ 

860 data = await _json_object_body(request) 

861 if isinstance(data, JSONResponse): 

862 return data 

863 

864 def _impl(): 

865 admission_lock = None 

866 try: 

867 if not data or not data.get("content"): 

868 return JSONResponse( 

869 {"success": False, "error": "Message content is required"}, 

870 status_code=400, 

871 ) 

872 

873 # Reject non-string content before .strip() raises AttributeError 

874 # → 500. Mirrors the isinstance guard in _validate_title. 

875 if not isinstance(data["content"], str): 

876 return JSONResponse( 

877 {"success": False, "error": "content must be a string"}, 

878 status_code=400, 

879 ) 

880 

881 content = data["content"].strip() 

882 

883 # Reject whitespace-only content 

884 if not content: 

885 return JSONResponse( 

886 {"success": False, "error": "Message content is required"}, 

887 status_code=400, 

888 ) 

889 

890 if len(content) > MAX_MESSAGE_LENGTH: 

891 return JSONResponse( 

892 { 

893 "success": False, 

894 "error": f"Message too long (max {MAX_MESSAGE_LENGTH} characters)", 

895 }, 

896 status_code=400, 

897 ) 

898 

899 # trigger_research is a strict boolean. Reject non-bool values 

900 # with a 400 instead of silently coercing them to True: a client 

901 # sending {"trigger_research": "no"} or {"trigger_research": 0} 

902 # intends to SUPPRESS research, and coercing the truthy-string to 

903 # True would launch an unwanted (paid) research run against their 

904 # intent (#4427). 

905 raw = data.get("trigger_research", True) 

906 if not isinstance(raw, bool): 

907 return JSONResponse( 

908 { 

909 "success": False, 

910 "error": "trigger_research must be a boolean", 

911 }, 

912 status_code=400, 

913 ) 

914 trigger_research = raw 

915 

916 service = ChatService(username) 

917 

918 # Verify session exists (informational fast-fail; the 

919 # UPDATE...RETURNING inside insert_message_in_db is the 

920 # authoritative check that survives a delete-race). 

921 try: 

922 session_data = service.get_session(session_id) 

923 except ChatSessionNotFound: 

924 return JSONResponse( 

925 {"success": False, "error": "Session not found"}, 

926 status_code=404, 

927 ) 

928 

929 # Reject sends to non-active sessions. Archived/deleted sessions 

930 # are intentionally read-only — users must reactivate first. 

931 if session_data.get("status") != ChatSessionStatus.ACTIVE.value: 

932 return JSONResponse( 

933 { 

934 "success": False, 

935 "error": "This chat is archived. Reactivate it to continue.", 

936 }, 

937 status_code=409, 

938 ) 

939 

940 # Pre-fetch existing messages for context decisions. 

941 # include_steps=False keeps `limit` spent entirely on genuine 

942 # ChatMessage conversation turns. Progress-step rows would 

943 # otherwise dominate this 20-entry window on a research-heavy 

944 # session and push real conversation history out of it before 

945 # ChatContextManager ever sees it — and ChatContextManager 

946 # discards step rows anyway, so there is no reason to spend the 

947 # window budget on them here. 

948 messages = service.get_session_messages( 

949 session_id, limit=20, include_steps=False 

950 ) 

951 

952 research_id = None 

953 research_mode = "none" 

954 message_id = None 

955 settings_snapshot = None 

956 research_context = None 

957 

958 if trigger_research: 

959 # Always quick mode in chat (intentional v1 scope). 

960 research_mode = "quick" 

961 

962 # Verify the DB password is available BEFORE creating any rows 

963 # or spawning a worker. Chat-triggered research runs on a 

964 # background thread that writes token/search metrics to the 

965 # user's encrypted database; without the password every metric 

966 # write is silently dropped, leaving the metrics dashboard empty 

967 # while the research still completes (issue #4457). The password 

968 # is re-fetched at the spawn site below, so discard it here. 

969 _pw, session_expired = resolve_user_password(username) 

970 if session_expired: 

971 # Use this router's {success, error} shape (the chat 

972 # frontend reads `err.error`); a {status, message} body 

973 # would surface only a generic "Request failed (401)". 

974 return JSONResponse( 

975 { 

976 "success": False, 

977 "error": "Your session has expired. Please log out and log back in to continue.", 

978 }, 

979 status_code=401, 

980 ) 

981 

982 # ChatService's reads above reuse a thread-local Session and 

983 # leave its read transaction open. Release that connection 

984 # before taking the admission gate: every producer follows 

985 # gate -> DB, and reversing the order here can deadlock under 

986 # pool exhaustion with a gate holder waiting for a connection. 

987 with get_user_db_session(username) as ordering_db: 

988 ordering_db.rollback() 

989 

990 # Short gated fast-fail before the potentially slow context 

991 # build below. The authoritative check is repeated after that 

992 # work and retained through claim + worker registration. 

993 with user_research_start_gate(username): 

994 cap_error = _check_chat_research_slot_locked( 

995 username, session_id 

996 ) 

997 if cap_error is not None: 

998 message, status, active_research_id = cap_error 

999 payload = {"success": False, "error": message} 

1000 if active_research_id is not None: 

1001 payload["active_research_id"] = active_research_id 

1002 return JSONResponse(payload, status_code=status) 

1003 

1004 # Settings + context (read-only — fine to do after the cap 

1005 # check, before the atomic write). 

1006 if trigger_research: 

1007 settings_snapshot = _load_settings(username) 

1008 context_manager = ChatContextManager( 

1009 session_id, 

1010 messages, 

1011 session_data.get("accumulated_context"), 

1012 settings_snapshot=settings_snapshot, 

1013 ) 

1014 # Pass the new user message so prior conversation is condensed 

1015 # into a summary focused on this question. 

1016 research_context = context_manager.build_research_context( 

1017 current_query=content 

1018 ) 

1019 research_id = str(uuid.uuid4()) 

1020 

1021 # Parse numeric search settings up-front. A malformed value 

1022 # must return a clean 400 HERE — before the atomic write 

1023 # below commits the user message + IN_PROGRESS research row. 

1024 try: 

1025 iterations = int( 

1026 settings_snapshot.get("search.iterations", {}).get( 

1027 "value", 3 

1028 ) 

1029 ) 

1030 questions = int( 

1031 settings_snapshot.get( 

1032 "search.questions_per_iteration", {} 

1033 ).get("value", 1) 

1034 ) 

1035 except (ValueError, TypeError): 

1036 return JSONResponse( 

1037 { 

1038 "success": False, 

1039 "error": ( 

1040 "Invalid numeric value in search settings " 

1041 "(iterations / questions_per_iteration)." 

1042 ), 

1043 }, 

1044 status_code=400, 

1045 ) 

1046 

1047 # End the cached read transaction before taking the stable 

1048 # per-user gate (gate -> DB is the process-wide lock order). 

1049 with get_user_db_session(username) as ordering_db: 

1050 ordering_db.rollback() 

1051 

1052 pending_admission_lock = get_user_research_start_lock(username) 

1053 pending_admission_lock.acquire() 

1054 admission_lock = pending_admission_lock 

1055 

1056 # Context preparation can take long enough for another start 

1057 # to consume the last slot. Reconcile and recheck under the 

1058 # gate that remains held through commit, registration, and 

1059 # every spawn-failure cleanup return below. 

1060 cap_error = _check_chat_research_slot_locked( 

1061 username, session_id 

1062 ) 

1063 if cap_error is not None: 

1064 message, status, active_research_id = cap_error 

1065 payload = {"success": False, "error": message} 

1066 if active_research_id is not None: 1066 ↛ 1068line 1066 didn't jump to line 1068 because the condition on line 1066 was always true

1067 payload["active_research_id"] = active_research_id 

1068 return JSONResponse(payload, status_code=status) 

1069 

1070 # ---- Atomic write: user message + research row in ONE txn ---- 

1071 # Closes the orphan window: any IntegrityError or 

1072 # concurrent-delete on the research insert rolls back the user 

1073 # message too. The UPDATE...RETURNING inside insert_message_in_db 

1074 # doubles as the authoritative "session-still-exists" check. 

1075 try: 

1076 with get_user_db_session(username) as db_session: 

1077 message_id = service.insert_message_in_db( 

1078 db_session, 

1079 session_id=session_id, 

1080 role="user", 

1081 content=content, 

1082 message_type="query" 

1083 if len(messages) == 0 

1084 else "followup", 

1085 ) 

1086 

1087 if trigger_research: 

1088 created_at = datetime.now(UTC).isoformat() 

1089 research_meta = { 

1090 "submission": { 

1091 "chat_session_id": session_id, 

1092 "message_id": message_id, 

1093 "research_mode": research_mode, 

1094 }, 

1095 # Persist the run's settings snapshot so 

1096 # view/export (report_assembly_service) renders 

1097 # the same report.uncited_sources_mode the 

1098 # generation-time render used. Mirrors the 

1099 # main-form creation path; completion merge 

1100 # back-fills when missing. 

1101 "settings_snapshot": settings_snapshot, 

1102 } 

1103 research = ResearchHistory( 

1104 id=research_id, 

1105 query=content, 

1106 mode=research_mode, 

1107 status=ResearchStatus.IN_PROGRESS.value, 

1108 created_at=created_at, 

1109 progress_log=[{"time": created_at, "progress": 0}], 

1110 research_meta=research_meta, 

1111 chat_session_id=session_id, 

1112 ) 

1113 db_session.add(research) 

1114 

1115 # Count this research toward the per-user concurrent 

1116 # cap. Mirrors research_routes.start_research. Added in 

1117 # the SAME transaction as the research row so the 

1118 # IntegrityError rollback below undoes both. 

1119 db_session.add( 

1120 UserActiveResearch( 

1121 username=username, 

1122 research_id=research_id, 

1123 status=ResearchStatus.IN_PROGRESS, 

1124 thread_id=str(threading.current_thread().ident), 

1125 settings_snapshot=settings_snapshot, 

1126 ) 

1127 ) 

1128 

1129 try: 

1130 db_session.commit() 

1131 except IntegrityError: 

1132 # The admission gate is held from the recheck through 

1133 # this commit, so no other chat send or retry in this 

1134 # process can pass the per-session guard meanwhile. 

1135 # The partial unique index on (chat_session_id) WHERE 

1136 # status='in_progress' (migration 0010) is the 

1137 # backstop for a writer outside that gate and catches 

1138 # the loser here. Rolling back also undoes the 

1139 # user-message INSERT and the message_count 

1140 # increment — no orphan. 

1141 db_session.rollback() 

1142 logger.warning( 

1143 f"Concurrent in-progress research race for chat {session_id[:8]}..." 

1144 ) 

1145 return JSONResponse( 

1146 { 

1147 "success": False, 

1148 "error": "Research already in progress on this chat session.", 

1149 }, 

1150 status_code=409, 

1151 ) 

1152 except ValueError as exc: 

1153 # `insert_message_in_db` raises ValueError("not found") when 

1154 # the session row was deleted between the existence check and 

1155 # the UPDATE...RETURNING. Map to 404. 

1156 if "not found" in str(exc).lower(): 

1157 return JSONResponse( 

1158 {"success": False, "error": "Session not found"}, 

1159 status_code=404, 

1160 ) 

1161 raise 

1162 # ---- end atomic write ---- 

1163 

1164 if trigger_research: 

1165 # Type narrowing: variables initialized to None at the top 

1166 # and assigned inside the matching `if trigger_research:` 

1167 # block above. Real runtime check (not assert) so it survives 

1168 # ``python -O`` (bandit S101). 

1169 if ( 

1170 settings_snapshot is None 

1171 or research_context is None 

1172 or research_id is None 

1173 or message_id is None 

1174 ): # pragma: no cover — unreachable invariant guard 

1175 raise RuntimeError( 

1176 "trigger_research path entered with unset state" 

1177 ) 

1178 # Get user password for metrics 

1179 pw = get_user_password(username) 

1180 

1181 # Extract settings values with safe .get() defaults 

1182 model_provider = settings_snapshot.get("llm.provider", {}).get( 

1183 "value", "" 

1184 ) 

1185 model = settings_snapshot.get("llm.model", {}).get("value", "") 

1186 search_engine = settings_snapshot.get("search.tool", {}).get( 

1187 "value", "" 

1188 ) 

1189 # llm.openai_endpoint.url is only the client's base URL for 

1190 # the openai_endpoint provider; passing it for any other 

1191 # provider mislabels that provider's endpoint downstream. 

1192 custom_endpoint = ( 

1193 settings_snapshot.get("llm.openai_endpoint.url", {}).get( 

1194 "value" 

1195 ) 

1196 if normalize_provider(model_provider) == "openai_endpoint" 

1197 else None 

1198 ) 

1199 # Defensive fallbacks kept in sync with default_settings.json. 

1200 user_strategy = settings_snapshot.get( 

1201 "search.search_strategy", {} 

1202 ).get("value", "langgraph-agent") 

1203 # `iterations` and `questions` were parsed + validated up-front 

1204 # (before the atomic write) so a malformed setting returns a 

1205 # clean 400 instead of orphaning a committed research row. 

1206 

1207 # For follow-up messages, use the contextual follow-up strategy 

1208 # which wraps the user's preferred strategy as a delegate. 

1209 if research_context.get("is_multi_turn"): 

1210 strategy = "enhanced-contextual-followup" 

1211 research_context["delegate_strategy"] = user_strategy 

1212 else: 

1213 strategy = user_strategy 

1214 

1215 # Spawn the worker thread. ``DuplicateResearchError`` inherits 

1216 # from ``Exception`` (not RuntimeError) so it is NOT in 

1217 # ROUTE_EXCEPTIONS and would otherwise escape to a generic 500 

1218 # — leaving the rows we just committed orphaned. Catch here, 

1219 # undo our side effects, and return 409. 

1220 try: 

1221 start_research_process( 

1222 research_id, 

1223 content, 

1224 research_mode, 

1225 run_research_process, 

1226 username=username, 

1227 user_password=pw, 

1228 model_provider=model_provider, 

1229 model=model, 

1230 search_engine=search_engine, 

1231 custom_endpoint=custom_endpoint, 

1232 strategy=strategy, 

1233 iterations=iterations, 

1234 questions_per_iteration=questions, 

1235 research_context=research_context, 

1236 chat_session_id=session_id, 

1237 chat_message_id=message_id, 

1238 settings_snapshot=settings_snapshot, 

1239 ) 

1240 except DuplicateResearchError: 

1241 logger.warning( 

1242 f"DuplicateResearchError on chat send_message " 

1243 f"for {research_id[:8]}... (chat {session_id[:8]}...)" 

1244 ) 

1245 # Per DuplicateResearchError docstring: do NOT mutate 

1246 # UserActiveResearch or the existing ResearchHistory row — 

1247 # those belong to the live thread. Only undo our own rows. 

1248 _cleanup_chat_send_rows( 

1249 username, 

1250 research_id, 

1251 message_id, 

1252 session_id, 

1253 "duplicate", 

1254 ) 

1255 return JSONResponse( 

1256 { 

1257 "success": False, 

1258 "error": "Research already in progress on this chat session.", 

1259 }, 

1260 status_code=409, 

1261 ) 

1262 except SystemAtCapacityError: 

1263 # System at concurrent-research capacity. Undo the rows we 

1264 # committed above and return 429 so the client can retry. 

1265 logger.warning( 

1266 f"SystemAtCapacityError on chat send_message " 

1267 f"for {research_id[:8]}... (chat {session_id[:8]}...)" 

1268 ) 

1269 _cleanup_chat_send_rows( 

1270 username, 

1271 research_id, 

1272 message_id, 

1273 session_id, 

1274 "capacity", 

1275 ) 

1276 return JSONResponse( 

1277 { 

1278 "success": False, 

1279 "error": "Server is at research capacity. Please retry shortly.", 

1280 }, 

1281 status_code=429, 

1282 ) 

1283 except Exception: 

1284 # Thread construction/registration can fail for reasons 

1285 # other than duplicate ownership or capacity (for example, 

1286 # RuntimeError from threading.Thread.start). The durable 

1287 # message, research, and per-user claim already exist, so 

1288 # undo all of them before releasing the admission gate. 

1289 logger.exception( 

1290 f"Failed to spawn chat research " 

1291 f"{research_id[:8]}... (chat {session_id[:8]}...)" 

1292 ) 

1293 _cleanup_chat_send_rows( 

1294 username, 

1295 research_id, 

1296 message_id, 

1297 session_id, 

1298 "spawn failure", 

1299 ) 

1300 return JSONResponse( 

1301 { 

1302 "success": False, 

1303 "error": "Failed to start research. Please try again.", 

1304 }, 

1305 status_code=500, 

1306 ) 

1307 

1308 logger.info( 

1309 f"Started chat research {research_id[:8]}... for chat {session_id[:8]}..." 

1310 ) 

1311 

1312 return JSONResponse( 

1313 { 

1314 "success": True, 

1315 "message_id": message_id, 

1316 "session_id": session_id, 

1317 "research_id": research_id, 

1318 "research_mode": research_mode, 

1319 } 

1320 ) 

1321 

1322 except ROUTE_EXCEPTIONS: 

1323 logger.exception("Error sending chat message") 

1324 return JSONResponse( 

1325 {"success": False, "error": "Failed to send message"}, 

1326 status_code=500, 

1327 ) 

1328 finally: 

1329 if admission_lock is not None: 

1330 admission_lock.release() 

1331 

1332 return await run_db_sync(_impl) 

1333 

1334 

1335def _check_chat_research_slot_locked( 

1336 username: str, session_id: str 

1337) -> tuple[str, int, str | None] | None: 

1338 """Reconcile and check chat admission while the caller holds its gate. 

1339 

1340 The caller must acquire ``user_research_start_gate(username)`` before 

1341 entering this helper. Opening the DB here preserves the process-wide 

1342 gate-before-session order. The read transaction is explicitly ended 

1343 before returning so a later gate acquisition cannot form a pool cycle. 

1344 

1345 Rows older than the 30-second spawn grace window whose worker is dead 

1346 are reclaimed before the slot checks run: this session's IN_PROGRESS 

1347 research rows first, then the user's ``UserActiveResearch`` slots. Once 

1348 that window has passed, a crashed worker's rows therefore cannot trip 

1349 the per-session 409 or the per-user 429. Returns 

1350 ``(error_message, http_status, active_research_id)`` to reject the 

1351 request, else ``None``. 

1352 """ 

1353 grace_cutoff_dt = datetime.now(UTC) - timedelta(seconds=30) 

1354 grace_cutoff_iso = grace_cutoff_dt.isoformat() 

1355 

1356 with get_user_db_session(username) as cap_db: 

1357 try: 

1358 stale_chat = ( 

1359 cap_db.query(ResearchHistory) 

1360 .filter( 

1361 ResearchHistory.chat_session_id == session_id, 

1362 ResearchHistory.status == ResearchStatus.IN_PROGRESS, 

1363 ResearchHistory.created_at < grace_cutoff_iso, 

1364 ) 

1365 .all() 

1366 ) 

1367 reclaimed_chat = False 

1368 for row in stale_chat: 

1369 if not is_research_thread_alive(row.id): 

1370 logger.warning( 

1371 f"Reclaiming stale chat research {row.id[:8]}... " 

1372 f"(thread dead) on chat {session_id[:8]}..." 

1373 ) 

1374 row.status = ResearchStatus.FAILED 

1375 cleanup_research(row.id) 

1376 reclaimed_chat = True 

1377 if reclaimed_chat: 

1378 cap_db.commit() 

1379 

1380 if reclaim_stale_user_active_research( 

1381 cap_db, 

1382 username, 

1383 grace_cutoff_dt=grace_cutoff_dt, 

1384 logger=logger, 

1385 ): 

1386 cap_db.commit() 

1387 

1388 cap_error = _enforce_chat_session_research_slot( 

1389 cap_db, username, session_id 

1390 ) 

1391 if cap_error is None: 

1392 return None 

1393 

1394 message, status = cap_error 

1395 active_research_id = None 

1396 if status == 409: 

1397 active_row = ( 

1398 cap_db.query(ResearchHistory.id) 

1399 .filter_by( 

1400 chat_session_id=session_id, 

1401 status=ResearchStatus.IN_PROGRESS, 

1402 ) 

1403 .first() 

1404 ) 

1405 if active_row is not None: 1405 ↛ 1407line 1405 didn't jump to line 1407 because the condition on line 1405 was always true

1406 active_research_id = active_row[0] 

1407 return message, status, active_research_id 

1408 finally: 

1409 cap_db.rollback() 

1410 

1411 

1412def _enforce_chat_session_research_slot( 

1413 cap_db, username: str, session_id: str 

1414) -> tuple[str, int] | None: 

1415 """Reject send/retry when this chat session or user is at capacity. 

1416 

1417 Runs inside the caller's ``cap_db`` transaction after any stale-row 

1418 reclaims, so those writes are visible to the count check below it. 

1419 

1420 Returns ``(error_message, http_status)`` when the request should be 

1421 rejected, else ``None``. 

1422 

1423 Two checks: 

1424 1. Per-session guard: at most one live research per chat session. 

1425 2. Per-user global cap: at most ``app.max_concurrent_researches`` 

1426 researches per user across ALL sessions. Mirrors 

1427 research start_research. 

1428 

1429 DOES NOT include the stale-row reclaim sweep. Its only caller, 

1430 ``_check_chat_research_slot_locked``, first reclaims dead rows older 

1431 than its grace window so neither check counts them. 

1432 """ 

1433 # Per-session guard. Note: this fires for ANY in-progress research 

1434 # on the session, INCLUDING the target research on a retry path. 

1435 # That is how retry refuses a target whose worker is still live, 

1436 # before anything is deleted. A target whose worker died more than the 

1437 # 30-second grace window ago has already been reclaimed by the caller's 

1438 # sweep, so it cannot block its own retry. 

1439 existing_session_research = ( 

1440 cap_db.query(ResearchHistory) 

1441 .filter_by( 

1442 chat_session_id=session_id, 

1443 status=ResearchStatus.IN_PROGRESS, 

1444 ) 

1445 .first() 

1446 ) 

1447 if existing_session_research: 

1448 return ( 

1449 "Research already in progress on this chat session. " 

1450 "Stop it before sending a new message.", 

1451 409, 

1452 ) 

1453 

1454 active_count = ( 

1455 cap_db.query(UserActiveResearch) 

1456 .filter_by( 

1457 username=username, 

1458 status=ResearchStatus.IN_PROGRESS, 

1459 ) 

1460 .count() 

1461 ) 

1462 # Clamp to the server-wide semaphore ceiling (#5549): 

1463 # app.max_concurrent_researches is user-editable but the semaphore it 

1464 # gates is global, so an unclamped read would let one user raise their 

1465 # own limit past the operator's and starve everyone else. 

1466 max_concurrent = clamp_user_max_concurrent( 

1467 SettingsManager(db_session=cap_db).get_setting( 

1468 "app.max_concurrent_researches", 3 

1469 ) 

1470 ) 

1471 if active_count >= max_concurrent: 

1472 return ( 

1473 f"Concurrent research limit reached ({active_count}/" 

1474 f"{max_concurrent}). Wait for an existing research to finish.", 

1475 429, 

1476 ) 

1477 

1478 return None 

1479 

1480 

1481# ============================================================================ 

1482# Per-attempt API Routes (delete + retry a single chat turn) 

1483# ============================================================================ 

1484 

1485 

1486@router.delete("/api/chat/sessions/{session_id}/attempts/{research_id}") 

1487# Per-user keying, like the other state-changing chat routes. Caps bulk 

1488# delete attempts that the global limiter alone left under-constrained. 

1489@limiter.limit("30 per minute", key_func=_chat_user_key) 

1490async def delete_attempt( 

1491 request: Request, 

1492 session_id: str, 

1493 research_id: str, 

1494 username: Annotated[str, Depends(require_auth)], 

1495): 

1496 """Delete a single chat attempt (user message + research + response). 

1497 

1498 Refuses with 409 if the target research is IN_PROGRESS and its worker 

1499 thread is alive — the client must Stop it first (or wait for it to 

1500 fail naturally). Stale IN_PROGRESS rows whose thread is dead are 

1501 reclaimed and deleted. 

1502 """ 

1503 

1504 def _impl(): 

1505 try: 

1506 service = ChatService(username) 

1507 try: 

1508 service.delete_attempt(session_id, research_id) 

1509 except AttemptNotFound: 

1510 return JSONResponse( 

1511 {"success": False, "error": "Attempt not found"}, 

1512 status_code=404, 

1513 ) 

1514 except AttemptInProgress: 

1515 return JSONResponse( 

1516 { 

1517 "success": False, 

1518 "error": ( 

1519 "Research is in progress. Stop it before deleting " 

1520 "the attempt." 

1521 ), 

1522 "active_research_id": research_id, 

1523 }, 

1524 status_code=409, 

1525 ) 

1526 

1527 return JSONResponse({"success": True}) 

1528 

1529 except ROUTE_EXCEPTIONS: 

1530 logger.exception("Error deleting chat attempt") 

1531 return JSONResponse( 

1532 {"success": False, "error": "Failed to delete attempt"}, 

1533 status_code=500, 

1534 ) 

1535 

1536 return await run_db_sync(_impl) 

1537 

1538 

1539@router.post("/api/chat/sessions/{session_id}/attempts/{research_id}/retry") 

1540# Same per-minute cap as send_message: retry spawns a full research run, 

1541# so it shares the spawn budget rather than getting a separate one (else 

1542# alternating send/retry would let a user exceed the intended rate). 

1543@limiter.limit("10 per minute", key_func=_chat_user_key) 

1544async def retry_attempt( 

1545 request: Request, 

1546 session_id: str, 

1547 research_id: str, 

1548 username: Annotated[str, Depends(require_auth)], 

1549): 

1550 """Retry a chat attempt: delete the old turn, re-submit same content. 

1551 

1552 Looks up the original user message content via 

1553 ``ChatService.get_original_attempt_query`` (uses 

1554 ``research_meta.submission.message_id`` first, falls back to a 

1555 ChatMessage query for older rows), deletes the old attempt 

1556 atomically, then runs the same spawn path as ``send_message``. 

1557 

1558 Returns the SAME shape as ``send_message`` so the client can 

1559 subscribe to the new research_id via its existing post-send flow. 

1560 """ 

1561 

1562 def _impl(): 

1563 admission_lock = None 

1564 service = None 

1565 rag_targets: list[tuple[str, list[str]]] = [] 

1566 try: 

1567 service = ChatService(username) 

1568 

1569 # Verify session exists + is active BEFORE doing anything 

1570 # destructive. The get_session call also seeds session_data for 

1571 # the context-builder below. 

1572 try: 

1573 session_data = service.get_session(session_id) 

1574 except ChatSessionNotFound: 

1575 return JSONResponse( 

1576 {"success": False, "error": "Session not found"}, 

1577 status_code=404, 

1578 ) 

1579 

1580 if session_data.get("status") != ChatSessionStatus.ACTIVE.value: 

1581 return JSONResponse( 

1582 { 

1583 "success": False, 

1584 "error": ( 

1585 "This chat is archived. Reactivate it to continue." 

1586 ), 

1587 }, 

1588 status_code=409, 

1589 ) 

1590 

1591 # Resolve DB password BEFORE any destructive op: the spawn path 

1592 # needs it for metrics writes, and re-checking AFTER 

1593 # delete_attempt would leave the attempt gone on a 

1594 # session-expired 401. 

1595 _pw, session_expired = resolve_user_password(username) 

1596 if session_expired: 

1597 return JSONResponse( 

1598 { 

1599 "success": False, 

1600 "error": ( 

1601 "Your session has expired. Please log out and " 

1602 "log back in to continue." 

1603 ), 

1604 }, 

1605 status_code=401, 

1606 ) 

1607 

1608 # get_session() leaves the reused thread-local Session in a read 

1609 # transaction. End it before the early gate acquisition so retry 

1610 # follows the same gate -> DB order as every other producer. 

1611 with get_user_db_session(username) as ordering_db: 

1612 ordering_db.rollback() 

1613 

1614 # Cheap gated fast-fail before context preparation. If the target 

1615 # is still live this returns 409 before any destructive work. 

1616 with user_research_start_gate(username): 

1617 cap_error = _check_chat_research_slot_locked( 

1618 username, session_id 

1619 ) 

1620 if cap_error is not None: 

1621 message, status, active_research_id = cap_error 

1622 payload = {"success": False, "error": message} 

1623 if active_research_id is not None: 

1624 payload["active_research_id"] = active_research_id 

1625 return JSONResponse(payload, status_code=status) 

1626 

1627 # Fetch original user content BEFORE deleting the old attempt. 

1628 # After delete_attempt the row is gone, so this lookup must 

1629 # succeed first. Raises AttemptNotFound → 404. 

1630 try: 

1631 original_content = service.get_original_attempt_query( 

1632 session_id, research_id 

1633 ) 

1634 except AttemptNotFound: 

1635 return JSONResponse( 

1636 {"success": False, "error": "Attempt not found"}, 

1637 status_code=404, 

1638 ) 

1639 

1640 if not original_content.strip(): 

1641 # Empty/whitespace-only original — shouldn't happen since 

1642 # send_message rejects these, but guard against a corrupt 

1643 # row before we destroy the only copy. 

1644 return JSONResponse( 

1645 { 

1646 "success": False, 

1647 "error": ( 

1648 "Original attempt has no query content to retry." 

1649 ), 

1650 }, 

1651 status_code=400, 

1652 ) 

1653 

1654 # Build the replacement context before deleting anything, but 

1655 # exclude the target attempt in memory so it cannot become its 

1656 # own prior context. Current rows link the triggering user message 

1657 # through research_meta; legacy rows carry research_id directly. 

1658 original_message_id = None 

1659 with get_user_db_session(username) as attempt_db: 

1660 attempt_row = ( 

1661 attempt_db.query(ResearchHistory) 

1662 .filter( 

1663 ResearchHistory.id == research_id, 

1664 ResearchHistory.chat_session_id == session_id, 

1665 ) 

1666 .first() 

1667 ) 

1668 if attempt_row is None: 1668 ↛ 1669line 1668 didn't jump to line 1669 because the condition on line 1668 was never true

1669 return JSONResponse( 

1670 {"success": False, "error": "Attempt not found"}, 

1671 status_code=404, 

1672 ) 

1673 submission = (attempt_row.research_meta or {}).get( 

1674 "submission", {} 

1675 ) 

1676 if isinstance(submission, dict): 

1677 candidate = submission.get("message_id") 

1678 if isinstance(candidate, str) and candidate: 1678 ↛ 1689line 1678 didn't jump to line 1689

1679 original_message_id = candidate 

1680 

1681 # include_steps=False keeps `limit` spent entirely on genuine 

1682 # ChatMessage conversation turns. Progress-step rows (there can 

1683 # be dozens per attempt, most recently from the very attempt 

1684 # being retried) would otherwise dominate the merged window used 

1685 # by get_session_messages() for client rendering and push real 

1686 # conversation history out of it before this filter ever runs — 

1687 # ChatContextManager discards step rows anyway, so there is no 

1688 # reason to spend the window budget on them here. 

1689 messages = service.get_session_messages( 

1690 session_id, limit=50, include_steps=False 

1691 ) 

1692 messages = [ 

1693 message 

1694 for message in messages 

1695 if message.get("research_id") != research_id 

1696 and message.get("id") != original_message_id 

1697 ][-20:] 

1698 

1699 settings_snapshot = _load_settings(username) 

1700 context_manager = ChatContextManager( 

1701 session_id, 

1702 messages, 

1703 session_data.get("accumulated_context"), 

1704 settings_snapshot=settings_snapshot, 

1705 ) 

1706 research_context = context_manager.build_research_context( 

1707 current_query=original_content 

1708 ) 

1709 

1710 # Parse numeric search settings up-front. A malformed value 

1711 # must return a clean 400 HERE — before the atomic write 

1712 # below commits the user message + IN_PROGRESS research row. 

1713 try: 

1714 iterations = int( 

1715 settings_snapshot.get("search.iterations", {}).get( 

1716 "value", 3 

1717 ) 

1718 ) 

1719 questions = int( 

1720 settings_snapshot.get( 

1721 "search.questions_per_iteration", {} 

1722 ).get("value", 1) 

1723 ) 

1724 except (ValueError, TypeError): 

1725 return JSONResponse( 

1726 { 

1727 "success": False, 

1728 "error": ( 

1729 "Invalid numeric value in search settings " 

1730 "(iterations / questions_per_iteration)." 

1731 ), 

1732 }, 

1733 status_code=400, 

1734 ) 

1735 

1736 # End all cached read transactions before taking the stable gate. 

1737 with get_user_db_session(username) as ordering_db: 

1738 ordering_db.rollback() 

1739 

1740 pending_admission_lock = get_user_research_start_lock(username) 

1741 pending_admission_lock.acquire() 

1742 admission_lock = pending_admission_lock 

1743 

1744 # Nothing destructive has happened yet. Recheck under the gate 

1745 # retained through delete, replacement claim, spawn, and cleanup. 

1746 cap_error = _check_chat_research_slot_locked(username, session_id) 

1747 if cap_error is not None: 

1748 message, status, active_research_id = cap_error 

1749 payload = {"success": False, "error": message} 

1750 if active_research_id is not None: 1750 ↛ 1752line 1750 didn't jump to line 1752 because the condition on line 1750 was always true

1751 payload["active_research_id"] = active_research_id 

1752 return JSONResponse(payload, status_code=status) 

1753 

1754 # Delete only after the authoritative gated recheck, so losing a 

1755 # race cannot destroy the user's old attempt without admitting its 

1756 # replacement. The RAG purge for documents the old attempt owned 

1757 # is deferred until the gate is released (see finally below): it 

1758 # does vector-store IO under FAISS locks, and the queue tick 

1759 # acquires every user's gate in turn. 

1760 try: 

1761 rag_targets = service.delete_attempt_rows( 

1762 session_id, research_id 

1763 ) 

1764 except AttemptInProgress: 

1765 return JSONResponse( 

1766 { 

1767 "success": False, 

1768 "error": ( 

1769 "Research is in progress. Stop it before retrying." 

1770 ), 

1771 "active_research_id": research_id, 

1772 }, 

1773 status_code=409, 

1774 ) 

1775 except AttemptNotFound: 

1776 return JSONResponse( 

1777 {"success": False, "error": "Attempt not found"}, 

1778 status_code=404, 

1779 ) 

1780 

1781 research_mode = "quick" 

1782 new_research_id = str(uuid.uuid4()) 

1783 

1784 # ---- Atomic write: user message + research row in ONE txn ---- 

1785 # Same pattern as send_message: any IntegrityError rolls back 

1786 # the user message too, so no orphan rows. 

1787 try: 

1788 with get_user_db_session(username) as db_session: 

1789 new_message_id = service.insert_message_in_db( 

1790 db_session, 

1791 session_id=session_id, 

1792 role="user", 

1793 content=original_content, 

1794 message_type="query" 

1795 if len(messages) == 0 

1796 else "followup", 

1797 ) 

1798 

1799 created_at = datetime.now(UTC).isoformat() 

1800 research_meta = { 

1801 "submission": { 

1802 "chat_session_id": session_id, 

1803 "message_id": new_message_id, 

1804 "research_mode": research_mode, 

1805 }, 

1806 # Persist the run's settings snapshot so 

1807 # view/export renders the same uncited-sources 

1808 # mode as generation. Mirrors the main-form path; 

1809 # completion merge back-fills when missing. 

1810 "settings_snapshot": settings_snapshot, 

1811 } 

1812 db_session.add( 

1813 ResearchHistory( 

1814 id=new_research_id, 

1815 query=original_content, 

1816 mode=research_mode, 

1817 status=ResearchStatus.IN_PROGRESS.value, 

1818 created_at=created_at, 

1819 progress_log=[{"time": created_at, "progress": 0}], 

1820 research_meta=research_meta, 

1821 chat_session_id=session_id, 

1822 ) 

1823 ) 

1824 db_session.add( 

1825 UserActiveResearch( 

1826 username=username, 

1827 research_id=new_research_id, 

1828 status=ResearchStatus.IN_PROGRESS, 

1829 thread_id=str(threading.current_thread().ident), 

1830 settings_snapshot=settings_snapshot, 

1831 ) 

1832 ) 

1833 

1834 try: 

1835 db_session.commit() 

1836 except IntegrityError: 

1837 db_session.rollback() 

1838 logger.warning( 

1839 f"Concurrent in-progress research race for " 

1840 f"chat {session_id[:8]}... (retry)" 

1841 ) 

1842 return JSONResponse( 

1843 { 

1844 "success": False, 

1845 "error": ( 

1846 "Research already in progress on this " 

1847 "chat session." 

1848 ), 

1849 }, 

1850 status_code=409, 

1851 ) 

1852 except ValueError as exc: 

1853 if "not found" in str(exc).lower(): 

1854 return JSONResponse( 

1855 {"success": False, "error": "Session not found"}, 

1856 status_code=404, 

1857 ) 

1858 raise 

1859 # ---- end atomic write ---- 

1860 

1861 pw = get_user_password(username) 

1862 

1863 model_provider = settings_snapshot.get("llm.provider", {}).get( 

1864 "value", "" 

1865 ) 

1866 model = settings_snapshot.get("llm.model", {}).get("value", "") 

1867 search_engine = settings_snapshot.get("search.tool", {}).get( 

1868 "value", "" 

1869 ) 

1870 # Only meaningful for the openai_endpoint provider (see above). 

1871 custom_endpoint = ( 

1872 settings_snapshot.get("llm.openai_endpoint.url", {}).get( 

1873 "value" 

1874 ) 

1875 if normalize_provider(model_provider) == "openai_endpoint" 

1876 else None 

1877 ) 

1878 user_strategy = settings_snapshot.get( 

1879 "search.search_strategy", {} 

1880 ).get("value", "langgraph-agent") 

1881 

1882 if research_context.get("is_multi_turn"): 

1883 strategy = "enhanced-contextual-followup" 

1884 research_context["delegate_strategy"] = user_strategy 

1885 else: 

1886 strategy = user_strategy 

1887 

1888 try: 

1889 start_research_process( 

1890 new_research_id, 

1891 original_content, 

1892 research_mode, 

1893 run_research_process, 

1894 username=username, 

1895 user_password=pw, 

1896 model_provider=model_provider, 

1897 model=model, 

1898 search_engine=search_engine, 

1899 custom_endpoint=custom_endpoint, 

1900 strategy=strategy, 

1901 iterations=iterations, 

1902 questions_per_iteration=questions, 

1903 research_context=research_context, 

1904 chat_session_id=session_id, 

1905 chat_message_id=new_message_id, 

1906 settings_snapshot=settings_snapshot, 

1907 ) 

1908 except DuplicateResearchError: 

1909 logger.warning( 

1910 f"DuplicateResearchError on chat retry_attempt " 

1911 f"for {new_research_id[:8]}... (chat {session_id[:8]}...)" 

1912 ) 

1913 _cleanup_chat_send_rows( 

1914 username, 

1915 new_research_id, 

1916 new_message_id, 

1917 session_id, 

1918 "duplicate", 

1919 preserve_attempt=True, 

1920 ) 

1921 return JSONResponse( 

1922 { 

1923 "success": False, 

1924 "error": ( 

1925 "Research already in progress on this chat session." 

1926 ), 

1927 }, 

1928 status_code=409, 

1929 ) 

1930 except SystemAtCapacityError: 

1931 logger.warning( 

1932 f"SystemAtCapacityError on chat retry_attempt " 

1933 f"for {new_research_id[:8]}... (chat {session_id[:8]}...)" 

1934 ) 

1935 _cleanup_chat_send_rows( 

1936 username, 

1937 new_research_id, 

1938 new_message_id, 

1939 session_id, 

1940 "capacity", 

1941 preserve_attempt=True, 

1942 ) 

1943 return JSONResponse( 

1944 { 

1945 "success": False, 

1946 "error": ( 

1947 "Server is at research capacity. Please retry " 

1948 "shortly." 

1949 ), 

1950 }, 

1951 status_code=429, 

1952 ) 

1953 except Exception: 

1954 logger.exception( 

1955 f"Failed to spawn retried chat research " 

1956 f"{new_research_id[:8]}... " 

1957 f"(chat {session_id[:8]}...)" 

1958 ) 

1959 _cleanup_chat_send_rows( 

1960 username, 

1961 new_research_id, 

1962 new_message_id, 

1963 session_id, 

1964 "retry spawn failure", 

1965 preserve_attempt=True, 

1966 ) 

1967 return JSONResponse( 

1968 { 

1969 "success": False, 

1970 "error": "Failed to retry research. Please try again.", 

1971 }, 

1972 status_code=500, 

1973 ) 

1974 

1975 logger.info( 

1976 f"Retried chat attempt as research " 

1977 f"{new_research_id[:8]}... for chat {session_id[:8]}..." 

1978 ) 

1979 

1980 return JSONResponse( 

1981 { 

1982 "success": True, 

1983 "session_id": session_id, 

1984 "research_id": new_research_id, 

1985 "message_id": new_message_id, 

1986 "research_mode": research_mode, 

1987 } 

1988 ) 

1989 

1990 except ROUTE_EXCEPTIONS: 

1991 logger.exception("Error retrying chat attempt") 

1992 return JSONResponse( 

1993 {"success": False, "error": "Failed to retry attempt"}, 

1994 status_code=500, 

1995 ) 

1996 finally: 

1997 if admission_lock is not None: 

1998 admission_lock.release() 

1999 # The old attempt's rows are committed as deleted whatever 

2000 # happened after, so purge its RAG data now that the gate is 

2001 # free. Best-effort: purge_attempt_rag logs its own failures. 

2002 if rag_targets and service is not None: 

2003 service.purge_attempt_rag(rag_targets) 

2004 

2005 return await run_db_sync(_impl)