Coverage for src/local_deep_research/web/routes/notes_routes.py: 70%

1134 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Notes Routes - API endpoints and pages for the notes feature. 

3""" 

4 

5from flask import Blueprint, jsonify, request, session 

6from loguru import logger 

7from sqlalchemy.exc import IntegrityError 

8from sqlalchemy.orm import load_only 

9 

10from ..auth.decorators import login_required 

11from ...research_library.notes.services.note_ai_service import NoteAIService 

12from ...research_library.notes.services.note_service import ( 

13 MAX_TAG_LENGTH, 

14 MAX_TAGS_PER_NOTE, 

15 NOTE_CONTENT_MAX_BYTES, 

16 NoteService, 

17 _assert_content_size, 

18 escape_markdown_link_label, 

19) 

20from ...research_library.utils import handle_api_error 

21from ...security.rate_limiter import limiter, _get_api_user_key 

22from ..utils.templates import render_template_with_defaults 

23 

24# Cap on user-supplied free-text query params that flow into `ILIKE %...%` 

25# over unbounded TEXT columns — shared with unified_search_routes via 

26# _search_constants so the two route modules can't diverge. 

27from ._search_constants import MAX_SEARCH_LEN 

28 

29# Bound at import time (not looked up on the class at call time) because 

30# tests monkeypatch the ``NoteAIService`` name in this module. 

31_MAX_GRADE_SOURCES = NoteAIService.MAX_GRADE_SOURCES 

32 

33MAX_LINK_TEXT_LEN = 500 

34# Per-claim length cap for the fact-check grade endpoint (claims are free text 

35# from the client; the grader bounds the count but not the per-string size). 

36MAX_CLAIM_LEN = 1000 

37# Upper bound on any ?limit query param across the list/search endpoints 

38# (was an unnamed 200 repeated at six call sites). 

39MAX_LIMIT = 200 

40 

41 

42def _clamp_limit(default, max_=MAX_LIMIT): 

43 """Parse ``?limit`` into ``[1, max_]``, defaulting to ``default``. 

44 

45 Raises ``ValueError`` on a non-integer value; the caller wraps this in 

46 ``try`` and returns the 400 (matching the six former inline copies). 

47 """ 

48 return max(1, min(int(request.args.get("limit", default)), max_)) 

49 

50 

51def _clamp_text_query(value, name, max_len=MAX_SEARCH_LEN): 

52 """Return value or raise ValueError if it exceeds the cap. 

53 

54 ``None``/empty pass through unchanged so optional params behave the 

55 same as before. The caller wraps this in ``try`` and lets 

56 ``handle_api_error`` produce the 4xx response. 

57 """ 

58 if value is None: 

59 return value 

60 if not isinstance(value, str): 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true

61 raise ValueError(f"{name} must be a string") 

62 if len(value) > max_len: 

63 raise ValueError(f"{name} exceeds maximum length ({max_len} chars)") 

64 return value 

65 

66 

67# Rate limits for AI-heavy endpoints. Split into two buckets so cheap 

68# FAISS / local-embedding lookups (semantic search, similar notes, 

69# suggested links, related research) don't drain the same budget as 

70# expensive LLM calls (summarize, suggest_tags, extract_key_concepts, 

71# semantic_diff). A user typing through the autocomplete UI shouldn't 

72# block a single LLM call for a full minute. 

73# 

74# ``key_func=_get_api_user_key`` keys each bucket per authenticated user; 

75# without it the global Limiter default (``get_client_ip``) would let 

76# shared-IP users (NAT, lab, reverse-proxy-without-trusted-forward-headers) 

77# drain each other's LLM/synthesis budgets. Matches the pattern in 

78# rate_limiter.py for sibling per-user buckets (journal_data, journals_read, 

79# api_rate_limit, upload_rate_limit_user). 

80_notes_ai_limit = limiter.shared_limit( 

81 "10 per minute", scope="notes_ai", key_func=_get_api_user_key 

82) 

83_notes_search_limit = limiter.shared_limit( 

84 "60 per minute", scope="notes_search", key_func=_get_api_user_key 

85) 

86_notes_synthesize_limit = limiter.shared_limit( 

87 "5 per minute", scope="notes_synthesize", key_func=_get_api_user_key 

88) 

89# Notes write budget — applies to create_note, update_note, delete_note, 

90# and add/remove from collection. 60/min comfortably covers active 

91# editing (a save every second for a minute) but blocks scripted abuse: 

92# pre-fix an authenticated user could rapid-POST PUT /api/notes/<id> 

93# under the global IP cap (5000/hr) and saturate the 4-worker 

94# summary_executor with bursty LLM change-summary tasks, queueing 

95# hundreds of MB of (old, new) content pairs. Per-user keying so two 

96# real users on shared NAT don't compete for the same bucket. 

97_notes_write_limit = limiter.shared_limit( 

98 "60 per minute", scope="notes_write", key_func=_get_api_user_key 

99) 

100# Save-as-note copies a whole completed report (up to the 50MB content 

101# cap) into a fresh note + version snapshot per call. At the generic 

102# notes_write 60/min that's on the order of GBs/min of per-user DB writes 

103# from one client — a disk/backup-bloat vector. Tighter, cost-aware bucket. 

104_notes_save_as_note_limit = limiter.shared_limit( 

105 "10 per minute", scope="notes_save_as_note", key_func=_get_api_user_key 

106) 

107# Fact-check kicks off a full LDR research run downstream, so it's strictly 

108# tighter than the LLM bucket — a few per minute bounds research load. 

109_notes_factcheck_limit = limiter.shared_limit( 

110 "3 per minute", scope="notes_factcheck", key_func=_get_api_user_key 

111) 

112 

113# Create a Blueprint for the notes routes 

114notes_bp = Blueprint("notes", __name__, url_prefix="/notes") 

115 

116# Pre-parse request-body ceiling for this JSON API surface. The app-wide 

117# MAX_CONTENT_LENGTH is sized for multi-file uploads (hundreds of GB), and 

118# request.get_json() buffers + parses the WHOLE body before any service 

119# validation runs — so without this check a multi-GB JSON body reached 

120# memory before _assert_content_size could reject it at 50 MB. 

121# 

122# Budget 2× the content cap. This bounds pre-parse memory; it does NOT 

123# precisely enforce the 50 MB content limit (that stays in 

124# _assert_content_size after parsing). 2× covers the realistic worst case 

125# where every character needs a 2-byte JSON escape (``"`` / ``\`` / 

126# newline); content dominated by control chars (each expands to a 6-byte 

127# ``\uXXXX``) can still exceed this and get a 413 even though its decoded 

128# size is under 50 MB — an acceptable trade for keeping the cap far below 

129# a memory-exhausting multi-GB body. 

130_MAX_JSON_BODY_BYTES = 2 * NOTE_CONTENT_MAX_BYTES 

131 

132 

133def arm_notes_body_cap(): 

134 """Set the JSON body cap on the request so Werkzeug enforces it while 

135 READING the stream (catches ``Transfer-Encoding: chunked`` bodies that 

136 carry no Content-Length and would otherwise be buffered whole). 

137 

138 Registered as an APP-level ``before_request`` in app_factory, BEFORE 

139 ``CSRFProtect(app)``: Flask-WTF's CSRF check reads ``request.form`` on 

140 every mutating request, which caches ``request.stream`` with the 

141 max_content_length in effect at THAT moment. App-level hooks run in 

142 registration order, so a notes-blueprint hook (or a later app-level 

143 one) runs too late — the stream is already cached under the app-wide, 

144 multi-file-upload MAX_CONTENT_LENGTH (hundreds of GB) and the cap never 

145 takes effect for chunked bodies. 

146 """ 

147 if request.blueprint == notes_bp.name: 

148 request.max_content_length = _MAX_JSON_BODY_BYTES 

149 

150 

151@notes_bp.before_request 

152def _reject_oversized_bodies(): 

153 """413 oversized bodies with a Content-Length header before parsing. 

154 

155 The stream-read cap (chunked bodies too) is armed earlier by 

156 ``arm_notes_body_cap``; this hook adds the clean JSON 413 for the 

157 common Content-Length case. 

158 """ 

159 if ( 

160 request.content_length is not None 

161 and request.content_length > _MAX_JSON_BODY_BYTES 

162 ): 

163 return jsonify( 

164 { 

165 "success": False, 

166 "error": ( 

167 "Request body too large " 

168 f"(max {_MAX_JSON_BODY_BYTES // (1024 * 1024)} MB)" 

169 ), 

170 } 

171 ), 413 

172 return None 

173 

174 

175@notes_bp.before_request 

176def _reject_non_object_json_body(): 

177 """400 a JSON body that parses to something other than an object. 

178 

179 Every mutating notes route reads the parsed body as a dict 

180 (``data.get(...)``). A truthy non-dict JSON value — ``true``, ``123``, 

181 ``"x"``, ``[1, 2]`` — slips past the ``... or {}`` / ``if not data`` 

182 idioms those routes use (which only catch *falsy* non-dicts) and then 

183 raises ``AttributeError``/``TypeError`` on the first ``.get``/subscript, 

184 surfacing as an uncaught 500. Reject it here, once, with a clean 400. 

185 

186 Scope is deliberately narrow: only a body the client DECLARED as JSON 

187 (``request.is_json``) that parses to a non-``None`` non-dict is rejected. 

188 A missing/empty body (parses to ``None``) is left for each route to 

189 default as it sees fit, and a malformed body (``get_json(silent=True)`` 

190 → ``None``) is likewise passed through unchanged. No notes route accepts 

191 a top-level JSON array or scalar, so this is safe blueprint-wide. Runs 

192 after ``_reject_oversized_bodies`` so an oversized body still 413s first. 

193 """ 

194 if request.method in ("POST", "PUT", "PATCH"): 194 ↛ 204line 194 didn't jump to line 204 because the condition on line 194 was always true

195 if request.is_json: 

196 data = request.get_json(silent=True) 

197 if data is not None and not isinstance(data, dict): 

198 return jsonify( 

199 { 

200 "success": False, 

201 "error": "Request body must be a JSON object", 

202 } 

203 ), 400 

204 return None 

205 

206 

207def _trigger_note_auto_index(note_id, service, username): 

208 """Trigger async auto-indexing for a note (non-blocking).""" 

209 try: 

210 from ...research_library.routes.rag_routes import trigger_auto_index 

211 from ...database.session_passwords import session_password_store 

212 

213 session_id = session.get("session_id") 

214 db_password = session_password_store.get_session_password( 

215 username, session_id 

216 ) 

217 if not db_password: 217 ↛ 223line 217 didn't jump to line 223 because the condition on line 217 was always true

218 logger.debug( 

219 "Skipping note auto-index for {}: no session password available", 

220 note_id, 

221 ) 

222 return 

223 collection_id = service.get_notes_collection_id() 

224 if not collection_id: 

225 logger.debug( 

226 "Skipping note auto-index for {}: no notes collection", 

227 note_id, 

228 ) 

229 return 

230 trigger_auto_index([note_id], collection_id, username, db_password) 

231 except Exception: 

232 logger.exception("Failed to trigger auto-indexing for note") 

233 

234 

235# ============================================================================ 

236# Page Routes 

237# ============================================================================ 

238 

239 

240@notes_bp.route("/") 

241@login_required 

242def notes_page(): 

243 """Render the notes list page.""" 

244 return render_template_with_defaults( 

245 "pages/notes.html", active_page="notes" 

246 ) 

247 

248 

249@notes_bp.route("/<string:note_id>") 

250@login_required 

251def note_detail_page(note_id): 

252 """Render the note detail page.""" 

253 return render_template_with_defaults( 

254 "pages/note_detail.html", 

255 active_page="notes", 

256 note_id=note_id, 

257 ) 

258 

259 

260# ============================================================================ 

261# API Routes - Notes CRUD 

262# ============================================================================ 

263 

264 

265@notes_bp.route("/api/notes", methods=["GET"]) 

266@login_required 

267@_notes_search_limit 

268def list_notes(): 

269 """ 

270 List notes with optional filtering. 

271 

272 Query params: 

273 collection_id: Filter by collection 

274 search: Search in title and content (capped at MAX_SEARCH_LEN) 

275 pinned_only: Only return pinned notes 

276 limit: Maximum number (default 100) 

277 offset: Offset for pagination (default 0) 

278 """ 

279 username = session.get("username") 

280 if not username: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true

281 return jsonify({"success": False, "error": "Not authenticated"}), 401 

282 

283 try: 

284 service = NoteService(username) 

285 

286 collection_id = request.args.get("collection_id") 

287 try: 

288 search = _clamp_text_query(request.args.get("search"), "search") 

289 except ValueError as exc: 

290 return jsonify({"success": False, "error": str(exc)}), 400 

291 pinned_only = request.args.get("pinned_only", "").lower() == "true" 

292 try: 

293 limit = _clamp_limit(100) 

294 offset = max(0, int(request.args.get("offset", 0))) 

295 except (ValueError, TypeError): 

296 return jsonify( 

297 {"success": False, "error": "Invalid limit or offset"} 

298 ), 400 

299 

300 # Total under the same filter — lets the frontend render a real 

301 # page-N-of-M control rather than guessing from len(notes) == 

302 # limit. count_notes repeats the filtered scan list_notes runs 

303 # (for a keyword search that is a second full ILIKE pass over 

304 # text_content), so skip it when a short first page already 

305 # reveals the total. 

306 if offset == 0: 

307 notes = service.list_notes( 

308 collection_id=collection_id, 

309 search=search, 

310 pinned_only=pinned_only, 

311 limit=limit, 

312 offset=0, 

313 ) 

314 total = ( 

315 len(notes) 

316 if len(notes) < limit 

317 else service.count_notes( 

318 collection_id=collection_id, 

319 search=search, 

320 pinned_only=pinned_only, 

321 ) 

322 ) 

323 else: 

324 total = service.count_notes( 

325 collection_id=collection_id, 

326 search=search, 

327 pinned_only=pinned_only, 

328 ) 

329 # Clamp an offset past the end of the result set: it returns 

330 # an empty page anyway, so don't make the DB scan-and-discard 

331 # `offset` rows for a guaranteed-empty result (mirrors 

332 # get_note_versions). 

333 if offset > total: 

334 offset = total 

335 

336 notes = service.list_notes( 

337 collection_id=collection_id, 

338 search=search, 

339 pinned_only=pinned_only, 

340 limit=limit, 

341 offset=offset, 

342 ) 

343 

344 return jsonify( 

345 { 

346 "success": True, 

347 "notes": notes, 

348 "total": total, 

349 "limit": limit, 

350 "offset": offset, 

351 } 

352 ) 

353 

354 except Exception as e: 

355 return handle_api_error("listing notes", e) 

356 

357 

358@notes_bp.route("/api/notes", methods=["POST"]) 

359@login_required 

360@_notes_write_limit 

361def create_note(): 

362 """ 

363 Create a new note. 

364 

365 Body: 

366 title: Note title (required) 

367 content: Note content (required) 

368 tags: List of tags (optional) 

369 collection_ids: List of collection IDs (optional) 

370 """ 

371 username = session.get("username") 

372 if not username: 

373 return jsonify({"success": False, "error": "Not authenticated"}), 401 

374 

375 try: 

376 data = request.get_json(silent=True) 

377 if not isinstance(data, dict) or not data: 

378 # get_json returns the parsed body verbatim, so a top-level JSON 

379 # array/string/number would pass a bare truthiness check and then 

380 # crash data.get() with AttributeError → opaque 500. Require an 

381 # object. 

382 return jsonify({"success": False, "error": "No data provided"}), 400 

383 

384 title = data.get("title") 

385 content = data.get("content") 

386 

387 if not title: 

388 return jsonify( 

389 {"success": False, "error": "Title is required"} 

390 ), 400 

391 if not content: 

392 return jsonify( 

393 {"success": False, "error": "Content is required"} 

394 ), 400 

395 

396 service = NoteService(username) 

397 note_id = service.create_note( 

398 title=title, 

399 content=content, 

400 tags=data.get("tags"), 

401 collection_ids=data.get("collection_ids"), 

402 ) 

403 

404 # Trigger async auto-indexing (non-blocking) 

405 _trigger_note_auto_index(note_id, service, username) 

406 

407 return jsonify({"success": True, "id": note_id}), 201 

408 

409 except ValueError as e: 

410 # Validation failures (content size, tag shape) — surface the 

411 # actual message so the client can fix and retry. 

412 return jsonify({"success": False, "error": str(e)}), 400 

413 except Exception as e: 

414 return handle_api_error("creating note", e) 

415 

416 

417@notes_bp.route("/api/notes/<string:note_id>", methods=["GET"]) 

418@login_required 

419def get_note(note_id): 

420 """Get a specific note.""" 

421 username = session.get("username") 

422 if not username: 

423 return jsonify({"success": False, "error": "Not authenticated"}), 401 

424 

425 try: 

426 service = NoteService(username) 

427 note = service.get_note(note_id) 

428 

429 if not note: 

430 return jsonify({"success": False, "error": "Note not found"}), 404 

431 

432 return jsonify({"success": True, "note": note}) 

433 

434 except Exception as e: 

435 return handle_api_error("getting note", e) 

436 

437 

438@notes_bp.route("/api/notes/<string:note_id>", methods=["PUT"]) 

439@login_required 

440@_notes_write_limit 

441def update_note(note_id): 

442 """ 

443 Update a note. 

444 

445 Body: 

446 title: New title (optional) 

447 content: New content (optional) 

448 tags: New tags (optional) 

449 pinned: New pinned status (optional) — wire-level alias for the 

450 ``Document.favorite`` column; the UI vocabulary is "pin". 

451 """ 

452 username = session.get("username") 

453 if not username: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 return jsonify({"success": False, "error": "Not authenticated"}), 401 

455 

456 try: 

457 data = request.get_json(silent=True) 

458 if not data: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true

459 return jsonify({"success": False, "error": "No data provided"}), 400 

460 

461 # Every sibling field gets a clean 400 via the service validators 

462 # (_validate_title/_assert_content_size/_validate_tags); pinned is 

463 # assigned straight into the Boolean column, where a non-bool 

464 # raises StatementError at flush → an opaque 500. 

465 pinned = data.get("pinned") 

466 if pinned is not None and not isinstance(pinned, bool): 

467 return jsonify( 

468 {"success": False, "error": "pinned must be a boolean"} 

469 ), 400 

470 

471 service = NoteService(username) 

472 content_provided = data.get("content") is not None 

473 try: 

474 success = service.update_note( 

475 note_id=note_id, 

476 title=data.get("title"), 

477 content=data.get("content"), 

478 tags=data.get("tags"), 

479 favorite=pinned, 

480 ) 

481 except ValueError as e: 

482 # Validation failures (content size, tag shape) — surface the 

483 # actual message so the client can fix and retry. 

484 return jsonify({"success": False, "error": str(e)}), 400 

485 

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

487 return jsonify({"success": False, "error": "Note not found"}), 404 

488 

489 # Trigger async auto-indexing if content OR title was updated 

490 # (non-blocking). The service marks DocumentCollection.indexed=False 

491 # for either change and relies on this post-commit trigger; the 

492 # title is baked into every chunk's metadata at index time, so a 

493 # rename leaves stale titles in search results until reindexed. A 

494 # title-only PUT used to flip indexed=False without ever scheduling 

495 # the reindex. Unchanged values are harmless: the worker runs with 

496 # force_reindex=False and skips rows still marked indexed=True. 

497 if content_provided or data.get("title") is not None: 

498 _trigger_note_auto_index(note_id, service, username) 

499 

500 return jsonify({"success": True}) 

501 

502 except Exception as e: 

503 return handle_api_error("updating note", e) 

504 

505 

506@notes_bp.route("/api/notes/<string:note_id>", methods=["DELETE"]) 

507@login_required 

508@_notes_write_limit 

509def delete_note(note_id): 

510 """Delete a note.""" 

511 username = session.get("username") 

512 if not username: 

513 return jsonify({"success": False, "error": "Not authenticated"}), 401 

514 

515 try: 

516 service = NoteService(username) 

517 success = service.delete_note(note_id) 

518 

519 if not success: 

520 return jsonify({"success": False, "error": "Note not found"}), 404 

521 

522 return jsonify({"success": True}) 

523 

524 except Exception as e: 

525 return handle_api_error("deleting note", e) 

526 

527 

528# ============================================================================ 

529# API Routes - Collections 

530# ============================================================================ 

531 

532 

533@notes_bp.route("/api/notes/<string:note_id>/collections", methods=["GET"]) 

534@login_required 

535def get_note_collections(note_id): 

536 """Get collections a note belongs to.""" 

537 username = session.get("username") 

538 if not username: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true

539 return jsonify({"success": False, "error": "Not authenticated"}), 401 

540 

541 try: 

542 service = NoteService(username) 

543 # 404 on unknown note id rather than returning an empty list. 

544 # Pre-fix, a typo in the note UUID returned 200 with [] — 

545 # indistinguishable from "this note has no collections" — making 

546 # client-side bugs invisible to QA. The sibling get_note route 

547 # already 404s for unknown ids; align with that contract. 

548 if not service.note_exists(note_id): 548 ↛ 550line 548 didn't jump to line 550 because the condition on line 548 was always true

549 return jsonify({"success": False, "error": "Note not found"}), 404 

550 collections = service.get_note_collections(note_id) 

551 

552 return jsonify({"success": True, "collections": collections}) 

553 

554 except Exception as e: 

555 return handle_api_error("getting note collections", e) 

556 

557 

558@notes_bp.route("/api/notes/<string:note_id>/collections", methods=["POST"]) 

559@login_required 

560@_notes_write_limit 

561def add_note_to_collection(note_id): 

562 """ 

563 Add a note to a collection. 

564 

565 Body: 

566 collection_id: The collection ID 

567 """ 

568 username = session.get("username") 

569 if not username: 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true

570 return jsonify({"success": False, "error": "Not authenticated"}), 401 

571 

572 try: 

573 data = request.get_json(silent=True) 

574 if not data or not data.get("collection_id"): 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true

575 return jsonify( 

576 {"success": False, "error": "collection_id is required"} 

577 ), 400 

578 

579 service = NoteService(username) 

580 # add_to_collection returns the same False for "note id missing / 

581 # not a note" and "already a member"; without this guard a stale 

582 # id (note deleted in another tab) produced a factually wrong 409 

583 # "Already in collection". 404 first, like get_note_collections. 

584 if not service.note_exists(note_id): 

585 return jsonify({"success": False, "error": "Note not found"}), 404 

586 success = service.add_to_collection(note_id, data["collection_id"]) 

587 

588 if not success: 588 ↛ 593line 588 didn't jump to line 593 because the condition on line 588 was always true

589 return jsonify( 

590 {"success": False, "error": "Already in collection"} 

591 ), 409 

592 

593 return jsonify({"success": True}) 

594 

595 except LookupError: 

596 return jsonify({"success": False, "error": "Collection not found"}), 404 

597 except Exception as e: 

598 return handle_api_error("adding note to collection", e) 

599 

600 

601@notes_bp.route( 

602 "/api/notes/<string:note_id>/collections/<string:collection_id>", 

603 methods=["DELETE"], 

604) 

605@login_required 

606@_notes_write_limit 

607def remove_note_from_collection(note_id, collection_id): 

608 """Remove a note from a collection.""" 

609 username = session.get("username") 

610 if not username: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true

611 return jsonify({"success": False, "error": "Not authenticated"}), 401 

612 

613 try: 

614 service = NoteService(username) 

615 # Same conflation as add_note_to_collection: a missing/non-note id 

616 # used to 404 with the misleading "Not in collection" message. 

617 if not service.note_exists(note_id): 617 ↛ 619line 617 didn't jump to line 619 because the condition on line 617 was always true

618 return jsonify({"success": False, "error": "Note not found"}), 404 

619 success = service.remove_from_collection(note_id, collection_id) 

620 

621 if not success: 

622 return jsonify( 

623 {"success": False, "error": "Not in collection"} 

624 ), 404 

625 

626 return jsonify({"success": True}) 

627 

628 except ValueError as e: 

629 # Guard failures (e.g. unlinking from the system Notes collection, 

630 # which is every note's persistent home) — surface the message. 

631 return jsonify({"success": False, "error": str(e)}), 400 

632 except Exception as e: 

633 return handle_api_error("removing note from collection", e) 

634 

635 

636# ============================================================================ 

637# API Routes - Research 

638# ============================================================================ 

639 

640 

641@notes_bp.route("/api/notes/<string:note_id>/research", methods=["GET"]) 

642@login_required 

643def get_note_research(note_id): 

644 """Get research runs triggered from a note.""" 

645 username = session.get("username") 

646 if not username: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 return jsonify({"success": False, "error": "Not authenticated"}), 401 

648 

649 try: 

650 service = NoteService(username) 

651 # 404 on unknown note id — see get_note_collections for the 

652 # rationale. The two routes had the same contract gap. 

653 if not service.note_exists(note_id): 653 ↛ 655line 653 didn't jump to line 655 because the condition on line 653 was always true

654 return jsonify({"success": False, "error": "Note not found"}), 404 

655 research = service.get_note_research(note_id) 

656 

657 return jsonify({"success": True, "research": research}) 

658 

659 except Exception as e: 

660 return handle_api_error("getting note research", e) 

661 

662 

663@notes_bp.route("/api/notes/<string:note_id>/research", methods=["POST"]) 

664@login_required 

665@_notes_write_limit 

666def link_research_to_note(note_id): 

667 """ 

668 Link a research run to a note. 

669 

670 Body: 

671 research_id: The research history ID 

672 """ 

673 username = session.get("username") 

674 if not username: 

675 return jsonify({"success": False, "error": "Not authenticated"}), 401 

676 

677 try: 

678 data = request.get_json(silent=True) 

679 if not data or not data.get("research_id"): 

680 return jsonify( 

681 {"success": False, "error": "research_id is required"} 

682 ), 400 

683 

684 service = NoteService(username) 

685 link_id = service.link_research_to_note( 

686 note_id=note_id, 

687 research_id=data["research_id"], 

688 ) 

689 

690 return jsonify({"success": True, "id": link_id}), 201 

691 

692 except ValueError as e: 

693 # A missing/non-note id surfaces as ValueError("... not found") — a 

694 # 404 like every sibling note route; the research-per-note cap is a 

695 # client-fixable 400. 

696 if "not found" in str(e).lower(): 

697 return jsonify({"success": False, "error": str(e)}), 404 

698 return jsonify({"success": False, "error": str(e)}), 400 

699 except IntegrityError as exc: 

700 # Only the (document_id, research_id) uniqueness collision means the 

701 # research is already linked → 409. A display_order retry-exhaustion 

702 # or an FK race (stale/deleted research_id) also surfaces as 

703 # IntegrityError but is NOT a duplicate — don't mislabel those as 

704 # "already linked"; let them fall through to a generic error. 

705 msg = str(getattr(exc, "orig", exc)).lower() 

706 if "foreign key" in msg: 

707 # research_id references a research run that doesn't exist (or 

708 # was deleted) — a client-fixable 404, not an opaque 500. 

709 return jsonify( 

710 {"success": False, "error": "Research run not found"} 

711 ), 404 

712 if "display_order" in msg: 

713 return handle_api_error("linking research to note", exc) 

714 return jsonify( 

715 {"success": False, "error": "Research already linked to this note"} 

716 ), 409 

717 except Exception as e: 

718 return handle_api_error("linking research to note", e) 

719 

720 

721@notes_bp.route( 

722 "/api/notes/<string:note_id>/research/<string:research_id>", 

723 methods=["PATCH"], 

724) 

725@login_required 

726@_notes_write_limit 

727def patch_note_research(note_id, research_id): 

728 """Update a NoteResearch row (currently supports is_collapsed toggle). 

729 

730 Body: 

731 is_collapsed: bool (optional) 

732 """ 

733 username = session.get("username") 

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

735 return jsonify({"success": False, "error": "Not authenticated"}), 401 

736 

737 try: 

738 data = request.get_json(silent=True) or {} 

739 service = NoteService(username) 

740 updated = service.update_note_research( 

741 note_id=note_id, 

742 research_id=research_id, 

743 is_collapsed=data.get("is_collapsed"), 

744 ) 

745 if not updated: 

746 return jsonify( 

747 {"success": False, "error": "Research link not found"} 

748 ), 404 

749 return jsonify({"success": True}) 

750 

751 except Exception as e: 

752 return handle_api_error("updating note research", e) 

753 

754 

755@notes_bp.route( 

756 "/api/notes/<string:note_id>/research/reorder", methods=["POST"] 

757) 

758@login_required 

759@_notes_write_limit 

760def reorder_note_research(note_id): 

761 """Atomically reorder research items for a note. 

762 

763 Body: 

764 research_ids: list of research_id strings in desired display order. 

765 """ 

766 username = session.get("username") 

767 if not username: 

768 return jsonify({"success": False, "error": "Not authenticated"}), 401 

769 

770 try: 

771 data = request.get_json(silent=True) or {} 

772 research_ids = data.get("research_ids") 

773 if not isinstance(research_ids, list) or not research_ids: 

774 return jsonify( 

775 { 

776 "success": False, 

777 "error": "research_ids must be a non-empty list", 

778 } 

779 ), 400 

780 if not all(isinstance(r, str) for r in research_ids): 

781 # Non-string elements (nested arrays/objects) are unhashable and 

782 # crash the dedup/reorder logic with TypeError → opaque 500. 

783 return jsonify( 

784 { 

785 "success": False, 

786 "error": "research_ids must be a list of strings", 

787 } 

788 ), 400 

789 

790 service = NoteService(username) 

791 # 404 a stale/deleted note id BEFORE the ids-match check, mirroring 

792 # every sibling note-scoped route (get_note_research, 

793 # add_note_to_collection, ...). Otherwise reorder_note_research finds 

794 # no NoteResearch rows for the missing note, returns False on the 

795 # ids-mismatch path, and the route misreports a 404 condition (note 

796 # deleted in another tab) as a 400 validation error. 

797 if not service.note_exists(note_id): 

798 return jsonify({"success": False, "error": "Note not found"}), 404 

799 ok = service.reorder_note_research(note_id, research_ids) 

800 if not ok: 800 ↛ 807line 800 didn't jump to line 807 because the condition on line 800 was always true

801 return jsonify( 

802 { 

803 "success": False, 

804 "error": "research_ids do not match the note's linked research", 

805 } 

806 ), 400 

807 return jsonify({"success": True}) 

808 

809 except ValueError as e: 

810 # Validation failures (research-per-note cap) — surface the 

811 # actual message so the client can fix and retry. 

812 return jsonify({"success": False, "error": str(e)}), 400 

813 except Exception as e: 

814 return handle_api_error("reordering note research", e) 

815 

816 

817# ============================================================================ 

818# API Routes - Research → Notes (results-page Notes panel) 

819# ============================================================================ 

820 

821 

822@notes_bp.route("/api/research/<string:research_id>/notes", methods=["GET"]) 

823@login_required 

824@_notes_search_limit 

825def get_research_notes(research_id): 

826 """List the notes linked to a research run. 

827 

828 The reverse of GET /api/notes/<id>/research — powers the Notes panel 

829 on the research results page, so commentary attached to a run is 

830 visible from the run itself, not only from the note. 

831 """ 

832 username = session.get("username") 

833 if not username: 833 ↛ 834line 833 didn't jump to line 834 because the condition on line 833 was never true

834 return jsonify({"success": False, "error": "Not authenticated"}), 401 

835 

836 try: 

837 service = NoteService(username) 

838 notes = service.get_notes_for_research(research_id) 

839 if notes is None: 

840 return jsonify( 

841 {"success": False, "error": "Research not found"} 

842 ), 404 

843 return jsonify({"success": True, "notes": notes}) 

844 

845 except Exception as e: 

846 return handle_api_error("listing notes for research", e) 

847 

848 

849@notes_bp.route("/api/research/<string:research_id>/notes", methods=["POST"]) 

850@login_required 

851@_notes_write_limit 

852def create_research_note(research_id): 

853 """Create a note pre-linked to a research run (all-or-nothing). 

854 

855 Body (all optional): ``title``, ``content``, ``tags``. Defaults 

856 produce a starter note titled after the research query. The results 

857 page's "Add note" (no body) and "Clip to note" (content = the quoted 

858 selection) both come through here. 

859 """ 

860 username = session.get("username") 

861 if not username: 861 ↛ 862line 861 didn't jump to line 862 because the condition on line 861 was never true

862 return jsonify({"success": False, "error": "Not authenticated"}), 401 

863 

864 try: 

865 data = request.get_json(silent=True) or {} 

866 service = NoteService(username) 

867 try: 

868 note_id = service.create_note_for_research( 

869 research_id, 

870 title=data.get("title"), 

871 content=data.get("content"), 

872 tags=data.get("tags"), 

873 ) 

874 except LookupError: 

875 return jsonify( 

876 {"success": False, "error": "Research not found"} 

877 ), 404 

878 # Reach semantic search / "Ask your notes" like a normally-created 

879 # note (the plain POST /api/notes route does the same at its tail). 

880 _trigger_note_auto_index(note_id, service, username) 

881 return jsonify({"success": True, "note_id": note_id}), 201 

882 

883 except ValueError as e: 

884 # Validation failures (title/content caps, tag shape) — surface 

885 # the actual message so the client can fix and retry. 

886 return jsonify({"success": False, "error": str(e)}), 400 

887 except Exception as e: 

888 return handle_api_error("creating note for research", e) 

889 

890 

891@notes_bp.route( 

892 "/api/research/<string:research_id>/save-as-note", methods=["POST"] 

893) 

894@login_required 

895@_notes_save_as_note_limit 

896def save_research_as_note(research_id): 

897 """Copy a COMPLETED research run's report into a new, linked note. 

898 

899 Deliberately a derivation, not a conversion: the report stays the 

900 immutable record (fact-check grades claims against it and its 

901 provenance must survive), while the note is the user's editable 

902 copy. Provenance is kept twice — the NoteResearch link and a header 

903 line inside the note content — so the copy stays traceable even if 

904 it is later unlinked. 

905 """ 

906 username = session.get("username") 

907 if not username: 907 ↛ 908line 907 didn't jump to line 908 because the condition on line 907 was never true

908 return jsonify({"success": False, "error": "Not authenticated"}), 401 

909 

910 try: 

911 from ...constants import ResearchStatus 

912 from ...database.models import ResearchHistory 

913 from ...database.session_context import get_user_db_session 

914 from ...storage import get_report_storage 

915 

916 with get_user_db_session(username) as db: 

917 research = ( 

918 db.query(ResearchHistory).filter_by(id=research_id).first() 

919 ) 

920 if research is None: 

921 return jsonify( 

922 {"success": False, "error": "Research not found"} 

923 ), 404 

924 if research.status != ResearchStatus.COMPLETED: 

925 return jsonify( 

926 { 

927 "success": False, 

928 "error": "Research is not complete yet.", 

929 "status": research.status, 

930 } 

931 ), 409 

932 query_text = (research.query or "").strip() 

933 meta = dict(research.research_meta or {}) 

934 

935 # Fetch the report markdown in-request (same pattern as the 

936 # fact-check grade flow: the per-user DB password is available 

937 # here, unlike in a background worker). 

938 settings_snapshot = meta.get("settings_snapshot") 

939 with get_user_db_session(username) as db: 

940 storage = get_report_storage( 

941 session=db, settings_snapshot=settings_snapshot 

942 ) 

943 report = storage.get_report(research_id, username) 

944 

945 if not report or not str(report).strip(): 

946 # get_report returns None for BOTH a missing report and a 

947 # swallowed read error; saving an empty note would look like 

948 # success while losing the content. Refuse instead. 

949 return jsonify( 

950 { 

951 "success": False, 

952 "error": ( 

953 "The research report is unavailable, so it cannot " 

954 "be saved as a note. Try re-running the research." 

955 ), 

956 } 

957 ), 502 

958 

959 from ...research_library.notes.services.note_service import ( 

960 MAX_TITLE_LENGTH, 

961 ) 

962 

963 title = ( 

964 f"Research: {query_text}"[:MAX_TITLE_LENGTH] 

965 if query_text 

966 else f"Research {research_id[:8]}" 

967 ) 

968 provenance = ( 

969 f"> Saved from the research run " 

970 f"[{escape_markdown_link_label(query_text or research_id)}]" 

971 f"(/results/{research_id}).\n\n" 

972 ) 

973 

974 service = NoteService(username) 

975 try: 

976 note_id = service.create_note_for_research( 

977 research_id, title=title, content=provenance + str(report) 

978 ) 

979 except LookupError: 

980 # Deleted between the checks above and the create — treat like 

981 # any other missing research. 

982 return jsonify( 

983 {"success": False, "error": "Research not found"} 

984 ), 404 

985 _trigger_note_auto_index(note_id, service, username) 

986 return jsonify({"success": True, "note_id": note_id}), 201 

987 

988 except ValueError as e: 

989 # Validation failures — most likely the report exceeding the note 

990 # content cap. Client-fixable only in the sense that the user 

991 # should know why it refused; surface the message. 

992 return jsonify({"success": False, "error": str(e)}), 400 

993 except Exception as e: 

994 return handle_api_error("saving research as note", e) 

995 

996 

997# ============================================================================ 

998# API Routes - Inline annotations (Word-review-style comments) and 

999# document-linked notes. Targets: research runs and library documents — 

1000# both have IMMUTABLE rendered text, so quote anchors can never drift. 

1001# The comment itself is a full NOTE; the anchor lives in note_references. 

1002# ============================================================================ 

1003 

1004MAX_ANNOTATION_COMMENT_LEN = 5000 

1005MAX_ANNOTATION_QUOTE_LEN = 1000 

1006MAX_ANNOTATION_CONTEXT_LEN = 100 

1007 

1008 

1009def _validated_annotation_fields(data): 

1010 """Validate/trim an annotation payload; raises ValueError. 

1011 

1012 Type-checks every field before touching it: a non-string comment 

1013 (e.g. ``{"comment": 123}`` from a direct API caller) would otherwise 

1014 crash on ``.strip()`` as an opaque 500 instead of the clean 400 the 

1015 length checks produce. 

1016 """ 

1017 for field in ("comment", "quote", "prefix", "suffix"): 

1018 value = data.get(field) 

1019 if value is not None and not isinstance(value, str): 

1020 raise ValueError(f"{field} must be a string") 

1021 comment = (data.get("comment") or "").strip() 

1022 quote = (data.get("quote") or "").strip() 

1023 if not comment: 

1024 raise ValueError("comment is required") 

1025 if not quote: 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true

1026 raise ValueError("quote is required") 

1027 if len(comment) > MAX_ANNOTATION_COMMENT_LEN: 1027 ↛ 1028line 1027 didn't jump to line 1028 because the condition on line 1027 was never true

1028 raise ValueError( 

1029 f"comment exceeds maximum length " 

1030 f"({MAX_ANNOTATION_COMMENT_LEN} chars)" 

1031 ) 

1032 if len(quote) > MAX_ANNOTATION_QUOTE_LEN: 1032 ↛ 1033line 1032 didn't jump to line 1033 because the condition on line 1032 was never true

1033 raise ValueError( 

1034 f"quote exceeds maximum length ({MAX_ANNOTATION_QUOTE_LEN} chars)" 

1035 ) 

1036 prefix = (data.get("prefix") or "")[-MAX_ANNOTATION_CONTEXT_LEN:] 

1037 suffix = (data.get("suffix") or "")[:MAX_ANNOTATION_CONTEXT_LEN] 

1038 return comment, quote, prefix, suffix 

1039 

1040 

1041def _comment_note_title(comment): 

1042 """Derive the comment-note's title from its first words.""" 

1043 one_line = " ".join(comment.split()) 

1044 if len(one_line) > 60: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true

1045 one_line = one_line[:60].rstrip() + "…" 

1046 return f"Comment: {one_line}" 

1047 

1048 

1049# Length of the comment preview echoed back in an annotation response (was 

1050# a bare 300 duplicated across both create-annotation handlers). 

1051_ANNOTATION_COMMENT_PREVIEW_CHARS = 300 

1052 

1053 

1054def _annotation_response(note_id, comment, quote, prefix, suffix): 

1055 """The annotation-created response payload shared by the research and 

1056 document create-annotation handlers (previously byte-identical dicts).""" 

1057 return { 

1058 "note_id": note_id, 

1059 "quote": quote, 

1060 "prefix": prefix, 

1061 "suffix": suffix, 

1062 "note_title": _comment_note_title(comment), 

1063 "comment_preview": comment[:_ANNOTATION_COMMENT_PREVIEW_CHARS], 

1064 } 

1065 

1066 

1067def _comment_note_content(comment, quote, target_label, target_url): 

1068 """Comment-first note body: remark, quoted passage, provenance. 

1069 

1070 ``target_label`` (a research query or a library document title, both 

1071 potentially untrusted) is escaped so it can't break out of the 

1072 provenance link — see ``escape_markdown_link_label``. 

1073 """ 

1074 quote_block = "\n".join(f"> {line}" for line in quote.split("\n")) 

1075 label = escape_markdown_link_label(target_label) 

1076 return ( 

1077 f"{comment}\n\n{quote_block}\n\n— comment on [{label}]({target_url})\n" 

1078 ) 

1079 

1080 

1081def _research_exists(username, research_id): 

1082 """(exists, query_text) for a research id in the user's DB.""" 

1083 from ...database.models import ResearchHistory 

1084 from ...database.session_context import get_user_db_session 

1085 

1086 with get_user_db_session(username) as db: 

1087 row = ( 

1088 db.query(ResearchHistory.id, ResearchHistory.query) 

1089 .filter_by(id=research_id) 

1090 .first() 

1091 ) 

1092 if row is None: 

1093 return False, None 

1094 return True, (row.query or "").strip() 

1095 

1096 

1097@notes_bp.route( 

1098 "/api/research/<string:research_id>/annotations", methods=["GET"] 

1099) 

1100@login_required 

1101@_notes_search_limit 

1102def get_research_annotations(research_id): 

1103 """List a research run's inline annotations (anchored comment notes).""" 

1104 username = session.get("username") 

1105 if not username: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true

1106 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1107 

1108 try: 

1109 exists, _query = _research_exists(username, research_id) 

1110 if not exists: 

1111 return jsonify( 

1112 {"success": False, "error": "Research not found"} 

1113 ), 404 

1114 service = NoteService(username) 

1115 annotations = service.get_annotations_for_target( 

1116 research_id=research_id 

1117 ) 

1118 return jsonify({"success": True, "annotations": annotations}) 

1119 

1120 except Exception as e: 

1121 return handle_api_error("listing research annotations", e) 

1122 

1123 

1124@notes_bp.route( 

1125 "/api/research/<string:research_id>/annotations", methods=["POST"] 

1126) 

1127@login_required 

1128@_notes_write_limit 

1129def create_research_annotation(research_id): 

1130 """Attach a comment to a passage of the research report. 

1131 

1132 Body: ``quote`` (the selected rendered text), optional ``prefix`` / 

1133 ``suffix`` (Hypothesis-style disambiguation context), ``comment``. 

1134 The comment becomes a NOTE linked to the run; the anchor is a 

1135 note_references row. 

1136 """ 

1137 username = session.get("username") 

1138 if not username: 1138 ↛ 1139line 1138 didn't jump to line 1139 because the condition on line 1138 was never true

1139 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1140 

1141 try: 

1142 data = request.get_json(silent=True) or {} 

1143 comment, quote, prefix, suffix = _validated_annotation_fields(data) 

1144 

1145 exists, query_text = _research_exists(username, research_id) 

1146 if not exists: 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true

1147 return jsonify( 

1148 {"success": False, "error": "Research not found"} 

1149 ), 404 

1150 

1151 service = NoteService(username) 

1152 note_id = service.create_note_for_research( 

1153 research_id, 

1154 title=_comment_note_title(comment), 

1155 content=_comment_note_content( 

1156 comment, 

1157 quote, 

1158 query_text or research_id, 

1159 f"/results/{research_id}", 

1160 ), 

1161 quote=quote, 

1162 prefix=prefix, 

1163 suffix=suffix, 

1164 ) 

1165 _trigger_note_auto_index(note_id, service, username) 

1166 return jsonify( 

1167 { 

1168 "success": True, 

1169 "annotation": _annotation_response( 

1170 note_id, comment, quote, prefix, suffix 

1171 ), 

1172 } 

1173 ), 201 

1174 

1175 except LookupError: 

1176 return jsonify({"success": False, "error": "Research not found"}), 404 

1177 except ValueError as e: 

1178 # Validation failures (missing/oversized fields) — surface the 

1179 # actual message so the client can fix and retry. 

1180 return jsonify({"success": False, "error": str(e)}), 400 

1181 except Exception as e: 

1182 return handle_api_error("creating research annotation", e) 

1183 

1184 

1185@notes_bp.route( 

1186 "/api/research/<string:research_id>/annotations/<string:note_id>", 

1187 methods=["DELETE"], 

1188) 

1189@login_required 

1190@_notes_write_limit 

1191def delete_research_annotation(research_id, note_id): 

1192 """Remove an annotation: the comment note is deleted (standard delete 

1193 path — version cascade, vector purge) and its anchor row cascades.""" 

1194 username = session.get("username") 

1195 if not username: 1195 ↛ 1196line 1195 didn't jump to line 1196 because the condition on line 1195 was never true

1196 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1197 

1198 try: 

1199 service = NoteService(username) 

1200 if not service.has_annotation(note_id, research_id=research_id): 

1201 return jsonify( 

1202 {"success": False, "error": "Annotation not found"} 

1203 ), 404 

1204 service.delete_note(note_id) 

1205 return jsonify({"success": True}) 

1206 

1207 except Exception as e: 

1208 return handle_api_error("deleting research annotation", e) 

1209 

1210 

1211# ---- Library documents: same surface, document targets -------------------- 

1212 

1213 

1214@notes_bp.route("/api/documents/<string:document_id>/notes", methods=["GET"]) 

1215@login_required 

1216@_notes_search_limit 

1217def get_document_notes(document_id): 

1218 """List the notes referencing a library document (its Notes panel).""" 

1219 username = session.get("username") 

1220 if not username: 1220 ↛ 1221line 1220 didn't jump to line 1221 because the condition on line 1220 was never true

1221 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1222 

1223 try: 

1224 service = NoteService(username) 

1225 notes = service.get_notes_for_document(document_id) 

1226 if notes is None: 1226 ↛ 1230line 1226 didn't jump to line 1230 because the condition on line 1226 was always true

1227 return jsonify( 

1228 {"success": False, "error": "Document not found"} 

1229 ), 404 

1230 return jsonify({"success": True, "notes": notes}) 

1231 

1232 except Exception as e: 

1233 return handle_api_error("listing notes for document", e) 

1234 

1235 

1236@notes_bp.route("/api/documents/<string:document_id>/notes", methods=["POST"]) 

1237@login_required 

1238@_notes_write_limit 

1239def create_document_note(document_id): 

1240 """Create a note referencing a library document (all-or-nothing). 

1241 

1242 Body (all optional): ``title``, ``content``, ``tags``. Defaults 

1243 produce a starter note titled after the document. 

1244 """ 

1245 username = session.get("username") 

1246 if not username: 1246 ↛ 1247line 1246 didn't jump to line 1247 because the condition on line 1246 was never true

1247 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1248 

1249 try: 

1250 data = request.get_json(silent=True) or {} 

1251 service = NoteService(username) 

1252 try: 

1253 note_id = service.create_note_for_document( 

1254 document_id, 

1255 title=data.get("title"), 

1256 content=data.get("content"), 

1257 tags=data.get("tags"), 

1258 ) 

1259 except LookupError: 

1260 return jsonify( 

1261 {"success": False, "error": "Document not found"} 

1262 ), 404 

1263 _trigger_note_auto_index(note_id, service, username) 

1264 return jsonify({"success": True, "note_id": note_id}), 201 

1265 

1266 except ValueError as e: 

1267 # Validation failures (caps, or trying to annotate a note — 

1268 # mutable content, anchors would drift) — surface the message. 

1269 return jsonify({"success": False, "error": str(e)}), 400 

1270 except Exception as e: 

1271 return handle_api_error("creating note for document", e) 

1272 

1273 

1274@notes_bp.route( 

1275 "/api/documents/<string:document_id>/annotations", methods=["GET"] 

1276) 

1277@login_required 

1278@_notes_search_limit 

1279def get_document_annotations(document_id): 

1280 """List a library document's inline annotations.""" 

1281 username = session.get("username") 

1282 if not username: 

1283 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1284 

1285 try: 

1286 service = NoteService(username) 

1287 # Existence gate doubles as the 404 (get_notes_for_document 

1288 # checks the Document row). 

1289 if service.get_notes_for_document(document_id) is None: 

1290 return jsonify( 

1291 {"success": False, "error": "Document not found"} 

1292 ), 404 

1293 annotations = service.get_annotations_for_target( 

1294 document_id=document_id 

1295 ) 

1296 return jsonify({"success": True, "annotations": annotations}) 

1297 

1298 except Exception as e: 

1299 return handle_api_error("listing document annotations", e) 

1300 

1301 

1302@notes_bp.route( 

1303 "/api/documents/<string:document_id>/annotations", methods=["POST"] 

1304) 

1305@login_required 

1306@_notes_write_limit 

1307def create_document_annotation(document_id): 

1308 """Attach a comment to a passage of a library document's text.""" 

1309 username = session.get("username") 

1310 if not username: 1310 ↛ 1311line 1310 didn't jump to line 1311 because the condition on line 1310 was never true

1311 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1312 

1313 try: 

1314 data = request.get_json(silent=True) or {} 

1315 comment, quote, prefix, suffix = _validated_annotation_fields(data) 

1316 

1317 from ...database.models import Document 

1318 from ...database.session_context import get_user_db_session 

1319 

1320 with get_user_db_session(username) as db: 

1321 row = ( 

1322 db.query(Document.id, Document.title) 

1323 .filter_by(id=document_id) 

1324 .first() 

1325 ) 

1326 if row is None: 1326 ↛ 1327line 1326 didn't jump to line 1327 because the condition on line 1326 was never true

1327 return jsonify( 

1328 {"success": False, "error": "Document not found"} 

1329 ), 404 

1330 doc_title = (row.title or "").strip() or document_id 

1331 

1332 service = NoteService(username) 

1333 note_id = service.create_note_for_document( 

1334 document_id, 

1335 title=_comment_note_title(comment), 

1336 content=_comment_note_content( 

1337 comment, 

1338 quote, 

1339 doc_title, 

1340 f"/library/document/{document_id}", 

1341 ), 

1342 quote=quote, 

1343 prefix=prefix, 

1344 suffix=suffix, 

1345 ) 

1346 _trigger_note_auto_index(note_id, service, username) 

1347 return jsonify( 

1348 { 

1349 "success": True, 

1350 "annotation": _annotation_response( 

1351 note_id, comment, quote, prefix, suffix 

1352 ), 

1353 } 

1354 ), 201 

1355 

1356 except LookupError: 

1357 return jsonify({"success": False, "error": "Document not found"}), 404 

1358 except ValueError as e: 

1359 return jsonify({"success": False, "error": str(e)}), 400 

1360 except Exception as e: 

1361 return handle_api_error("creating document annotation", e) 

1362 

1363 

1364@notes_bp.route( 

1365 "/api/documents/<string:document_id>/annotations/<string:note_id>", 

1366 methods=["DELETE"], 

1367) 

1368@login_required 

1369@_notes_write_limit 

1370def delete_document_annotation(document_id, note_id): 

1371 """Remove a document annotation (deletes the comment note; the 

1372 anchor row cascades).""" 

1373 username = session.get("username") 

1374 if not username: 1374 ↛ 1375line 1374 didn't jump to line 1375 because the condition on line 1374 was never true

1375 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1376 

1377 try: 

1378 service = NoteService(username) 

1379 if not service.has_annotation(note_id, document_id=document_id): 1379 ↛ 1380line 1379 didn't jump to line 1380 because the condition on line 1379 was never true

1380 return jsonify( 

1381 {"success": False, "error": "Annotation not found"} 

1382 ), 404 

1383 service.delete_note(note_id) 

1384 return jsonify({"success": True}) 

1385 

1386 except Exception as e: 

1387 return handle_api_error("deleting document annotation", e) 

1388 

1389 

1390# ============================================================================ 

1391# API Routes - RAG Indexing 

1392# ============================================================================ 

1393 

1394 

1395@notes_bp.route("/api/notes/<string:note_id>/index", methods=["POST"]) 

1396@login_required 

1397@_notes_write_limit 

1398def index_note_to_collection(note_id): 

1399 """ 

1400 Index a note into a collection for RAG search. 

1401 

1402 Body: 

1403 collection_id: The collection to index the note into (required) 

1404 force_reindex: Whether to force reindexing (optional, default false) 

1405 """ 

1406 from ...research_library.routes.rag_routes import get_rag_service 

1407 

1408 username = session.get("username") 

1409 if not username: 1409 ↛ 1410line 1409 didn't jump to line 1410 because the condition on line 1409 was never true

1410 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1411 

1412 try: 

1413 data = request.get_json(silent=True) 

1414 if not data or not data.get("collection_id"): 

1415 return jsonify( 

1416 {"success": False, "error": "collection_id is required"} 

1417 ), 400 

1418 

1419 collection_id = data["collection_id"] 

1420 force_reindex = data.get("force_reindex", False) 

1421 

1422 # Verify the id is actually a note before indexing — every sibling 

1423 # note route guards this, but this one passed the id straight to the 

1424 # RAG service, which would index an arbitrary user document. 

1425 if not NoteService(username).note_exists(note_id): 

1426 return jsonify({"success": False, "error": "Note not found"}), 404 

1427 

1428 # Get RAG service configured for the collection 

1429 rag_service = get_rag_service(collection_id) 

1430 

1431 # Index the note (notes are now documents) 

1432 result = rag_service.index_document( 

1433 document_id=note_id, 

1434 collection_id=collection_id, 

1435 force_reindex=force_reindex, 

1436 ) 

1437 

1438 if result["status"] == "error": 

1439 return jsonify( 

1440 {"success": False, "error": result.get("error")} 

1441 ), 400 

1442 

1443 return jsonify({"success": True, "result": result}) 

1444 

1445 except Exception as e: 

1446 return handle_api_error("indexing note", e) 

1447 

1448 

1449# ============================================================================ 

1450# API Routes - AI Features 

1451# ============================================================================ 

1452 

1453 

1454@notes_bp.route("/api/notes/semantic-search", methods=["GET"]) 

1455@login_required 

1456@_notes_search_limit 

1457def semantic_search_notes(): 

1458 """ 

1459 Search notes using semantic similarity. 

1460 

1461 Finds notes by meaning rather than keyword matching. 

1462 

1463 Query params: 

1464 q: Search query (required) 

1465 limit: Maximum results (default 10) 

1466 min_similarity: Minimum similarity threshold 0.0-1.0 (default 0.3) 

1467 collection_id: Optional — restrict results to this collection's notes 

1468 (matches the text-search filter). 

1469 """ 

1470 username = session.get("username") 

1471 if not username: 1471 ↛ 1472line 1471 didn't jump to line 1472 because the condition on line 1471 was never true

1472 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1473 

1474 try: 

1475 try: 

1476 query = _clamp_text_query(request.args.get("q", ""), "q") 

1477 except ValueError as exc: 

1478 return jsonify({"success": False, "error": str(exc)}), 400 

1479 query = (query or "").strip() 

1480 if not query: 1480 ↛ 1481line 1480 didn't jump to line 1481 because the condition on line 1480 was never true

1481 return jsonify( 

1482 {"success": False, "error": "Query is required"} 

1483 ), 400 

1484 

1485 try: 

1486 limit = _clamp_limit(10) 

1487 min_similarity = max( 

1488 0.0, min(float(request.args.get("min_similarity", 0.3)), 1.0) 

1489 ) 

1490 except (ValueError, TypeError): 

1491 return jsonify( 

1492 {"success": False, "error": "Invalid limit or min_similarity"} 

1493 ), 400 

1494 

1495 collection_id = request.args.get("collection_id") or None 

1496 

1497 service = NoteAIService(username) 

1498 results = service.semantic_search( 

1499 query, 

1500 limit=limit, 

1501 min_similarity=min_similarity, 

1502 collection_id=collection_id, 

1503 ) 

1504 

1505 return jsonify( 

1506 { 

1507 "success": True, 

1508 "query": query, 

1509 "results": results, 

1510 "count": len(results), 

1511 } 

1512 ) 

1513 

1514 except Exception as e: 

1515 return handle_api_error("semantic search", e) 

1516 

1517 

1518@notes_bp.route("/api/notes/<string:note_id>/similar", methods=["GET"]) 

1519@login_required 

1520@_notes_search_limit 

1521def get_similar_notes(note_id): 

1522 """ 

1523 Find notes similar to the given note using embeddings. 

1524 

1525 Query params: 

1526 limit: Maximum number of results (default 5) 

1527 """ 

1528 username = session.get("username") 

1529 if not username: 1529 ↛ 1530line 1529 didn't jump to line 1530 because the condition on line 1529 was never true

1530 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1531 

1532 try: 

1533 try: 

1534 limit = _clamp_limit(5) 

1535 except (ValueError, TypeError): 

1536 return jsonify({"success": False, "error": "Invalid limit"}), 400 

1537 # 404 on unknown note id — the AI service returns [] for both a 

1538 # missing note and a note with no similar matches, which made a 

1539 # stale id indistinguishable from a real-empty result. Same guard 

1540 # as get_note_collections / get_note_research. 

1541 if not NoteService(username).note_exists(note_id): 

1542 return jsonify({"success": False, "error": "Note not found"}), 404 

1543 service = NoteAIService(username) 

1544 similar = service.find_similar_notes(note_id, limit=limit) 

1545 

1546 return jsonify({"success": True, "similar_notes": similar}) 

1547 

1548 except Exception as e: 

1549 return handle_api_error("finding similar notes", e) 

1550 

1551 

1552@notes_bp.route("/api/notes/<string:note_id>/summarize", methods=["POST"]) 

1553@login_required 

1554@_notes_ai_limit 

1555def summarize_note(note_id): 

1556 """Generate an AI summary of the note.""" 

1557 username = session.get("username") 

1558 if not username: 

1559 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1560 

1561 try: 

1562 service = NoteAIService(username) 

1563 summary = service.summarize_note(note_id) 

1564 

1565 # summarize_note returns None only for a missing/empty note (real LLM 

1566 # failures raise → handled below as 500). That's a client/data 

1567 # condition, so 404 — consistent with the sibling AI endpoints. 

1568 if summary is None: 

1569 return jsonify( 

1570 {"success": False, "error": "Note not found or empty"} 

1571 ), 404 

1572 

1573 return jsonify({"success": True, "summary": summary}) 

1574 

1575 except Exception as e: 

1576 return handle_api_error("summarizing note", e) 

1577 

1578 

1579@notes_bp.route( 

1580 "/api/notes/<string:note_id>/research-questions", methods=["POST"] 

1581) 

1582@login_required 

1583@_notes_ai_limit 

1584def extract_research_questions(note_id): 

1585 """Extract potential research questions from the note.""" 

1586 username = session.get("username") 

1587 if not username: 1587 ↛ 1588line 1587 didn't jump to line 1588 because the condition on line 1587 was never true

1588 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1589 

1590 try: 

1591 # 404 on unknown note id — see get_similar_notes for the rationale. 

1592 if not NoteService(username).note_exists(note_id): 1592 ↛ 1594line 1592 didn't jump to line 1594 because the condition on line 1592 was always true

1593 return jsonify({"success": False, "error": "Note not found"}), 404 

1594 service = NoteAIService(username) 

1595 questions = service.extract_research_questions(note_id) 

1596 

1597 return jsonify({"success": True, "questions": questions}) 

1598 

1599 except Exception as e: 

1600 return handle_api_error("extracting research questions", e) 

1601 

1602 

1603@notes_bp.route("/api/notes/suggest-tags", methods=["POST"]) 

1604@login_required 

1605@_notes_ai_limit 

1606def suggest_tags(): 

1607 """ 

1608 Suggest tags for note content. 

1609 

1610 Body: 

1611 content: The note content to analyze 

1612 existing_tags: Current tags to avoid duplicates (optional) 

1613 """ 

1614 username = session.get("username") 

1615 if not username: 1615 ↛ 1616line 1615 didn't jump to line 1616 because the condition on line 1615 was never true

1616 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1617 

1618 try: 

1619 data = request.get_json(silent=True) 

1620 if not data or not data.get("content"): 1620 ↛ 1621line 1620 didn't jump to line 1621 because the condition on line 1620 was never true

1621 return jsonify( 

1622 {"success": False, "error": "content is required"} 

1623 ), 400 

1624 

1625 # Cap body size — same MAX_CONTENT_SIZE used by create_note / 

1626 # update_note. Without this, the route accepts unbounded text and 

1627 # holds it all in memory while the LLM call runs. 

1628 _assert_content_size(data["content"]) 

1629 

1630 # existing_tags is interpolated/joined downstream; a non-list (or a 

1631 # list with non-string items) would raise deep in the service and 

1632 # surface as an opaque 500. Validate to a clean 400 here. 

1633 existing_tags = data.get("existing_tags") 

1634 if existing_tags is not None and ( 

1635 not isinstance(existing_tags, list) 

1636 or not all(isinstance(t, str) for t in existing_tags) 

1637 ): 

1638 return jsonify( 

1639 { 

1640 "success": False, 

1641 "error": "existing_tags must be a list of strings", 

1642 } 

1643 ), 400 

1644 # Cap count + per-item length like _validate_tags does on the 

1645 # create/update paths. suggest_tags joins the WHOLE list before 

1646 # truncating the joined result, so without a cap a client could send a 

1647 # multi-MB existing_tags array (within the body ceiling, at 10/min) and 

1648 # force a large in-memory join on every call. 

1649 if existing_tags is not None and ( 

1650 len(existing_tags) > MAX_TAGS_PER_NOTE 

1651 or any(len(t) > MAX_TAG_LENGTH for t in existing_tags) 

1652 ): 

1653 return jsonify( 

1654 { 

1655 "success": False, 

1656 "error": ( 

1657 f"existing_tags exceeds limits (max " 

1658 f"{MAX_TAGS_PER_NOTE} tags, {MAX_TAG_LENGTH} chars each)" 

1659 ), 

1660 } 

1661 ), 400 

1662 

1663 service = NoteAIService(username) 

1664 tags = service.suggest_tags( 

1665 content=data["content"], 

1666 existing_tags=existing_tags, 

1667 ) 

1668 

1669 return jsonify({"success": True, "tags": tags}) 

1670 

1671 except ValueError as e: 

1672 return jsonify({"success": False, "error": str(e)}), 400 

1673 except Exception as e: 

1674 return handle_api_error("suggesting tags", e) 

1675 

1676 

1677@notes_bp.route("/api/notes/<string:note_id>/key-concepts", methods=["POST"]) 

1678@login_required 

1679@_notes_ai_limit 

1680def extract_key_concepts(note_id): 

1681 """Extract key concepts, entities, and themes from the note.""" 

1682 username = session.get("username") 

1683 if not username: 1683 ↛ 1684line 1683 didn't jump to line 1684 because the condition on line 1683 was never true

1684 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1685 

1686 try: 

1687 # 404 on unknown note id — see get_similar_notes for the rationale. 

1688 if not NoteService(username).note_exists(note_id): 1688 ↛ 1690line 1688 didn't jump to line 1690 because the condition on line 1688 was always true

1689 return jsonify({"success": False, "error": "Note not found"}), 404 

1690 service = NoteAIService(username) 

1691 concepts = service.extract_key_concepts(note_id) 

1692 

1693 return jsonify({"success": True, "concepts": concepts}) 

1694 

1695 except Exception as e: 

1696 return handle_api_error("extracting key concepts", e) 

1697 

1698 

1699# ============================================================================ 

1700# API Routes - Smart Note Linking 

1701# ============================================================================ 

1702 

1703 

1704@notes_bp.route("/api/notes/<string:note_id>/backlinks", methods=["GET"]) 

1705@login_required 

1706@_notes_search_limit 

1707def get_backlinks(note_id): 

1708 """Get notes that link to this note (backlinks).""" 

1709 username = session.get("username") 

1710 if not username: 1710 ↛ 1711line 1710 didn't jump to line 1711 because the condition on line 1710 was never true

1711 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1712 

1713 try: 

1714 service = NoteService(username) 

1715 # 404 an unknown id like the other note routes, so an empty list 

1716 # for a real note is distinguishable from "no such note". 

1717 if not service.note_exists(note_id): 

1718 return jsonify({"success": False, "error": "Note not found"}), 404 

1719 backlinks = service.get_backlinks(note_id) 

1720 

1721 return jsonify( 

1722 { 

1723 "success": True, 

1724 "backlinks": backlinks, 

1725 "total": len(backlinks), 

1726 } 

1727 ) 

1728 

1729 except Exception as e: 

1730 return handle_api_error("getting backlinks", e) 

1731 

1732 

1733@notes_bp.route("/api/notes/<string:note_id>/outgoing-links", methods=["GET"]) 

1734@login_required 

1735@_notes_search_limit 

1736def get_outgoing_links(note_id): 

1737 """Get notes that this note links to.""" 

1738 username = session.get("username") 

1739 if not username: 1739 ↛ 1740line 1739 didn't jump to line 1740 because the condition on line 1739 was never true

1740 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1741 

1742 try: 

1743 service = NoteService(username) 

1744 # 404 an unknown id like the other note routes. 

1745 if not service.note_exists(note_id): 1745 ↛ 1747line 1745 didn't jump to line 1747 because the condition on line 1745 was always true

1746 return jsonify({"success": False, "error": "Note not found"}), 404 

1747 links = service.get_outgoing_links(note_id) 

1748 

1749 return jsonify( 

1750 { 

1751 "success": True, 

1752 "links": links, 

1753 "total": len(links), 

1754 } 

1755 ) 

1756 

1757 except Exception as e: 

1758 return handle_api_error("getting outgoing links", e) 

1759 

1760 

1761@notes_bp.route("/api/notes/<string:note_id>/suggested-links", methods=["GET"]) 

1762@login_required 

1763@_notes_search_limit 

1764def get_suggested_links(note_id): 

1765 """Get AI-suggested notes to link to based on semantic similarity.""" 

1766 username = session.get("username") 

1767 if not username: 1767 ↛ 1768line 1767 didn't jump to line 1768 because the condition on line 1767 was never true

1768 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1769 

1770 try: 

1771 try: 

1772 limit = _clamp_limit(5) 

1773 except (ValueError, TypeError): 

1774 return jsonify({"success": False, "error": "Invalid limit"}), 400 

1775 # 404 a missing/unknown note like every sibling AI-link route 

1776 # (suggest_links returns [] for a missing note, which is otherwise 

1777 # indistinguishable from "a real note with no suggestions"). 

1778 if not NoteService(username).note_exists(note_id): 1778 ↛ 1779line 1778 didn't jump to line 1779 because the condition on line 1778 was never true

1779 return jsonify({"success": False, "error": "Note not found"}), 404 

1780 service = NoteAIService(username) 

1781 suggestions = service.suggest_links(note_id, limit=limit) 

1782 

1783 return jsonify({"success": True, "suggestions": suggestions}) 

1784 

1785 except Exception as e: 

1786 return handle_api_error("getting suggested links", e) 

1787 

1788 

1789@notes_bp.route("/api/notes/<string:note_id>/accept-link", methods=["POST"]) 

1790@login_required 

1791@_notes_write_limit 

1792def accept_suggested_link(note_id): 

1793 """Accept an AI-suggested link: insert [[Target Title]] into the note 

1794 content and flag the resulting NoteLink as auto_suggested=True. 

1795 

1796 Body: 

1797 target_note_id: The ID of the note to link to. 

1798 """ 

1799 username = session.get("username") 

1800 if not username: 1800 ↛ 1801line 1800 didn't jump to line 1801 because the condition on line 1800 was never true

1801 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1802 

1803 try: 

1804 data = request.get_json(silent=True) or {} 

1805 target_note_id = data.get("target_note_id") 

1806 if not target_note_id: 1806 ↛ 1807line 1806 didn't jump to line 1807 because the condition on line 1806 was never true

1807 return jsonify( 

1808 {"success": False, "error": "target_note_id required"} 

1809 ), 400 

1810 

1811 service = NoteService(username) 

1812 result = service.accept_suggested_link(note_id, target_note_id) 

1813 if not result: 1813 ↛ 1818line 1813 didn't jump to line 1818 because the condition on line 1813 was always true

1814 return jsonify( 

1815 {"success": False, "error": "Link could not be accepted"} 

1816 ), 404 

1817 

1818 return jsonify({"success": True, "note": result}) 

1819 

1820 except ValueError as e: 

1821 # A real write failure surfaced by the service (e.g. the appended link 

1822 # pushed the note past the content-size cap) is a client-fixable 400, 

1823 # not the generic 500 handle_api_error would otherwise return. 

1824 return jsonify({"success": False, "error": str(e)}), 400 

1825 except Exception as e: 

1826 return handle_api_error("accepting suggested link", e) 

1827 

1828 

1829@notes_bp.route( 

1830 "/api/notes/<string:note_id>/unlinked-mentions", methods=["GET"] 

1831) 

1832@login_required 

1833@_notes_search_limit 

1834def get_unlinked_mentions(note_id): 

1835 """Notes whose text mentions this note's title but don't yet link to it. 

1836 

1837 Lexical (no embeddings). The client links one via the existing 

1838 /accept-link route on the *mentioning* note (target = this note). 

1839 """ 

1840 username = session.get("username") 

1841 if not username: 1841 ↛ 1842line 1841 didn't jump to line 1842 because the condition on line 1841 was never true

1842 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1843 

1844 try: 

1845 service = NoteService(username) 

1846 # 404 an unknown id like the other note routes. 

1847 if not service.note_exists(note_id): 1847 ↛ 1849line 1847 didn't jump to line 1849 because the condition on line 1847 was always true

1848 return jsonify({"success": False, "error": "Note not found"}), 404 

1849 mentions = service.get_unlinked_mentions(note_id) 

1850 return jsonify({"success": True, "mentions": mentions}) 

1851 except Exception as e: 

1852 return handle_api_error("getting unlinked mentions", e) 

1853 

1854 

1855# Passage text is a paragraph-ish selection; cap it so a huge selection 

1856# can't be sent as the embedding query. 

1857MAX_PASSAGE_LEN = 2000 

1858 

1859 

1860@notes_bp.route( 

1861 "/api/notes/<string:note_id>/similar-passages", methods=["POST"] 

1862) 

1863@login_required 

1864@_notes_search_limit 

1865def similar_passages(note_id): 

1866 """Find note passages (chunks) semantically similar to a given snippet. 

1867 

1868 Body: ``{"text": "<selected paragraph>"}``. Chunks from this note are 

1869 excluded so the result is "related passages elsewhere". 

1870 """ 

1871 username = session.get("username") 

1872 if not username: 1872 ↛ 1873line 1872 didn't jump to line 1873 because the condition on line 1872 was never true

1873 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1874 

1875 try: 

1876 data = request.get_json(silent=True) or {} 

1877 text = data.get("text") 

1878 if not text or not isinstance(text, str) or not text.strip(): 1878 ↛ 1879line 1878 didn't jump to line 1879 because the condition on line 1878 was never true

1879 return jsonify({"success": False, "error": "text required"}), 400 

1880 text = text.strip()[:MAX_PASSAGE_LEN] 

1881 

1882 service = NoteAIService(username) 

1883 passages = service.find_similar_passages(text, exclude_note_id=note_id) 

1884 return jsonify({"success": True, "passages": passages}) 

1885 except Exception as e: 

1886 return handle_api_error("finding similar passages", e) 

1887 

1888 

1889# ============================================================================ 

1890# API Routes - Ask your notes (research pinned to the Notes collection) 

1891# ============================================================================ 

1892 

1893 

1894@notes_bp.route("/api/notes/ask-context", methods=["GET"]) 

1895@login_required 

1896@_notes_search_limit 

1897def ask_context(): 

1898 """Pre-flight context for the 'Ask your notes' box: the Notes collection 

1899 id + note/indexed counts, so the UI can warn when there's nothing to 

1900 search before starting a research run pinned to that collection. 

1901 """ 

1902 username = session.get("username") 

1903 if not username: 

1904 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1905 

1906 try: 

1907 service = NoteService(username) 

1908 status = service.get_notes_index_status() 

1909 return jsonify({"success": True, **status}) 

1910 except Exception as e: 

1911 return handle_api_error("getting ask-notes context", e) 

1912 

1913 

1914# ============================================================================ 

1915# API Routes - Verify Note with Research (fact-check) 

1916# ============================================================================ 

1917 

1918 

1919@notes_bp.route("/api/notes/<string:note_id>/fact-check", methods=["POST"]) 

1920@login_required 

1921@_notes_factcheck_limit 

1922def fact_check_note(note_id): 

1923 """Extract checkable factual claims from a note and synthesize a research 

1924 query for verifying them. 

1925 

1926 The client then starts a research run (reusing /api/start_research + the 

1927 research-link flow, exactly like "Research This Note") and, once it 

1928 completes, POSTs the claims to the /grade endpoint below. 

1929 """ 

1930 username = session.get("username") 

1931 if not username: 1931 ↛ 1932line 1931 didn't jump to line 1932 because the condition on line 1931 was never true

1932 return jsonify({"success": False, "error": "Not authenticated"}), 401 

1933 

1934 try: 

1935 # Guard the note id like every other note route: without this an 

1936 # unknown/typo'd id fell through to the claim-less 422 below, conflating 

1937 # "this note has nothing to verify" with "there is no such note". 

1938 if not NoteService(username).note_exists(note_id): 

1939 return jsonify({"success": False, "error": "Note not found"}), 404 

1940 

1941 ai = NoteAIService(username) 

1942 claims = ai.extract_claims(note_id) 

1943 if not claims: 

1944 # The note exists but has no checkable factual claims. 

1945 return jsonify( 

1946 { 

1947 "success": False, 

1948 "error": "No checkable factual claims found in this note.", 

1949 } 

1950 ), 422 

1951 query = ai.synthesize_factcheck_query(claims) 

1952 return jsonify({"success": True, "claims": claims, "query": query}) 

1953 

1954 except Exception as e: 

1955 return handle_api_error("starting note fact-check", e) 

1956 

1957 

1958def _grade_note_claims(username, note_id, research_id, claims): 

1959 """Grade ``claims`` against a COMPLETED research run's report and persist 

1960 the verdicts under ``research_meta['fact_check']``. 

1961 

1962 Returns a ``(payload, status_code)`` tuple. Extracted from the route so it 

1963 can be unit-tested without a Flask client. Runs entirely in the request 

1964 thread, where the per-user DB password is available, so the LLM grade and 

1965 report fetch work without the encrypted-DB background-worker problem. 

1966 """ 

1967 from ...constants import ResearchStatus 

1968 from ...database.models import NoteResearch, ResearchHistory 

1969 from ...database.session_context import get_user_db_session 

1970 from ...storage import get_report_storage 

1971 from ..services.report_assembly_service import ( 

1972 get_research_source_links_batch, 

1973 ) 

1974 

1975 # Load the research (per-user DB session scopes it to this user) and 

1976 # require it to have completed before grading. 

1977 with get_user_db_session(username) as db: 

1978 research = db.query(ResearchHistory).filter_by(id=research_id).first() 

1979 if not research: 

1980 return {"success": False, "error": "Research not found"}, 404 

1981 # Require the research to actually be linked to THIS note — otherwise a 

1982 # client could grade an arbitrary completed research against an 

1983 # arbitrary note and stamp a foreign note_id into its meta. 

1984 linked = ( 

1985 db.query(NoteResearch) 

1986 .filter_by(document_id=note_id, research_id=research_id) 

1987 .first() 

1988 ) 

1989 if not linked: 

1990 return { 

1991 "success": False, 

1992 "error": "Research is not linked to this note", 

1993 }, 404 

1994 status = research.status 

1995 meta = dict(research.research_meta or {}) 

1996 

1997 if status != ResearchStatus.COMPLETED: 

1998 return { 

1999 "success": False, 

2000 "error": "Research is not complete yet.", 

2001 "status": status, 

2002 }, 409 

2003 

2004 # Fetch the report markdown + sources (in-request). 

2005 settings_snapshot = meta.get("settings_snapshot") 

2006 with get_user_db_session(username) as db: 

2007 storage = get_report_storage( 

2008 session=db, settings_snapshot=settings_snapshot 

2009 ) 

2010 report = storage.get_report(research_id, username) 

2011 # Sources live in the research_resources table, not research_meta. 

2012 # The post-refactor save path never writes the legacy 

2013 # `all_links_of_system` metadata key (see the identical fix in 

2014 # research_routes.get_report), so reading it here returned [] for 

2015 # every research: the grader prompt always showed "(no sources)" 

2016 # and every verdict's sources array was empty, leaving the 

2017 # citation-validation/anti-rubber-stamp checks unreachable. Read 

2018 # the structured table instead — the same source of truth the 

2019 # results page uses. Fetch only the MAX_GRADE_SOURCES window the 

2020 # grader will actually show; anything past it was discarded. 

2021 sources = get_research_source_links_batch( 

2022 [research_id], db, limit=_MAX_GRADE_SOURCES 

2023 ).get(research_id, []) 

2024 

2025 # get_report returns None for BOTH a missing report and a swallowed read 

2026 # error. Grading against an empty report would label every claim 

2027 # "unverified" and persist that as a successful fact-check — a false result. 

2028 # Refuse to grade instead. 

2029 if not report or not str(report).strip(): 

2030 return { 

2031 "success": False, 

2032 "error": ( 

2033 "The research report is unavailable, so its claims cannot be " 

2034 "graded. Try re-running the research." 

2035 ), 

2036 }, 502 

2037 

2038 ai = NoteAIService(username) 

2039 verdicts = ai.grade_all_claims(claims, report, sources) 

2040 

2041 # Persist verdicts under research_meta['fact_check']. Reassign the whole 

2042 # dict so SQLAlchemy marks the plain-JSON column dirty (this codebase 

2043 # deliberately avoids MutableDict — see research_sources_service.py). 

2044 with get_user_db_session(username) as db: 

2045 research = db.query(ResearchHistory).filter_by(id=research_id).first() 

2046 if research is None: 

2047 # The research row existed when we loaded it for grading (above) 

2048 # but has since vanished — e.g. a concurrent delete between the 

2049 # grade and this persist. The verdicts were computed but cannot be 

2050 # saved. Returning success here would tell the client a fact-check 

2051 # was persisted that never was (a silent partial failure); report 

2052 # the conflict explicitly instead. 

2053 return { 

2054 "success": False, 

2055 "error": ( 

2056 "The research run disappeared before its fact-check " 

2057 "verdicts could be saved. Please try again." 

2058 ), 

2059 }, 409 

2060 try: 

2061 new_meta = dict(research.research_meta or {}) 

2062 new_meta["fact_check"] = { 

2063 "note_id": note_id, 

2064 "claims": claims, 

2065 "verdicts": verdicts, 

2066 } 

2067 research.research_meta = new_meta 

2068 db.commit() 

2069 except Exception: 

2070 # Don't leave the shared request session poisoned. 

2071 db.rollback() 

2072 raise 

2073 

2074 return {"success": True, "verdicts": verdicts}, 200 

2075 

2076 

2077@notes_bp.route( 

2078 "/api/notes/<string:note_id>/fact-check/<string:research_id>/grade", 

2079 methods=["POST"], 

2080) 

2081@login_required 

2082@_notes_factcheck_limit 

2083def grade_note_fact_check(note_id, research_id): 

2084 """Grade a note's claims against a COMPLETED research run's report. 

2085 

2086 Body: ``{"claims": ["...", ...]}`` — the claims returned by /fact-check. 

2087 """ 

2088 username = session.get("username") 

2089 if not username: 2089 ↛ 2090line 2089 didn't jump to line 2090 because the condition on line 2089 was never true

2090 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2091 

2092 try: 

2093 data = request.get_json(silent=True) or {} 

2094 raw_claims = data.get("claims") 

2095 if not isinstance(raw_claims, list): 

2096 return jsonify({"success": False, "error": "claims required"}), 400 

2097 # Cap both the count AND each claim's length — the rest of this file 

2098 # length-bounds free text (MAX_SEARCH_LEN / _clamp_text_query) but the 

2099 # grade endpoint took client claims with no per-string bound. 

2100 claims = [ 

2101 c.strip()[:MAX_CLAIM_LEN] 

2102 for c in raw_claims 

2103 if isinstance(c, str) and c.strip() 

2104 ][: NoteAIService.MAX_CLAIMS_PER_NOTE] 

2105 if not claims: 

2106 return jsonify({"success": False, "error": "claims required"}), 400 

2107 

2108 payload, status_code = _grade_note_claims( 

2109 username, note_id, research_id, claims 

2110 ) 

2111 return jsonify(payload), status_code 

2112 

2113 except Exception as e: 

2114 return handle_api_error("grading note fact-check", e) 

2115 

2116 

2117@notes_bp.route("/api/notes/resolve-link", methods=["POST"]) 

2118@login_required 

2119@_notes_search_limit 

2120def resolve_link(): 

2121 """ 

2122 Resolve a [[link]] text to a note. 

2123 

2124 Body: 

2125 link_text: The text inside [[brackets]] 

2126 """ 

2127 username = session.get("username") 

2128 if not username: 2128 ↛ 2129line 2128 didn't jump to line 2129 because the condition on line 2128 was never true

2129 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2130 

2131 try: 

2132 data = request.get_json(silent=True) 

2133 if not data or not data.get("link_text"): 2133 ↛ 2134line 2133 didn't jump to line 2134 because the condition on line 2133 was never true

2134 return jsonify( 

2135 {"success": False, "error": "link_text is required"} 

2136 ), 400 

2137 try: 

2138 link_text = _clamp_text_query( 

2139 data["link_text"], "link_text", max_len=MAX_LINK_TEXT_LEN 

2140 ) 

2141 except ValueError as exc: 

2142 return jsonify({"success": False, "error": str(exc)}), 400 

2143 

2144 service = NoteService(username) 

2145 result = service.resolve_link(link_text) 

2146 

2147 if result: 

2148 return jsonify({"success": True, "note": result}) 

2149 return jsonify({"success": False, "error": "Note not found"}), 404 

2150 

2151 except Exception as e: 

2152 return handle_api_error("resolving link", e) 

2153 

2154 

2155@notes_bp.route("/api/notes/search-for-linking", methods=["GET"]) 

2156@login_required 

2157@_notes_search_limit 

2158def search_notes_for_linking(): 

2159 """ 

2160 Search notes for the [[link]] autocomplete feature. 

2161 

2162 Query params: 

2163 query: Search query (partial title match) 

2164 exclude_note_id: Note ID to exclude from results 

2165 limit: Maximum number of results (default 10) 

2166 """ 

2167 username = session.get("username") 

2168 if not username: 2168 ↛ 2169line 2168 didn't jump to line 2169 because the condition on line 2168 was never true

2169 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2170 

2171 try: 

2172 try: 

2173 query = _clamp_text_query(request.args.get("query", ""), "query") 

2174 except ValueError as exc: 

2175 return jsonify({"success": False, "error": str(exc)}), 400 

2176 if not query: 

2177 return jsonify({"success": True, "notes": []}) 

2178 

2179 exclude_note_id = request.args.get("exclude_note_id") 

2180 try: 

2181 limit = _clamp_limit(10) 

2182 except (ValueError, TypeError): 

2183 return jsonify({"success": False, "error": "Invalid limit"}), 400 

2184 

2185 service = NoteService(username) 

2186 notes = service.search_notes_for_linking(query, exclude_note_id, limit) 

2187 

2188 return jsonify({"success": True, "notes": notes}) 

2189 

2190 except Exception as e: 

2191 return handle_api_error("searching notes for linking", e) 

2192 

2193 

2194# ============================================================================ 

2195# API Routes - Note Synthesis 

2196# ============================================================================ 

2197 

2198 

2199@notes_bp.route("/api/notes/synthesize/preview", methods=["POST"]) 

2200@login_required 

2201@_notes_synthesize_limit 

2202def preview_synthesis(): 

2203 """ 

2204 Preview what a synthesis would produce. 

2205 

2206 Body: 

2207 note_ids: List of note IDs (2-5) 

2208 synthesis_type: 'merge', 'summarize', or 'compare' 

2209 """ 

2210 username = session.get("username") 

2211 if not username: 2211 ↛ 2212line 2211 didn't jump to line 2212 because the condition on line 2211 was never true

2212 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2213 

2214 try: 

2215 data = request.get_json(silent=True) 

2216 if not data or not data.get("note_ids"): 2216 ↛ 2217line 2216 didn't jump to line 2217 because the condition on line 2216 was never true

2217 return jsonify( 

2218 {"success": False, "error": "note_ids is required"} 

2219 ), 400 

2220 

2221 note_ids = data["note_ids"] 

2222 if not isinstance(note_ids, list) or not all( 2222 ↛ 2227line 2222 didn't jump to line 2227 because the condition on line 2222 was never true

2223 isinstance(n, str) for n in note_ids 

2224 ): 

2225 # Element type matters: synthesize_notes does dict.fromkeys(note_ids), 

2226 # which raises TypeError (→ 500) on unhashable items like dicts. 

2227 return jsonify( 

2228 { 

2229 "success": False, 

2230 "error": "note_ids must be a list of strings", 

2231 } 

2232 ), 400 

2233 synthesis_type = data.get("synthesis_type", "merge") 

2234 # synthesize_notes checks `synthesis_type not in {set}` (a hash), which 

2235 # raises TypeError (→ opaque 500) on an unhashable non-string like a 

2236 # list/dict. Reject to a clean 400 here, like note_ids above. 

2237 if not isinstance(synthesis_type, str): 

2238 return jsonify( 

2239 { 

2240 "success": False, 

2241 "error": "synthesis_type must be a string", 

2242 } 

2243 ), 400 

2244 

2245 # synthesize_notes returns the result without saving — callers (this 

2246 # preview route, and the create-note flow downstream) decide whether 

2247 # to persist it. Same call site, same payload. 

2248 service = NoteAIService(username) 

2249 preview = service.synthesize_notes(note_ids, synthesis_type) 

2250 

2251 if "error" in preview: 2251 ↛ 2252line 2251 didn't jump to line 2252 because the condition on line 2251 was never true

2252 return jsonify({"success": False, "error": preview["error"]}), 400 

2253 

2254 return jsonify({"success": True, "result": preview}) 

2255 

2256 except Exception as e: 

2257 return handle_api_error("previewing synthesis", e) 

2258 

2259 

2260@notes_bp.route("/api/notes/synthesize", methods=["POST"]) 

2261@login_required 

2262@_notes_synthesize_limit 

2263def synthesize_notes(): 

2264 """ 

2265 Synthesize multiple notes into one. 

2266 

2267 Body: 

2268 note_ids: List of note IDs (2-5) 

2269 synthesis_type: 'merge', 'summarize', or 'compare' 

2270 create_note: Whether to create the note (default true) 

2271 """ 

2272 from ...database.models import NoteSynthesis, NoteSynthesisSource 

2273 

2274 username = session.get("username") 

2275 if not username: 2275 ↛ 2276line 2275 didn't jump to line 2276 because the condition on line 2275 was never true

2276 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2277 

2278 try: 

2279 data = request.get_json(silent=True) 

2280 if not data or not data.get("note_ids"): 2280 ↛ 2281line 2280 didn't jump to line 2281 because the condition on line 2280 was never true

2281 return jsonify( 

2282 {"success": False, "error": "note_ids is required"} 

2283 ), 400 

2284 

2285 note_ids = data["note_ids"] 

2286 if not isinstance(note_ids, list) or not all( 

2287 isinstance(n, str) for n in note_ids 

2288 ): 

2289 # Element type matters: synthesize_notes does dict.fromkeys(note_ids), 

2290 # which raises TypeError (→ 500) on unhashable items like dicts. 

2291 return jsonify( 

2292 { 

2293 "success": False, 

2294 "error": "note_ids must be a list of strings", 

2295 } 

2296 ), 400 

2297 synthesis_type = data.get("synthesis_type", "merge") 

2298 # synthesize_notes checks `synthesis_type not in {set}` (a hash), which 

2299 # raises TypeError (→ opaque 500) on an unhashable non-string like a 

2300 # list/dict. Reject to a clean 400 here, like note_ids above. 

2301 if not isinstance(synthesis_type, str): 

2302 return jsonify( 

2303 { 

2304 "success": False, 

2305 "error": "synthesis_type must be a string", 

2306 } 

2307 ), 400 

2308 create_note = data.get("create_note", True) 

2309 

2310 ai_service = NoteAIService(username) 

2311 result = ai_service.synthesize_notes(note_ids, synthesis_type) 

2312 

2313 if "error" in result: 

2314 return jsonify({"success": False, "error": result["error"]}), 400 

2315 

2316 # Create the note if requested 

2317 if create_note: 

2318 note_service = NoteService(username) 

2319 new_note_id = note_service.create_note( 

2320 title=result["suggested_title"], 

2321 content=result["content"], 

2322 tags=["synthesized", synthesis_type], 

2323 ) 

2324 

2325 # Record the synthesis. Use the FILTERED source set the AI 

2326 # service actually fed to the LLM (result["source_notes"]) 

2327 # rather than the raw client input — NoteAIService.synthesize_notes 

2328 # silently drops any non-note Document IDs from the prompt, so 

2329 # persisting raw note_ids would (a) record IDs the LLM never 

2330 # saw as "sources" and (b) FK-violate on any bogus ID after 

2331 # create_note has already committed. 

2332 from ...database.session_context import get_user_db_session 

2333 

2334 filtered_source_ids = [ 

2335 src["id"] 

2336 for src in result.get("source_notes", []) 

2337 if "id" in src 

2338 ] 

2339 

2340 db_session = None 

2341 try: 

2342 with get_user_db_session(username) as db_session: 

2343 synthesis_record = NoteSynthesis( 

2344 result_document_id=new_note_id, 

2345 synthesis_type=synthesis_type, 

2346 ) 

2347 for doc_id in filtered_source_ids: 

2348 synthesis_record.sources.append( 

2349 NoteSynthesisSource(source_document_id=doc_id) 

2350 ) 

2351 db_session.add(synthesis_record) 

2352 db_session.commit() 

2353 except Exception: 

2354 # create_note() already committed the new Document in its own 

2355 # session, so a failure here would leave an orphaned 

2356 # "synthesized" note with no provenance record. Roll back the 

2357 # poisoned shared session and delete the just-created note 

2358 # before surfacing the error. 

2359 logger.exception( 

2360 "Failed to persist synthesis record for note {}; " 

2361 "deleting the orphaned note", 

2362 new_note_id, 

2363 ) 

2364 NoteService._rollback_quietly(db_session) 

2365 try: 

2366 note_service.delete_note(new_note_id) 

2367 except Exception: 

2368 logger.exception( 

2369 "Failed to delete orphaned synthesized note {}", 

2370 new_note_id, 

2371 ) 

2372 raise 

2373 

2374 result["note_id"] = new_note_id 

2375 

2376 status_code = 201 if create_note else 200 

2377 return jsonify({"success": True, "result": result}), status_code 

2378 

2379 except Exception as e: 

2380 return handle_api_error("synthesizing notes", e) 

2381 

2382 

2383# ============================================================================ 

2384# API Routes - Version History 

2385# ============================================================================ 

2386 

2387 

2388@notes_bp.route("/api/notes/<string:note_id>/versions", methods=["GET"]) 

2389@login_required 

2390def get_note_versions(note_id): 

2391 """ 

2392 Get version history for a note. 

2393 

2394 Query params: 

2395 limit: Maximum number of versions to return (default 20, max 200) 

2396 offset: Number of versions to skip (default 0). Combined with 

2397 limit, this makes older versions reachable when total > limit. 

2398 Pre-fix the route had no offset support, so versions 1 

2399 through (total - limit) were unreachable through the UI. 

2400 """ 

2401 from ...database.models import Document, NoteVersion 

2402 from ...database.session_context import get_user_db_session 

2403 

2404 username = session.get("username") 

2405 if not username: 2405 ↛ 2406line 2405 didn't jump to line 2406 because the condition on line 2405 was never true

2406 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2407 

2408 note_service = NoteService(username) 

2409 

2410 try: 

2411 try: 

2412 limit = _clamp_limit(20) 

2413 except (ValueError, TypeError): 

2414 return jsonify({"success": False, "error": "Invalid limit"}), 400 

2415 

2416 try: 

2417 offset = max(0, int(request.args.get("offset", 0))) 

2418 except (ValueError, TypeError): 

2419 return jsonify({"success": False, "error": "Invalid offset"}), 400 

2420 

2421 with get_user_db_session(username) as db_session: 

2422 # Defense-in-depth: confirm the document is a note before 

2423 # serving anything from its version history. Mirrors the 

2424 # guard on the sibling version routes (get_note_version, 

2425 # get_semantic_diff, restore_note_version). load_only keeps 

2426 # the guard from hydrating text_content (up to 50 MB) just 

2427 # to check the source type. 

2428 doc = ( 

2429 db_session.query(Document) 

2430 .options(load_only(Document.id, Document.source_type_id)) 

2431 .filter_by(id=note_id) 

2432 .first() 

2433 ) 

2434 if not doc or not note_service._is_note(db_session, doc): 

2435 return jsonify( 

2436 {"success": False, "error": "Note not found"} 

2437 ), 404 

2438 

2439 total = ( 

2440 db_session.query(NoteVersion) 

2441 .filter_by(document_id=note_id) 

2442 .count() 

2443 ) 

2444 # Cap offset at total: an unbounded OFFSET (e.g. 

2445 # ?offset=1_000_000_000) forces SQLite to row-walk the 

2446 # whole table in a backward index scan even though the 

2447 # result is empty. Clamping is cheap and harmless — a real 

2448 # caller with offset > total wants the empty page anyway. 

2449 if offset > total: 2449 ↛ 2450line 2449 didn't jump to line 2450 because the condition on line 2449 was never true

2450 offset = total 

2451 # Column projection: each NoteVersion row carries a full 

2452 # content snapshot (up to the 50 MB cap); loading whole 

2453 # entities transferred `limit` note bodies per call to render 

2454 # a metadata-only list. 

2455 versions = ( 

2456 db_session.query( 

2457 NoteVersion.id, 

2458 NoteVersion.title, 

2459 NoteVersion.change_type, 

2460 NoteVersion.change_summary, 

2461 NoteVersion.created_at, 

2462 ) 

2463 .filter_by(document_id=note_id) 

2464 .order_by( 

2465 NoteVersion.created_at.desc(), 

2466 NoteVersion.id.desc(), # id tiebreaker for stable paging 

2467 ) 

2468 .offset(offset) 

2469 .limit(limit) 

2470 .all() 

2471 ) 

2472 return jsonify( 

2473 { 

2474 "success": True, 

2475 "total": total, 

2476 "limit": limit, 

2477 "offset": offset, 

2478 "versions": [ 

2479 { 

2480 "id": v.id, 

2481 # Global version index — newest is `total`, 

2482 # oldest is 1. Stable across pages. 

2483 "version_number": total - offset - i, 

2484 "title": v.title, 

2485 "change_type": v.change_type, 

2486 "change_summary": v.change_summary, 

2487 "created_at": v.created_at.isoformat() 

2488 if v.created_at 

2489 else None, 

2490 } 

2491 for i, v in enumerate(versions) 

2492 ], 

2493 } 

2494 ) 

2495 

2496 except Exception as e: 

2497 return handle_api_error("getting note versions", e) 

2498 

2499 

2500@notes_bp.route( 

2501 "/api/notes/<string:note_id>/versions/semantic-diff", methods=["GET"] 

2502) 

2503@login_required 

2504@_notes_ai_limit 

2505def get_semantic_diff(note_id): 

2506 """ 

2507 Get AI-powered semantic diff between two versions. 

2508 

2509 Query params: 

2510 version1: First version UUID 

2511 version2: Second version UUID, or the literal "current" to diff 

2512 against the note's current content. 

2513 """ 

2514 from ...database.models import Document, NoteVersion 

2515 from ...database.session_context import get_user_db_session 

2516 

2517 username = session.get("username") 

2518 if not username: 2518 ↛ 2519line 2518 didn't jump to line 2519 because the condition on line 2518 was never true

2519 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2520 

2521 note_service = NoteService(username) 

2522 

2523 try: 

2524 version1 = request.args.get("version1") 

2525 version2 = request.args.get("version2") 

2526 

2527 if not version1 or not version2: 2527 ↛ 2528line 2527 didn't jump to line 2528 because the condition on line 2527 was never true

2528 return jsonify( 

2529 { 

2530 "success": False, 

2531 "error": "version1 and version2 are required", 

2532 } 

2533 ), 400 

2534 

2535 with get_user_db_session(username) as db_session: 

2536 # Defense-in-depth: confirm the document is a note before 

2537 # serving anything from its version history. load_only defers 

2538 # text_content; the "current" branch below lazy-loads it only 

2539 # when actually diffing against the live note. 

2540 doc = ( 

2541 db_session.query(Document) 

2542 .options(load_only(Document.id, Document.source_type_id)) 

2543 .filter_by(id=note_id) 

2544 .first() 

2545 ) 

2546 if not doc or not note_service._is_note(db_session, doc): 2546 ↛ 2547line 2546 didn't jump to line 2547 because the condition on line 2546 was never true

2547 return jsonify( 

2548 {"success": False, "error": "Note not found"} 

2549 ), 404 

2550 

2551 v1 = ( 

2552 db_session.query(NoteVersion) 

2553 .filter_by(document_id=note_id, id=version1) 

2554 .first() 

2555 ) 

2556 if not v1: 

2557 return jsonify( 

2558 {"success": False, "error": "Version not found"} 

2559 ), 404 

2560 

2561 if version2 == "current": 2561 ↛ 2562line 2561 didn't jump to line 2562 because the condition on line 2561 was never true

2562 v2_content = doc.text_content or "" 

2563 else: 

2564 v2 = ( 

2565 db_session.query(NoteVersion) 

2566 .filter_by(document_id=note_id, id=version2) 

2567 .first() 

2568 ) 

2569 if not v2: 

2570 return jsonify( 

2571 {"success": False, "error": "Version not found"} 

2572 ), 404 

2573 v2_content = v2.content 

2574 

2575 ai_service = NoteAIService(username) 

2576 diff = ai_service.semantic_diff(v1.content, v2_content) 

2577 

2578 return jsonify({"success": True, "diff": diff}) 

2579 

2580 except Exception as e: 

2581 return handle_api_error("getting semantic diff", e) 

2582 

2583 

2584@notes_bp.route( 

2585 "/api/notes/<string:note_id>/versions/<string:version_id>", methods=["GET"] 

2586) 

2587@login_required 

2588def get_note_version(note_id, version_id): 

2589 """Get a specific version of a note.""" 

2590 from ...database.models import Document, NoteVersion 

2591 from ...database.session_context import get_user_db_session 

2592 

2593 username = session.get("username") 

2594 if not username: 2594 ↛ 2595line 2594 didn't jump to line 2595 because the condition on line 2594 was never true

2595 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2596 

2597 note_service = NoteService(username) 

2598 

2599 try: 

2600 with get_user_db_session(username) as db_session: 

2601 # Defense-in-depth: confirm the parent document is a note. 

2602 # load_only keeps the guard from hydrating text_content. 

2603 doc = ( 

2604 db_session.query(Document) 

2605 .options(load_only(Document.id, Document.source_type_id)) 

2606 .filter_by(id=note_id) 

2607 .first() 

2608 ) 

2609 if not doc or not note_service._is_note(db_session, doc): 2609 ↛ 2610line 2609 didn't jump to line 2610 because the condition on line 2609 was never true

2610 return jsonify( 

2611 {"success": False, "error": "Note not found"} 

2612 ), 404 

2613 

2614 version = ( 

2615 db_session.query(NoteVersion) 

2616 .filter_by(document_id=note_id, id=version_id) 

2617 .first() 

2618 ) 

2619 

2620 if not version: 

2621 return jsonify( 

2622 {"success": False, "error": "Version not found"} 

2623 ), 404 

2624 

2625 return jsonify( 

2626 { 

2627 "success": True, 

2628 "version": { 

2629 "id": version.id, 

2630 "title": version.title, 

2631 "content": version.content, 

2632 "tags": version.tags, 

2633 "change_type": version.change_type, 

2634 "change_summary": version.change_summary, 

2635 "created_at": version.created_at.isoformat() 

2636 if version.created_at 

2637 else None, 

2638 }, 

2639 } 

2640 ) 

2641 

2642 except Exception as e: 

2643 return handle_api_error("getting note version", e) 

2644 

2645 

2646@notes_bp.route( 

2647 "/api/notes/<string:note_id>/versions/<string:version_id>/restore", 

2648 methods=["POST"], 

2649) 

2650@login_required 

2651@_notes_write_limit 

2652def restore_note_version(note_id, version_id): 

2653 """Restore a note to a previous version. 

2654 

2655 Delegates to ``NoteService.restore_with_bookends`` which wraps 

2656 PRE_RESTORE + content update + RESTORE in a single transaction. 

2657 Earlier code orchestrated this from three separate sessions — process 

2658 death between writes could leave a restored note with no audit-trail 

2659 row. Keep the orchestration in the service; this handler just routes. 

2660 """ 

2661 username = session.get("username") 

2662 if not username: 2662 ↛ 2663line 2662 didn't jump to line 2663 because the condition on line 2662 was never true

2663 return jsonify({"success": False, "error": "Not authenticated"}), 401 

2664 

2665 note_service = NoteService(username) 

2666 

2667 try: 

2668 success, error = note_service.restore_with_bookends(note_id, version_id) 

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

2670 if error == "note_not_found": 

2671 return jsonify( 

2672 {"success": False, "error": "Note not found"} 

2673 ), 404 

2674 if error == "version_not_found": 

2675 return jsonify( 

2676 {"success": False, "error": "Version not found"} 

2677 ), 404 

2678 return jsonify( 

2679 {"success": False, "error": "Failed to restore version"} 

2680 ), 500 

2681 

2682 _trigger_note_auto_index(note_id, note_service, username) 

2683 

2684 return jsonify( 

2685 { 

2686 "success": True, 

2687 "message": f"Restored to version {version_id[:8]}", 

2688 } 

2689 ) 

2690 

2691 except Exception as e: 

2692 return handle_api_error("restoring note version", e)