Coverage for src/local_deep_research/web/routers/notes.py: 84%

1032 statements  

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

1""" 

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

3 

4Ported from the Flask ``web/routes/notes_routes.py`` blueprint (``notes_bp``, 

5url_prefix ``/notes``). Every endpoint is mirrored exactly — same paths, 

6methods, status codes, service calls, validation, and error messages. 

7 

8Notes on the port: 

9 * ``@login_required`` → ``Depends(require_auth)``; the per-route manual 

10 ``session.get("username")`` / 401 guard is dropped because ``require_auth`` 

11 already raises 401 when there is no authenticated user. 

12 * The two blueprint-wide ``before_request`` hooks (``_reject_oversized_bodies`` 

13 and ``_reject_non_object_json_body``) are collapsed into the shared 

14 ``_notes_json_body`` dependency, injected into every mutating route. 

15 * Flask ``request.args`` → ``request.query_params``; ``session[...]`` → 

16 the ``username`` dependency / ``request.session``. 

17 * Route registration order is deliberate: the literal ``/api/notes/...`` GET 

18 routes are registered BEFORE ``GET /api/notes/{note_id}`` because Starlette 

19 matches routes in registration order (Flask's map gave static rules 

20 priority automatically; FastAPI does not). 

21""" 

22 

23import asyncio 

24import json 

25import math 

26import reprlib 

27 

28from fastapi import APIRouter, Depends, Request 

29from fastapi.responses import JSONResponse 

30from loguru import logger 

31from sqlalchemy.exc import IntegrityError 

32from sqlalchemy.orm import load_only 

33 

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

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

36 MAX_TAG_LENGTH, 

37 MAX_TAGS_PER_NOTE, 

38 NOTE_CONTENT_MAX_BYTES, 

39 NoteService, 

40 _assert_content_size, 

41 escape_markdown_link_label, 

42) 

43from ...research_library.utils import handle_api_error 

44from ..dependencies.auth import require_auth 

45from ..dependencies.rate_limit import limiter, _user_key 

46from ..template_config import templates 

47 

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

49# over unbounded TEXT columns — shared with unified_search_routes via 

50# _search_constants so the two route modules can't diverge. The router lives 

51# in web/routers/, so reach the file in web/routes/. 

52from ..routes._search_constants import MAX_SEARCH_LEN 

53from typing import Annotated, Any 

54 

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

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

57_MAX_GRADE_SOURCES = NoteAIService.MAX_GRADE_SOURCES 

58 

59MAX_LINK_TEXT_LEN = 500 

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

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

62MAX_CLAIM_LEN = 1000 

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

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

65MAX_LIMIT = 200 

66 

67 

68def _clamp_limit(request: Request, default, max_=MAX_LIMIT): 

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

70 

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

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

73 """ 

74 return max(1, min(int(request.query_params.get("limit", default)), max_)) 

75 

76 

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

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

79 

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

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

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

83 """ 

84 if value is None: 

85 return value 

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

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

88 if len(value) > max_len: 

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

90 return value 

91 

92 

93# Bounded repr for an untrusted value going into an audit-log line. A naive 

94# ``repr(value)[:100]`` slices AFTER building the full repr — for a value 

95# that came out of ``_notes_json_body`` (up to the 100 MB notes JSON body 

96# cap, see ``_MAX_JSON_BODY_BYTES`` above) that means materializing a second 

97# ~100 MB+ string (more if it's escape-heavy, since ``repr`` expands each 

98# control char to ``\xNN``) just to throw all but 100 chars away. Same 

99# problem for a huge list/dict: ``repr()`` walks and stringifies every 

100# element before any truncation happens. 

101# 

102# ``reprlib.Repr`` avoids this: its container/string methods slice or 

103# ``itertools.islice`` BEFORE recursing/stringifying (see 

104# ``reprlib.Repr.repr_str`` / ``repr_list`` / ``repr_dict`` in the stdlib), 

105# so cost is bounded by the limits below regardless of the input's real 

106# size. ``maxlevel`` also caps recursion depth for a deeply nested dict. 

107_log_value_repr = reprlib.Repr( 

108 maxlevel=4, 

109 maxtuple=10, 

110 maxlist=10, 

111 maxdict=10, 

112 maxset=10, 

113 maxfrozenset=10, 

114 maxstring=100, 

115 maxlong=100, 

116 maxother=100, 

117) 

118 

119 

120def _log_value_preview(value: Any) -> str: 

121 """Bounded, ``logger.warning``-safe preview of an untrusted JSON value. 

122 

123 Use in place of ``repr(value)[:100]`` at any audit-log site whose value 

124 comes from request input (query params, and especially JSON bodies, 

125 which this router allows up to ~100 MB). See ``_log_value_repr`` above 

126 for why plain ``repr()[:100]`` is unsafe here. 

127 

128 The final ``[:100]`` is a cheap backstop, not the primary defense — the 

129 limits on ``_log_value_repr`` already keep the *work* bounded; this just 

130 keeps the *output* bounded too (e.g. a container's per-element reprs can 

131 still sum past 100 chars before ellipsis is added). 

132 """ 

133 return _log_value_repr.repr(value)[:100] 

134 

135 

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

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

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

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

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

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

142# 

143# ``key_func=_user_key`` keys each bucket per authenticated user; without it 

144# the global Limiter default (client IP) would let shared-IP users (NAT, lab, 

145# reverse-proxy-without-trusted-forward-headers) drain each other's 

146# LLM/synthesis budgets. 

147_notes_ai_limit = limiter.shared_limit( 

148 "10 per minute", scope="notes_ai", key_func=_user_key 

149) 

150_notes_search_limit = limiter.shared_limit( 

151 "60 per minute", scope="notes_search", key_func=_user_key 

152) 

153_notes_synthesize_limit = limiter.shared_limit( 

154 "5 per minute", scope="notes_synthesize", key_func=_user_key 

155) 

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

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

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

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

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

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

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

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

164_notes_write_limit = limiter.shared_limit( 

165 "60 per minute", scope="notes_write", key_func=_user_key 

166) 

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

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

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

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

171_notes_save_as_note_limit = limiter.shared_limit( 

172 "10 per minute", scope="notes_save_as_note", key_func=_user_key 

173) 

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

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

176_notes_factcheck_limit = limiter.shared_limit( 

177 "3 per minute", scope="notes_factcheck", key_func=_user_key 

178) 

179 

180router = APIRouter(prefix="/notes", tags=["notes"]) 

181 

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

183# request size limit is sized for multi-file uploads (hundreds of GB), and 

184# parsing buffers the WHOLE body before any service validation runs — so 

185# without this check a multi-GB JSON body reached memory before 

186# _assert_content_size could reject it at 50 MB. 

187# 

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

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

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

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

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

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

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

195# a memory-exhausting multi-GB body. 

196_MAX_JSON_BODY_BYTES = 2 * NOTE_CONTENT_MAX_BYTES 

197 

198 

199#: Above this size the JSON parse is handed to a worker thread. Measured on 

200#: the pinned interpreter with an adversarial-but-valid document (many small 

201#: objects, which is what an attacker sends -- not one long string, which is 

202#: what a benign client sends), ``json.loads`` runs at ~110 ms/MB and scales 

203#: linearly. So 64 KiB is ~7 ms on the loop, and the notes ceiling of 100 MB 

204#: would be ~11 SECONDS -- a whole-instance freeze under single-worker 

205#: uvicorn, where Flask's thread-per-request model only cost one thread. 

206#: Below the threshold the parse is sub-10 ms and dispatching it would trade 

207#: that for contention on the same threadpool the DB work uses. 

208_JSON_OFFLOAD_THRESHOLD_BYTES = 64 * 1024 

209 

210 

211async def _parse_json_off_loop(body): 

212 """``json.loads`` without stalling the event loop on a large body. 

213 

214 ``asyncio.to_thread`` rather than ``run_db_sync``: this opens no DB 

215 session, so the session cleanup that wrapper does is unnecessary 

216 (see dependencies/threadpool.py::run_db_sync). 

217 

218 The bytearray is passed through without copying to ``bytes`` -- at the 

219 notes cap that copy would itself be a 100 MB allocation, and nothing 

220 mutates the buffer after this point. 

221 """ 

222 if len(body) < _JSON_OFFLOAD_THRESHOLD_BYTES: 222 ↛ 224line 222 didn't jump to line 224 because the condition on line 222 was always true

223 return json.loads(body) 

224 return await asyncio.to_thread(json.loads, body) 

225 

226 

227async def _notes_json_body( 

228 request: Request, 

229) -> dict[str, Any] | JSONResponse: 

230 """Shared body gate for every mutating notes route. 

231 

232 Replaces the blueprint's two ``before_request`` hooks: 

233 

234 * 413 a body whose ``Content-Length`` exceeds ``_MAX_JSON_BODY_BYTES`` 

235 (bounds pre-parse memory before ``request.json()`` buffers it). 

236 * 400 a declared-JSON body that parses to something other than an 

237 object — ``true`` / ``123`` / ``"x"`` / ``[1, 2]`` would otherwise 

238 slip past the routes' ``... or {}`` idioms and crash on the first 

239 ``.get`` as an opaque 500. 

240 

241 Returns either a ``JSONResponse`` (the caller must return it verbatim) or 

242 the parsed dict. A missing/empty/malformed body becomes ``{}`` so each 

243 route applies its own "no data provided" default exactly as before. 

244 """ 

245 

246 def _too_large(): 

247 return JSONResponse( 

248 { 

249 "success": False, 

250 "error": ( 

251 "Request body too large " 

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

253 ), 

254 }, 

255 status_code=413, 

256 ) 

257 

258 # Fast path: reject a body that DECLARES an over-cap Content-Length before 

259 # reading anything. 

260 content_length = request.headers.get("content-length") 

261 if content_length is not None: 

262 try: 

263 declared = int(content_length) 

264 except (TypeError, ValueError): 

265 declared = None 

266 if declared is not None and declared > _MAX_JSON_BODY_BYTES: 

267 return _too_large() 

268 

269 # Read the body from the stream with a running cap, so a chunked request 

270 # (``Transfer-Encoding: chunked``, no Content-Length) is cut off at the 

271 # notes ceiling BEFORE it is fully buffered — the Flask blueprint got this 

272 # from ``arm_notes_body_cap`` setting ``request.max_content_length`` at the 

273 # stream level; the plain Content-Length check above cannot (a chunked 

274 # body has no Content-Length to inspect). No notes route reads the body 

275 # again after this dependency, so consuming the stream here is safe. 

276 body = bytearray() 

277 async for chunk in request.stream(): 

278 body.extend(chunk) 

279 if len(body) > _MAX_JSON_BODY_BYTES: 

280 return _too_large() 

281 

282 if not body: 

283 # Missing/empty body — leave the route to default as it sees fit 

284 # (matches Flask's ``get_json(silent=True) -> None``). 

285 return {} 

286 try: 

287 data = await _parse_json_off_loop(body) 

288 except Exception: 

289 # Malformed JSON — same "leave the route to default" behavior. 

290 return {} 

291 

292 if not isinstance(data, dict): 

293 return JSONResponse( 

294 { 

295 "success": False, 

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

297 }, 

298 status_code=400, 

299 ) 

300 return data 

301 

302 

303_NotesBody = Annotated[ 

304 dict[str, Any] | JSONResponse, 

305 Depends(_notes_json_body), 

306] 

307 

308 

309def _trigger_note_auto_index(note_id, service, username, session_id): 

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

311 try: 

312 from ...web.routers.rag import trigger_auto_index 

313 from ...database.session_passwords import session_password_store 

314 

315 db_password = session_password_store.get_session_password( 

316 username, session_id 

317 ) 

318 if not db_password: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true

319 logger.debug( 

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

321 note_id, 

322 ) 

323 return 

324 collection_id = service.get_notes_collection_id() 

325 if not collection_id: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true

326 logger.debug( 

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

328 note_id, 

329 ) 

330 return 

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

332 except Exception: 

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

334 

335 

336# ============================================================================ 

337# Page Routes 

338# ============================================================================ 

339 

340 

341@router.get("/") 

342def notes_page( 

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

344): 

345 """Render the notes list page.""" 

346 return templates.TemplateResponse( 

347 request=request, 

348 name="pages/notes.html", 

349 context={"active_page": "notes"}, 

350 ) 

351 

352 

353@router.get("/{note_id}") 

354def note_detail_page( 

355 request: Request, 

356 note_id: str, 

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

358): 

359 """Render the note detail page.""" 

360 return templates.TemplateResponse( 

361 request=request, 

362 name="pages/note_detail.html", 

363 context={"active_page": "notes", "note_id": note_id}, 

364 ) 

365 

366 

367# ============================================================================ 

368# API Routes - Notes CRUD 

369# ============================================================================ 

370 

371 

372@router.get("/api/notes") 

373@_notes_search_limit 

374def list_notes( 

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

376): 

377 """ 

378 List notes with optional filtering. 

379 

380 Query params: 

381 collection_id: Filter by collection 

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

383 pinned_only: Only return pinned notes 

384 limit: Maximum number (default 100) 

385 offset: Offset for pagination (default 0) 

386 """ 

387 try: 

388 service = NoteService(username) 

389 

390 collection_id = request.query_params.get("collection_id") 

391 try: 

392 search = _clamp_text_query( 

393 request.query_params.get("search"), "search" 

394 ) 

395 except ValueError as exc: 

396 return JSONResponse( 

397 {"success": False, "error": str(exc)}, status_code=400 

398 ) 

399 pinned_only = ( 

400 request.query_params.get("pinned_only", "").lower() == "true" 

401 ) 

402 try: 

403 limit = _clamp_limit(request, 100) 

404 offset = max(0, int(request.query_params.get("offset", 0))) 

405 except (ValueError, TypeError): 

406 return JSONResponse( 

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

408 status_code=400, 

409 ) 

410 

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

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

413 # limit. count_notes repeats the filtered scan list_notes runs 

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

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

416 # reveals the total. 

417 if offset == 0: 

418 notes = service.list_notes( 

419 collection_id=collection_id, 

420 search=search, 

421 pinned_only=pinned_only, 

422 limit=limit, 

423 offset=0, 

424 ) 

425 total = ( 

426 len(notes) 

427 if len(notes) < limit 

428 else service.count_notes( 

429 collection_id=collection_id, 

430 search=search, 

431 pinned_only=pinned_only, 

432 ) 

433 ) 

434 else: 

435 total = service.count_notes( 

436 collection_id=collection_id, 

437 search=search, 

438 pinned_only=pinned_only, 

439 ) 

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

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

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

443 # get_note_versions). 

444 if offset > total: 

445 offset = total 

446 

447 notes = service.list_notes( 

448 collection_id=collection_id, 

449 search=search, 

450 pinned_only=pinned_only, 

451 limit=limit, 

452 offset=offset, 

453 ) 

454 

455 return { 

456 "success": True, 

457 "notes": notes, 

458 "total": total, 

459 "limit": limit, 

460 "offset": offset, 

461 } 

462 

463 except Exception as e: 

464 return handle_api_error("listing notes", e) 

465 

466 

467@router.post("/api/notes") 

468@_notes_write_limit 

469def create_note( 

470 request: Request, 

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

472 body: _NotesBody, 

473): 

474 """ 

475 Create a new note. 

476 

477 Body: 

478 title: Note title (required) 

479 content: Note content (required) 

480 tags: List of tags (optional) 

481 collection_ids: List of collection IDs (optional) 

482 """ 

483 if isinstance(body, JSONResponse): 

484 return body 

485 

486 try: 

487 data = body 

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

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

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

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

492 # object. 

493 return JSONResponse( 

494 {"success": False, "error": "No data provided"}, 

495 status_code=400, 

496 ) 

497 

498 title = data.get("title") 

499 content = data.get("content") 

500 

501 if not title: 

502 return JSONResponse( 

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

504 status_code=400, 

505 ) 

506 if not content: 

507 return JSONResponse( 

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

509 status_code=400, 

510 ) 

511 

512 service = NoteService(username) 

513 note_id = service.create_note( 

514 title=title, 

515 content=content, 

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

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

518 ) 

519 

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

521 _trigger_note_auto_index( 

522 note_id, service, username, request.session.get("session_id") 

523 ) 

524 

525 return JSONResponse({"success": True, "id": note_id}, status_code=201) 

526 

527 except ValueError as e: 

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

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

530 return JSONResponse( 

531 {"success": False, "error": str(e)}, status_code=400 

532 ) 

533 except Exception as e: 

534 return handle_api_error("creating note", e) 

535 

536 

537# --------------------------------------------------------------------------- 

538# Literal-path GET routes under /api/notes/... MUST be registered before 

539# GET /api/notes/{note_id} below — Starlette matches routes in registration 

540# order, so otherwise ``/api/notes/semantic-search`` etc. would be captured 

541# by the ``{note_id}`` route. (Flask's URL map gave static rules priority 

542# automatically; FastAPI does not.) 

543# --------------------------------------------------------------------------- 

544 

545 

546@router.get("/api/notes/semantic-search") 

547@_notes_search_limit 

548def semantic_search_notes( 

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

550): 

551 """ 

552 Search notes using semantic similarity. 

553 

554 Finds notes by meaning rather than keyword matching. 

555 

556 Query params: 

557 q: Search query (required) 

558 limit: Maximum results (default 10) 

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

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

561 (matches the text-search filter). 

562 """ 

563 try: 

564 try: 

565 query = _clamp_text_query(request.query_params.get("q", ""), "q") 

566 except ValueError as exc: 

567 return JSONResponse( 

568 {"success": False, "error": str(exc)}, status_code=400 

569 ) 

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

571 if not query: 

572 return JSONResponse( 

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

574 status_code=400, 

575 ) 

576 

577 try: 

578 limit = _clamp_limit(request, 10) 

579 min_similarity_raw = float( 

580 request.query_params.get("min_similarity", 0.3) 

581 ) 

582 min_similarity = max(0.0, min(min_similarity_raw, 1.0)) 

583 except (ValueError, TypeError): 

584 return JSONResponse( 

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

586 status_code=400, 

587 ) 

588 if not math.isfinite(min_similarity_raw): 

589 logger.warning( 

590 "notes_api validation reject: min_similarity={!r} (non-finite) " 

591 "(user={!r})", 

592 min_similarity_raw, 

593 username, 

594 ) 

595 return JSONResponse( 

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

597 status_code=400, 

598 ) 

599 

600 collection_id = request.query_params.get("collection_id") or None 

601 

602 service = NoteAIService(username) 

603 results = service.semantic_search( 

604 query, 

605 limit=limit, 

606 min_similarity=min_similarity, 

607 collection_id=collection_id, 

608 ) 

609 

610 return { 

611 "success": True, 

612 "query": query, 

613 "results": results, 

614 "count": len(results), 

615 } 

616 

617 except Exception as e: 

618 return handle_api_error("semantic search", e) 

619 

620 

621@router.get("/api/notes/ask-context") 

622@_notes_search_limit 

623def ask_context( 

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

625): 

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

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

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

629 """ 

630 try: 

631 service = NoteService(username) 

632 status = service.get_notes_index_status() 

633 return {"success": True, **status} 

634 except Exception as e: 

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

636 

637 

638@router.get("/api/notes/search-for-linking") 

639@_notes_search_limit 

640def search_notes_for_linking( 

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

642): 

643 """ 

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

645 

646 Query params: 

647 query: Search query (partial title match) 

648 exclude_note_id: Note ID to exclude from results 

649 limit: Maximum number of results (default 10) 

650 """ 

651 try: 

652 try: 

653 query = _clamp_text_query( 

654 request.query_params.get("query", ""), "query" 

655 ) 

656 except ValueError as exc: 

657 return JSONResponse( 

658 {"success": False, "error": str(exc)}, status_code=400 

659 ) 

660 if not query: 

661 return {"success": True, "notes": []} 

662 

663 exclude_note_id = request.query_params.get("exclude_note_id") 

664 try: 

665 limit = _clamp_limit(request, 10) 

666 except (ValueError, TypeError): 

667 return JSONResponse( 

668 {"success": False, "error": "Invalid limit"}, status_code=400 

669 ) 

670 

671 service = NoteService(username) 

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

673 

674 return {"success": True, "notes": notes} 

675 

676 except Exception as e: 

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

678 

679 

680@router.get("/api/notes/{note_id}") 

681def get_note( 

682 request: Request, 

683 note_id: str, 

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

685): 

686 """Get a specific note.""" 

687 try: 

688 service = NoteService(username) 

689 note = service.get_note(note_id) 

690 

691 if not note: 

692 return JSONResponse( 

693 {"success": False, "error": "Note not found"}, status_code=404 

694 ) 

695 

696 return {"success": True, "note": note} 

697 

698 except Exception as e: 

699 return handle_api_error("getting note", e) 

700 

701 

702@router.put("/api/notes/{note_id}") 

703@_notes_write_limit 

704def update_note( 

705 request: Request, 

706 note_id: str, 

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

708 body: _NotesBody, 

709): 

710 """ 

711 Update a note. 

712 

713 Body: 

714 title: New title (optional) 

715 content: New content (optional) 

716 tags: New tags (optional) 

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

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

719 """ 

720 if isinstance(body, JSONResponse): 

721 return body 

722 

723 try: 

724 data = body 

725 if not data: 

726 return JSONResponse( 

727 {"success": False, "error": "No data provided"}, 

728 status_code=400, 

729 ) 

730 

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

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

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

734 # raises StatementError at flush → an opaque 500. 

735 pinned = data.get("pinned") 

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

737 return JSONResponse( 

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

739 status_code=400, 

740 ) 

741 

742 service = NoteService(username) 

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

744 try: 

745 success = service.update_note( 

746 note_id=note_id, 

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

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

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

750 favorite=pinned, 

751 ) 

752 except ValueError as e: 

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

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

755 return JSONResponse( 

756 {"success": False, "error": str(e)}, status_code=400 

757 ) 

758 

759 if not success: 

760 return JSONResponse( 

761 {"success": False, "error": "Note not found"}, status_code=404 

762 ) 

763 

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

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

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

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

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

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

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

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

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

773 _trigger_note_auto_index( 

774 note_id, service, username, request.session.get("session_id") 

775 ) 

776 

777 return {"success": True} 

778 

779 except Exception as e: 

780 return handle_api_error("updating note", e) 

781 

782 

783@router.delete("/api/notes/{note_id}") 

784@_notes_write_limit 

785def delete_note( 

786 request: Request, 

787 note_id: str, 

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

789): 

790 """Delete a note.""" 

791 try: 

792 service = NoteService(username) 

793 success = service.delete_note(note_id) 

794 

795 if not success: 

796 return JSONResponse( 

797 {"success": False, "error": "Note not found"}, status_code=404 

798 ) 

799 

800 return {"success": True} 

801 

802 except Exception as e: 

803 return handle_api_error("deleting note", e) 

804 

805 

806# ============================================================================ 

807# API Routes - Collections 

808# ============================================================================ 

809 

810 

811@router.get("/api/notes/{note_id}/collections") 

812def get_note_collections( 

813 request: Request, 

814 note_id: str, 

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

816): 

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

818 try: 

819 service = NoteService(username) 

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

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

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

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

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

825 if not service.note_exists(note_id): 

826 return JSONResponse( 

827 {"success": False, "error": "Note not found"}, status_code=404 

828 ) 

829 collections = service.get_note_collections(note_id) 

830 

831 return {"success": True, "collections": collections} 

832 

833 except Exception as e: 

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

835 

836 

837@router.post("/api/notes/{note_id}/collections") 

838@_notes_write_limit 

839def add_note_to_collection( 

840 request: Request, 

841 note_id: str, 

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

843 body: _NotesBody, 

844): 

845 """ 

846 Add a note to a collection. 

847 

848 Body: 

849 collection_id: The collection ID 

850 """ 

851 if isinstance(body, JSONResponse): 851 ↛ 852line 851 didn't jump to line 852 because the condition on line 851 was never true

852 return body 

853 

854 try: 

855 data = body 

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

857 return JSONResponse( 

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

859 status_code=400, 

860 ) 

861 

862 service = NoteService(username) 

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

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

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

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

867 if not service.note_exists(note_id): 

868 return JSONResponse( 

869 {"success": False, "error": "Note not found"}, status_code=404 

870 ) 

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

872 

873 if not success: 

874 return JSONResponse( 

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

876 status_code=409, 

877 ) 

878 

879 return {"success": True} 

880 

881 except LookupError: 

882 return JSONResponse( 

883 {"success": False, "error": "Collection not found"}, 

884 status_code=404, 

885 ) 

886 except Exception as e: 

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

888 

889 

890@router.delete("/api/notes/{note_id}/collections/{collection_id}") 

891@_notes_write_limit 

892def remove_note_from_collection( 

893 request: Request, 

894 note_id: str, 

895 collection_id: str, 

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

897): 

898 """Remove a note from a collection.""" 

899 try: 

900 service = NoteService(username) 

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

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

903 if not service.note_exists(note_id): 

904 return JSONResponse( 

905 {"success": False, "error": "Note not found"}, status_code=404 

906 ) 

907 success = service.remove_from_collection(note_id, collection_id) 

908 

909 if not success: 

910 return JSONResponse( 

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

912 status_code=404, 

913 ) 

914 

915 return {"success": True} 

916 

917 except ValueError as e: 

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

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

920 return JSONResponse( 

921 {"success": False, "error": str(e)}, status_code=400 

922 ) 

923 except Exception as e: 

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

925 

926 

927# ============================================================================ 

928# API Routes - Research 

929# ============================================================================ 

930 

931 

932@router.get("/api/notes/{note_id}/research") 

933def get_note_research( 

934 request: Request, 

935 note_id: str, 

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

937): 

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

939 try: 

940 service = NoteService(username) 

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

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

943 if not service.note_exists(note_id): 

944 return JSONResponse( 

945 {"success": False, "error": "Note not found"}, status_code=404 

946 ) 

947 research = service.get_note_research(note_id) 

948 

949 return {"success": True, "research": research} 

950 

951 except Exception as e: 

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

953 

954 

955@router.post("/api/notes/{note_id}/research") 

956@_notes_write_limit 

957def link_research_to_note( 

958 request: Request, 

959 note_id: str, 

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

961 body: _NotesBody, 

962): 

963 """ 

964 Link a research run to a note. 

965 

966 Body: 

967 research_id: The research history ID 

968 """ 

969 if isinstance(body, JSONResponse): 969 ↛ 970line 969 didn't jump to line 970 because the condition on line 969 was never true

970 return body 

971 

972 try: 

973 data = body 

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

975 return JSONResponse( 

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

977 status_code=400, 

978 ) 

979 

980 service = NoteService(username) 

981 link_id = service.link_research_to_note( 

982 note_id=note_id, 

983 research_id=data["research_id"], 

984 ) 

985 

986 return JSONResponse({"success": True, "id": link_id}, status_code=201) 

987 

988 except ValueError as e: 

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

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

991 # client-fixable 400. 

992 if "not found" in str(e).lower(): 992 ↛ 996line 992 didn't jump to line 996 because the condition on line 992 was always true

993 return JSONResponse( 

994 {"success": False, "error": str(e)}, status_code=404 

995 ) 

996 return JSONResponse( 

997 {"success": False, "error": str(e)}, status_code=400 

998 ) 

999 except IntegrityError as exc: 

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

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

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

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

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

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

1006 if "foreign key" in msg: 

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

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

1009 return JSONResponse( 

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

1011 status_code=404, 

1012 ) 

1013 if "display_order" in msg: 

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

1015 return JSONResponse( 

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

1017 status_code=409, 

1018 ) 

1019 except Exception as e: 

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

1021 

1022 

1023@router.patch("/api/notes/{note_id}/research/{research_id}") 

1024@_notes_write_limit 

1025def patch_note_research( 

1026 request: Request, 

1027 note_id: str, 

1028 research_id: str, 

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

1030 body: _NotesBody, 

1031): 

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

1033 

1034 Body: 

1035 is_collapsed: bool (optional) 

1036 """ 

1037 if isinstance(body, JSONResponse): 1037 ↛ 1038line 1037 didn't jump to line 1038 because the condition on line 1037 was never true

1038 return body 

1039 

1040 try: 

1041 data = body 

1042 # Omitted: is_collapsed stays None and is passed through (no-op). 

1043 is_collapsed = data.get("is_collapsed") 

1044 if "is_collapsed" in data and not isinstance( 

1045 data["is_collapsed"], bool 

1046 ): 

1047 logger.warning( 

1048 "notes_api validation reject: is_collapsed={} (type={}) " 

1049 "(user={!r})", 

1050 _log_value_preview(data["is_collapsed"]), 

1051 type(data["is_collapsed"]).__name__, 

1052 username, 

1053 ) 

1054 return JSONResponse( 

1055 { 

1056 "success": False, 

1057 "error": "is_collapsed must be a boolean", 

1058 }, 

1059 status_code=400, 

1060 ) 

1061 service = NoteService(username) 

1062 updated = service.update_note_research( 

1063 note_id=note_id, 

1064 research_id=research_id, 

1065 is_collapsed=is_collapsed, 

1066 ) 

1067 if not updated: 

1068 return JSONResponse( 

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

1070 status_code=404, 

1071 ) 

1072 return {"success": True} 

1073 

1074 except Exception as e: 

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

1076 

1077 

1078@router.post("/api/notes/{note_id}/research/reorder") 

1079@_notes_write_limit 

1080def reorder_note_research( 

1081 request: Request, 

1082 note_id: str, 

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

1084 body: _NotesBody, 

1085): 

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

1087 

1088 Body: 

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

1090 """ 

1091 if isinstance(body, JSONResponse): 1091 ↛ 1092line 1091 didn't jump to line 1092 because the condition on line 1091 was never true

1092 return body 

1093 

1094 try: 

1095 data = body 

1096 research_ids = data.get("research_ids") 

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

1098 return JSONResponse( 

1099 { 

1100 "success": False, 

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

1102 }, 

1103 status_code=400, 

1104 ) 

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

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

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

1108 return JSONResponse( 

1109 { 

1110 "success": False, 

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

1112 }, 

1113 status_code=400, 

1114 ) 

1115 

1116 service = NoteService(username) 

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

1118 # every sibling note-scoped route (get_note_research, 

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

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

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

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

1123 if not service.note_exists(note_id): 

1124 return JSONResponse( 

1125 {"success": False, "error": "Note not found"}, status_code=404 

1126 ) 

1127 ok = service.reorder_note_research(note_id, research_ids) 

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

1129 return JSONResponse( 

1130 { 

1131 "success": False, 

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

1133 }, 

1134 status_code=400, 

1135 ) 

1136 return {"success": True} 

1137 

1138 except ValueError as e: 

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

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

1141 return JSONResponse( 

1142 {"success": False, "error": str(e)}, status_code=400 

1143 ) 

1144 except Exception as e: 

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

1146 

1147 

1148# ============================================================================ 

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

1150# ============================================================================ 

1151 

1152 

1153@router.get("/api/research/{research_id}/notes") 

1154@_notes_search_limit 

1155def get_research_notes( 

1156 request: Request, 

1157 research_id: str, 

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

1159): 

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

1161 

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

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

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

1165 """ 

1166 try: 

1167 service = NoteService(username) 

1168 notes = service.get_notes_for_research(research_id) 

1169 if notes is None: 

1170 return JSONResponse( 

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

1172 status_code=404, 

1173 ) 

1174 return {"success": True, "notes": notes} 

1175 

1176 except Exception as e: 

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

1178 

1179 

1180@router.post("/api/research/{research_id}/notes") 

1181@_notes_write_limit 

1182def create_research_note( 

1183 request: Request, 

1184 research_id: str, 

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

1186 body: _NotesBody, 

1187): 

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

1189 

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

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

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

1193 selection) both come through here. 

1194 """ 

1195 if isinstance(body, JSONResponse): 1195 ↛ 1196line 1195 didn't jump to line 1196 because the condition on line 1195 was never true

1196 return body 

1197 

1198 try: 

1199 data = body 

1200 service = NoteService(username) 

1201 try: 

1202 note_id = service.create_note_for_research( 

1203 research_id, 

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

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

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

1207 ) 

1208 except LookupError: 

1209 return JSONResponse( 

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

1211 status_code=404, 

1212 ) 

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

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

1215 _trigger_note_auto_index( 

1216 note_id, service, username, request.session.get("session_id") 

1217 ) 

1218 return JSONResponse( 

1219 {"success": True, "note_id": note_id}, status_code=201 

1220 ) 

1221 

1222 except ValueError as e: 

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

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

1225 return JSONResponse( 

1226 {"success": False, "error": str(e)}, status_code=400 

1227 ) 

1228 except Exception as e: 

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

1230 

1231 

1232@router.post("/api/research/{research_id}/save-as-note") 

1233@_notes_save_as_note_limit 

1234def save_research_as_note( 

1235 request: Request, 

1236 research_id: str, 

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

1238 body: _NotesBody, 

1239): 

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

1241 

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

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

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

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

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

1247 it is later unlinked. 

1248 """ 

1249 if isinstance(body, JSONResponse): 1249 ↛ 1250line 1249 didn't jump to line 1250 because the condition on line 1249 was never true

1250 return body 

1251 

1252 try: 

1253 from ...constants import ResearchStatus 

1254 from ...database.models import ResearchHistory 

1255 from ...database.session_context import get_user_db_session 

1256 from ...storage import get_report_storage 

1257 

1258 with get_user_db_session(username) as db: 

1259 research = ( 

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

1261 ) 

1262 if research is None: 

1263 return JSONResponse( 

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

1265 status_code=404, 

1266 ) 

1267 if research.status != ResearchStatus.COMPLETED: 

1268 return JSONResponse( 

1269 { 

1270 "success": False, 

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

1272 "status": research.status, 

1273 }, 

1274 status_code=409, 

1275 ) 

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

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

1278 

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

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

1281 # here, unlike in a background worker). 

1282 settings_snapshot = meta.get("settings_snapshot") 

1283 with get_user_db_session(username) as db: 

1284 storage = get_report_storage( 

1285 session=db, settings_snapshot=settings_snapshot 

1286 ) 

1287 report = storage.get_report(research_id, username) 

1288 

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

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

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

1292 # success while losing the content. Refuse instead. 

1293 return JSONResponse( 

1294 { 

1295 "success": False, 

1296 "error": ( 

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

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

1299 ), 

1300 }, 

1301 status_code=502, 

1302 ) 

1303 

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

1305 MAX_TITLE_LENGTH, 

1306 ) 

1307 

1308 title = ( 

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

1310 if query_text 

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

1312 ) 

1313 provenance = ( 

1314 f"> Saved from the research run " 

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

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

1317 ) 

1318 

1319 service = NoteService(username) 

1320 try: 

1321 note_id = service.create_note_for_research( 

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

1323 ) 

1324 except LookupError: 

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

1326 # any other missing research. 

1327 return JSONResponse( 

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

1329 status_code=404, 

1330 ) 

1331 _trigger_note_auto_index( 

1332 note_id, service, username, request.session.get("session_id") 

1333 ) 

1334 return JSONResponse( 

1335 {"success": True, "note_id": note_id}, status_code=201 

1336 ) 

1337 

1338 except ValueError as e: 

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

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

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

1342 return JSONResponse( 

1343 {"success": False, "error": str(e)}, status_code=400 

1344 ) 

1345 except Exception as e: 

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

1347 

1348 

1349# ============================================================================ 

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

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

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

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

1354# ============================================================================ 

1355 

1356MAX_ANNOTATION_COMMENT_LEN = 5000 

1357MAX_ANNOTATION_QUOTE_LEN = 1000 

1358MAX_ANNOTATION_CONTEXT_LEN = 100 

1359 

1360 

1361def _validated_annotation_fields(data): 

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

1363 

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

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

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

1367 length checks produce. 

1368 """ 

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

1370 value = data.get(field) 

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

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

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

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

1375 if not comment: 

1376 raise ValueError("comment is required") 

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

1378 raise ValueError("quote is required") 

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

1380 raise ValueError( 

1381 f"comment exceeds maximum length " 

1382 f"({MAX_ANNOTATION_COMMENT_LEN} chars)" 

1383 ) 

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

1385 raise ValueError( 

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

1387 ) 

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

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

1390 return comment, quote, prefix, suffix 

1391 

1392 

1393def _comment_note_title(comment): 

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

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

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

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

1398 return f"Comment: {one_line}" 

1399 

1400 

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

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

1403_ANNOTATION_COMMENT_PREVIEW_CHARS = 300 

1404 

1405 

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

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

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

1409 return { 

1410 "note_id": note_id, 

1411 "quote": quote, 

1412 "prefix": prefix, 

1413 "suffix": suffix, 

1414 "note_title": _comment_note_title(comment), 

1415 "comment_preview": comment[:_ANNOTATION_COMMENT_PREVIEW_CHARS], 

1416 } 

1417 

1418 

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

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

1421 

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

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

1424 provenance link — see ``escape_markdown_link_label``. 

1425 """ 

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

1427 label = escape_markdown_link_label(target_label) 

1428 return ( 

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

1430 ) 

1431 

1432 

1433def _research_exists(username, research_id): 

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

1435 from ...database.models import ResearchHistory 

1436 from ...database.session_context import get_user_db_session 

1437 

1438 with get_user_db_session(username) as db: 

1439 row = ( 

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

1441 .filter_by(id=research_id) 

1442 .first() 

1443 ) 

1444 if row is None: 

1445 return False, None 

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

1447 

1448 

1449@router.get("/api/research/{research_id}/annotations") 

1450@_notes_search_limit 

1451def get_research_annotations( 

1452 request: Request, 

1453 research_id: str, 

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

1455): 

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

1457 try: 

1458 exists, _query = _research_exists(username, research_id) 

1459 if not exists: 

1460 return JSONResponse( 

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

1462 status_code=404, 

1463 ) 

1464 service = NoteService(username) 

1465 annotations = service.get_annotations_for_target( 

1466 research_id=research_id 

1467 ) 

1468 return {"success": True, "annotations": annotations} 

1469 

1470 except Exception as e: 

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

1472 

1473 

1474@router.post("/api/research/{research_id}/annotations") 

1475@_notes_write_limit 

1476def create_research_annotation( 

1477 request: Request, 

1478 research_id: str, 

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

1480 body: _NotesBody, 

1481): 

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

1483 

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

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

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

1487 note_references row. 

1488 """ 

1489 if isinstance(body, JSONResponse): 1489 ↛ 1490line 1489 didn't jump to line 1490 because the condition on line 1489 was never true

1490 return body 

1491 

1492 try: 

1493 data = body 

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

1495 

1496 exists, query_text = _research_exists(username, research_id) 

1497 if not exists: 

1498 return JSONResponse( 

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

1500 status_code=404, 

1501 ) 

1502 

1503 service = NoteService(username) 

1504 note_id = service.create_note_for_research( 

1505 research_id, 

1506 title=_comment_note_title(comment), 

1507 content=_comment_note_content( 

1508 comment, 

1509 quote, 

1510 query_text or research_id, 

1511 f"/results/{research_id}", 

1512 ), 

1513 quote=quote, 

1514 prefix=prefix, 

1515 suffix=suffix, 

1516 ) 

1517 _trigger_note_auto_index( 

1518 note_id, service, username, request.session.get("session_id") 

1519 ) 

1520 return JSONResponse( 

1521 { 

1522 "success": True, 

1523 "annotation": _annotation_response( 

1524 note_id, comment, quote, prefix, suffix 

1525 ), 

1526 }, 

1527 status_code=201, 

1528 ) 

1529 

1530 except LookupError: 

1531 return JSONResponse( 

1532 {"success": False, "error": "Research not found"}, status_code=404 

1533 ) 

1534 except ValueError as e: 

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

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

1537 return JSONResponse( 

1538 {"success": False, "error": str(e)}, status_code=400 

1539 ) 

1540 except Exception as e: 

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

1542 

1543 

1544@router.delete("/api/research/{research_id}/annotations/{note_id}") 

1545@_notes_write_limit 

1546def delete_research_annotation( 

1547 request: Request, 

1548 research_id: str, 

1549 note_id: str, 

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

1551): 

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

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

1554 try: 

1555 service = NoteService(username) 

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

1557 return JSONResponse( 

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

1559 status_code=404, 

1560 ) 

1561 service.delete_note(note_id) 

1562 return {"success": True} 

1563 

1564 except Exception as e: 

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

1566 

1567 

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

1569 

1570 

1571@router.get("/api/documents/{document_id}/notes") 

1572@_notes_search_limit 

1573def get_document_notes( 

1574 request: Request, 

1575 document_id: str, 

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

1577): 

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

1579 try: 

1580 service = NoteService(username) 

1581 notes = service.get_notes_for_document(document_id) 

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

1583 return JSONResponse( 

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

1585 status_code=404, 

1586 ) 

1587 return {"success": True, "notes": notes} 

1588 

1589 except Exception as e: 

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

1591 

1592 

1593@router.post("/api/documents/{document_id}/notes") 

1594@_notes_write_limit 

1595def create_document_note( 

1596 request: Request, 

1597 document_id: str, 

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

1599 body: _NotesBody, 

1600): 

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

1602 

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

1604 produce a starter note titled after the document. 

1605 """ 

1606 if isinstance(body, JSONResponse): 1606 ↛ 1607line 1606 didn't jump to line 1607 because the condition on line 1606 was never true

1607 return body 

1608 

1609 try: 

1610 data = body 

1611 service = NoteService(username) 

1612 try: 

1613 note_id = service.create_note_for_document( 

1614 document_id, 

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

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

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

1618 ) 

1619 except LookupError: 

1620 return JSONResponse( 

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

1622 status_code=404, 

1623 ) 

1624 _trigger_note_auto_index( 

1625 note_id, service, username, request.session.get("session_id") 

1626 ) 

1627 return JSONResponse( 

1628 {"success": True, "note_id": note_id}, status_code=201 

1629 ) 

1630 

1631 except ValueError as e: 

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

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

1634 return JSONResponse( 

1635 {"success": False, "error": str(e)}, status_code=400 

1636 ) 

1637 except Exception as e: 

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

1639 

1640 

1641@router.get("/api/documents/{document_id}/annotations") 

1642@_notes_search_limit 

1643def get_document_annotations( 

1644 request: Request, 

1645 document_id: str, 

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

1647): 

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

1649 try: 

1650 service = NoteService(username) 

1651 # Existence gate doubles as the 404 (get_notes_for_document 

1652 # checks the Document row). 

1653 if service.get_notes_for_document(document_id) is None: 

1654 return JSONResponse( 

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

1656 status_code=404, 

1657 ) 

1658 annotations = service.get_annotations_for_target( 

1659 document_id=document_id 

1660 ) 

1661 return {"success": True, "annotations": annotations} 

1662 

1663 except Exception as e: 

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

1665 

1666 

1667@router.post("/api/documents/{document_id}/annotations") 

1668@_notes_write_limit 

1669def create_document_annotation( 

1670 request: Request, 

1671 document_id: str, 

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

1673 body: _NotesBody, 

1674): 

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

1676 if isinstance(body, JSONResponse): 1676 ↛ 1677line 1676 didn't jump to line 1677 because the condition on line 1676 was never true

1677 return body 

1678 

1679 try: 

1680 data = body 

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

1682 

1683 from ...database.models import Document 

1684 from ...database.session_context import get_user_db_session 

1685 

1686 with get_user_db_session(username) as db: 

1687 row = ( 

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

1689 .filter_by(id=document_id) 

1690 .first() 

1691 ) 

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

1693 return JSONResponse( 

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

1695 status_code=404, 

1696 ) 

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

1698 

1699 service = NoteService(username) 

1700 note_id = service.create_note_for_document( 

1701 document_id, 

1702 title=_comment_note_title(comment), 

1703 content=_comment_note_content( 

1704 comment, 

1705 quote, 

1706 doc_title, 

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

1708 ), 

1709 quote=quote, 

1710 prefix=prefix, 

1711 suffix=suffix, 

1712 ) 

1713 _trigger_note_auto_index( 

1714 note_id, service, username, request.session.get("session_id") 

1715 ) 

1716 return JSONResponse( 

1717 { 

1718 "success": True, 

1719 "annotation": _annotation_response( 

1720 note_id, comment, quote, prefix, suffix 

1721 ), 

1722 }, 

1723 status_code=201, 

1724 ) 

1725 

1726 except LookupError: 

1727 return JSONResponse( 

1728 {"success": False, "error": "Document not found"}, status_code=404 

1729 ) 

1730 except ValueError as e: 

1731 return JSONResponse( 

1732 {"success": False, "error": str(e)}, status_code=400 

1733 ) 

1734 except Exception as e: 

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

1736 

1737 

1738@router.delete("/api/documents/{document_id}/annotations/{note_id}") 

1739@_notes_write_limit 

1740def delete_document_annotation( 

1741 request: Request, 

1742 document_id: str, 

1743 note_id: str, 

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

1745): 

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

1747 anchor row cascades).""" 

1748 try: 

1749 service = NoteService(username) 

1750 if not service.has_annotation(note_id, document_id=document_id): 

1751 return JSONResponse( 

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

1753 status_code=404, 

1754 ) 

1755 service.delete_note(note_id) 

1756 return {"success": True} 

1757 

1758 except Exception as e: 

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

1760 

1761 

1762# ============================================================================ 

1763# API Routes - RAG Indexing 

1764# ============================================================================ 

1765 

1766 

1767@router.post("/api/notes/{note_id}/index") 

1768@_notes_write_limit 

1769def index_note_to_collection( 

1770 request: Request, 

1771 note_id: str, 

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

1773 body: _NotesBody, 

1774): 

1775 """ 

1776 Index a note into a collection for RAG search. 

1777 

1778 Body: 

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

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

1781 """ 

1782 from ...web.routers.rag import get_rag_service 

1783 

1784 if isinstance(body, JSONResponse): 1784 ↛ 1785line 1784 didn't jump to line 1785 because the condition on line 1784 was never true

1785 return body 

1786 

1787 try: 

1788 data = body 

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

1790 return JSONResponse( 

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

1792 status_code=400, 

1793 ) 

1794 

1795 collection_id = data["collection_id"] 

1796 # Omitted: defaults to False (do not force-reindex). 

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

1798 if not isinstance(force_reindex, bool): 

1799 logger.warning( 

1800 "notes_api validation reject: force_reindex={} (type={}) " 

1801 "(user={!r})", 

1802 _log_value_preview(force_reindex), 

1803 type(force_reindex).__name__, 

1804 username, 

1805 ) 

1806 return JSONResponse( 

1807 { 

1808 "success": False, 

1809 "error": "force_reindex must be a boolean", 

1810 }, 

1811 status_code=400, 

1812 ) 

1813 

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

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

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

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

1818 return JSONResponse( 

1819 {"success": False, "error": "Note not found"}, status_code=404 

1820 ) 

1821 

1822 # Get RAG service configured for the collection 

1823 rag_service = get_rag_service(request, username, collection_id) 

1824 

1825 # Index the note (notes are now documents) 

1826 result = rag_service.index_document( 

1827 document_id=note_id, 

1828 collection_id=collection_id, 

1829 force_reindex=force_reindex, 

1830 ) 

1831 

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

1833 return JSONResponse( 

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

1835 status_code=400, 

1836 ) 

1837 

1838 return {"success": True, "result": result} 

1839 

1840 except Exception as e: 

1841 return handle_api_error("indexing note", e) 

1842 

1843 

1844# ============================================================================ 

1845# API Routes - AI Features 

1846# (semantic_search_notes is registered above, before GET /api/notes/{note_id}) 

1847# ============================================================================ 

1848 

1849 

1850@router.get("/api/notes/{note_id}/similar") 

1851@_notes_search_limit 

1852def get_similar_notes( 

1853 request: Request, 

1854 note_id: str, 

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

1856): 

1857 """ 

1858 Find notes similar to the given note using embeddings. 

1859 

1860 Query params: 

1861 limit: Maximum number of results (default 5) 

1862 """ 

1863 try: 

1864 try: 

1865 limit = _clamp_limit(request, 5) 

1866 except (ValueError, TypeError): 

1867 return JSONResponse( 

1868 {"success": False, "error": "Invalid limit"}, status_code=400 

1869 ) 

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

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

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

1873 # as get_note_collections / get_note_research. 

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

1875 return JSONResponse( 

1876 {"success": False, "error": "Note not found"}, status_code=404 

1877 ) 

1878 service = NoteAIService(username) 

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

1880 

1881 return {"success": True, "similar_notes": similar} 

1882 

1883 except Exception as e: 

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

1885 

1886 

1887@router.post("/api/notes/{note_id}/summarize") 

1888@_notes_ai_limit 

1889def summarize_note( 

1890 request: Request, 

1891 note_id: str, 

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

1893 body: _NotesBody, 

1894): 

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

1896 if isinstance(body, JSONResponse): 1896 ↛ 1897line 1896 didn't jump to line 1897 because the condition on line 1896 was never true

1897 return body 

1898 

1899 try: 

1900 service = NoteAIService(username) 

1901 summary = service.summarize_note(note_id) 

1902 

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

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

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

1906 if summary is None: 

1907 return JSONResponse( 

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

1909 status_code=404, 

1910 ) 

1911 

1912 return {"success": True, "summary": summary} 

1913 

1914 except Exception as e: 

1915 return handle_api_error("summarizing note", e) 

1916 

1917 

1918@router.post("/api/notes/{note_id}/research-questions") 

1919@_notes_ai_limit 

1920def extract_research_questions( 

1921 request: Request, 

1922 note_id: str, 

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

1924 body: _NotesBody, 

1925): 

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

1927 if isinstance(body, JSONResponse): 1927 ↛ 1928line 1927 didn't jump to line 1928 because the condition on line 1927 was never true

1928 return body 

1929 

1930 try: 

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

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

1933 return JSONResponse( 

1934 {"success": False, "error": "Note not found"}, status_code=404 

1935 ) 

1936 service = NoteAIService(username) 

1937 questions = service.extract_research_questions(note_id) 

1938 

1939 return {"success": True, "questions": questions} 

1940 

1941 except Exception as e: 

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

1943 

1944 

1945@router.post("/api/notes/suggest-tags") 

1946@_notes_ai_limit 

1947def suggest_tags( 

1948 request: Request, 

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

1950 body: _NotesBody, 

1951): 

1952 """ 

1953 Suggest tags for note content. 

1954 

1955 Body: 

1956 content: The note content to analyze 

1957 existing_tags: Current tags to avoid duplicates (optional) 

1958 """ 

1959 if isinstance(body, JSONResponse): 1959 ↛ 1960line 1959 didn't jump to line 1960 because the condition on line 1959 was never true

1960 return body 

1961 

1962 try: 

1963 data = body 

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

1965 return JSONResponse( 

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

1967 status_code=400, 

1968 ) 

1969 

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

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

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

1973 _assert_content_size(data["content"]) 

1974 

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

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

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

1978 existing_tags = data.get("existing_tags") 

1979 if existing_tags is not None and ( 

1980 not isinstance(existing_tags, list) 

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

1982 ): 

1983 return JSONResponse( 

1984 { 

1985 "success": False, 

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

1987 }, 

1988 status_code=400, 

1989 ) 

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

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

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

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

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

1995 if existing_tags is not None and ( 

1996 len(existing_tags) > MAX_TAGS_PER_NOTE 

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

1998 ): 

1999 return JSONResponse( 

2000 { 

2001 "success": False, 

2002 "error": ( 

2003 f"existing_tags exceeds limits (max " 

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

2005 ), 

2006 }, 

2007 status_code=400, 

2008 ) 

2009 

2010 service = NoteAIService(username) 

2011 tags = service.suggest_tags( 

2012 content=data["content"], 

2013 existing_tags=existing_tags, 

2014 ) 

2015 

2016 return {"success": True, "tags": tags} 

2017 

2018 except ValueError as e: 

2019 return JSONResponse( 

2020 {"success": False, "error": str(e)}, status_code=400 

2021 ) 

2022 except Exception as e: 

2023 return handle_api_error("suggesting tags", e) 

2024 

2025 

2026@router.post("/api/notes/{note_id}/key-concepts") 

2027@_notes_ai_limit 

2028def extract_key_concepts( 

2029 request: Request, 

2030 note_id: str, 

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

2032 body: _NotesBody, 

2033): 

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

2035 if isinstance(body, JSONResponse): 2035 ↛ 2036line 2035 didn't jump to line 2036 because the condition on line 2035 was never true

2036 return body 

2037 

2038 try: 

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

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

2041 return JSONResponse( 

2042 {"success": False, "error": "Note not found"}, status_code=404 

2043 ) 

2044 service = NoteAIService(username) 

2045 concepts = service.extract_key_concepts(note_id) 

2046 

2047 return {"success": True, "concepts": concepts} 

2048 

2049 except Exception as e: 

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

2051 

2052 

2053# ============================================================================ 

2054# API Routes - Smart Note Linking 

2055# ============================================================================ 

2056 

2057 

2058@router.get("/api/notes/{note_id}/backlinks") 

2059@_notes_search_limit 

2060def get_backlinks( 

2061 request: Request, 

2062 note_id: str, 

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

2064): 

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

2066 try: 

2067 service = NoteService(username) 

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

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

2070 if not service.note_exists(note_id): 

2071 return JSONResponse( 

2072 {"success": False, "error": "Note not found"}, status_code=404 

2073 ) 

2074 backlinks = service.get_backlinks(note_id) 

2075 

2076 return { 

2077 "success": True, 

2078 "backlinks": backlinks, 

2079 "total": len(backlinks), 

2080 } 

2081 

2082 except Exception as e: 

2083 return handle_api_error("getting backlinks", e) 

2084 

2085 

2086@router.get("/api/notes/{note_id}/outgoing-links") 

2087@_notes_search_limit 

2088def get_outgoing_links( 

2089 request: Request, 

2090 note_id: str, 

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

2092): 

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

2094 try: 

2095 service = NoteService(username) 

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

2097 if not service.note_exists(note_id): 

2098 return JSONResponse( 

2099 {"success": False, "error": "Note not found"}, status_code=404 

2100 ) 

2101 links = service.get_outgoing_links(note_id) 

2102 

2103 return { 

2104 "success": True, 

2105 "links": links, 

2106 "total": len(links), 

2107 } 

2108 

2109 except Exception as e: 

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

2111 

2112 

2113@router.get("/api/notes/{note_id}/suggested-links") 

2114@_notes_search_limit 

2115def get_suggested_links( 

2116 request: Request, 

2117 note_id: str, 

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

2119): 

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

2121 try: 

2122 try: 

2123 limit = _clamp_limit(request, 5) 

2124 except (ValueError, TypeError): 

2125 return JSONResponse( 

2126 {"success": False, "error": "Invalid limit"}, status_code=400 

2127 ) 

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

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

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

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

2132 return JSONResponse( 

2133 {"success": False, "error": "Note not found"}, status_code=404 

2134 ) 

2135 service = NoteAIService(username) 

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

2137 

2138 return {"success": True, "suggestions": suggestions} 

2139 

2140 except Exception as e: 

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

2142 

2143 

2144@router.post("/api/notes/{note_id}/accept-link") 

2145@_notes_write_limit 

2146def accept_suggested_link( 

2147 request: Request, 

2148 note_id: str, 

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

2150 body: _NotesBody, 

2151): 

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

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

2154 

2155 Body: 

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

2157 """ 

2158 if isinstance(body, JSONResponse): 2158 ↛ 2159line 2158 didn't jump to line 2159 because the condition on line 2158 was never true

2159 return body 

2160 

2161 try: 

2162 data = body 

2163 target_note_id = data.get("target_note_id") 

2164 if not target_note_id: 

2165 return JSONResponse( 

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

2167 status_code=400, 

2168 ) 

2169 

2170 service = NoteService(username) 

2171 result = service.accept_suggested_link(note_id, target_note_id) 

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

2173 return JSONResponse( 

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

2175 status_code=404, 

2176 ) 

2177 

2178 return {"success": True, "note": result} 

2179 

2180 except ValueError as e: 

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

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

2183 # not the generic 500 handle_api_error would otherwise return. 

2184 return JSONResponse( 

2185 {"success": False, "error": str(e)}, status_code=400 

2186 ) 

2187 except Exception as e: 

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

2189 

2190 

2191@router.get("/api/notes/{note_id}/unlinked-mentions") 

2192@_notes_search_limit 

2193def get_unlinked_mentions( 

2194 request: Request, 

2195 note_id: str, 

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

2197): 

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

2199 

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

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

2202 """ 

2203 try: 

2204 service = NoteService(username) 

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

2206 if not service.note_exists(note_id): 

2207 return JSONResponse( 

2208 {"success": False, "error": "Note not found"}, status_code=404 

2209 ) 

2210 mentions = service.get_unlinked_mentions(note_id) 

2211 return {"success": True, "mentions": mentions} 

2212 except Exception as e: 

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

2214 

2215 

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

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

2218MAX_PASSAGE_LEN = 2000 

2219 

2220 

2221@router.post("/api/notes/{note_id}/similar-passages") 

2222@_notes_search_limit 

2223def similar_passages( 

2224 request: Request, 

2225 note_id: str, 

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

2227 body: _NotesBody, 

2228): 

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

2230 

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

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

2233 """ 

2234 if isinstance(body, JSONResponse): 2234 ↛ 2235line 2234 didn't jump to line 2235 because the condition on line 2234 was never true

2235 return body 

2236 

2237 try: 

2238 data = body 

2239 text = data.get("text") 

2240 if not text or not isinstance(text, str) or not text.strip(): 

2241 return JSONResponse( 

2242 {"success": False, "error": "text required"}, status_code=400 

2243 ) 

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

2245 

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

2247 logger.warning( 

2248 "notes_api validation reject: similar-passages note_id={} " 

2249 "not found (user={!r})", 

2250 _log_value_preview(note_id), 

2251 username, 

2252 ) 

2253 return JSONResponse( 

2254 {"success": False, "error": "Note not found"}, status_code=404 

2255 ) 

2256 

2257 service = NoteAIService(username) 

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

2259 return {"success": True, "passages": passages} 

2260 except Exception as e: 

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

2262 

2263 

2264# ============================================================================ 

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

2266# (ask_context is registered above, before GET /api/notes/{note_id}) 

2267# ============================================================================ 

2268 

2269 

2270# ============================================================================ 

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

2272# ============================================================================ 

2273 

2274 

2275@router.post("/api/notes/{note_id}/fact-check") 

2276@_notes_factcheck_limit 

2277def fact_check_note( 

2278 request: Request, 

2279 note_id: str, 

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

2281 body: _NotesBody, 

2282): 

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

2284 query for verifying them. 

2285 

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

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

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

2289 """ 

2290 if isinstance(body, JSONResponse): 2290 ↛ 2291line 2290 didn't jump to line 2291 because the condition on line 2290 was never true

2291 return body 

2292 

2293 try: 

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

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

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

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

2298 return JSONResponse( 

2299 {"success": False, "error": "Note not found"}, status_code=404 

2300 ) 

2301 

2302 ai = NoteAIService(username) 

2303 claims = ai.extract_claims(note_id) 

2304 if not claims: 

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

2306 return JSONResponse( 

2307 { 

2308 "success": False, 

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

2310 }, 

2311 status_code=422, 

2312 ) 

2313 query = ai.synthesize_factcheck_query(claims) 

2314 return {"success": True, "claims": claims, "query": query} 

2315 

2316 except Exception as e: 

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

2318 

2319 

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

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

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

2323 

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

2325 can be unit-tested without a client. Runs entirely in the request thread, 

2326 where the per-user DB password is available, so the LLM grade and report 

2327 fetch work without the encrypted-DB background-worker problem. 

2328 """ 

2329 from ...constants import ResearchStatus 

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

2331 from ...database.session_context import get_user_db_session 

2332 from ...storage import get_report_storage 

2333 from ..services.report_assembly_service import ( 

2334 get_research_source_links_batch, 

2335 ) 

2336 

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

2338 # require it to have completed before grading. 

2339 with get_user_db_session(username) as db: 

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

2341 if not research: 

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

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

2344 # client could grade an arbitrary completed research against an 

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

2346 linked = ( 

2347 db.query(NoteResearch) 

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

2349 .first() 

2350 ) 

2351 if not linked: 

2352 return { 

2353 "success": False, 

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

2355 }, 404 

2356 status = research.status 

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

2358 

2359 if status != ResearchStatus.COMPLETED: 

2360 return { 

2361 "success": False, 

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

2363 "status": status, 

2364 }, 409 

2365 

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

2367 settings_snapshot = meta.get("settings_snapshot") 

2368 with get_user_db_session(username) as db: 

2369 storage = get_report_storage( 

2370 session=db, settings_snapshot=settings_snapshot 

2371 ) 

2372 report = storage.get_report(research_id, username) 

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

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

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

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

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

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

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

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

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

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

2383 sources = get_research_source_links_batch( 

2384 [research_id], db, limit=_MAX_GRADE_SOURCES 

2385 ).get(research_id, []) 

2386 

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

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

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

2390 # Refuse to grade instead. 

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

2392 return { 

2393 "success": False, 

2394 "error": ( 

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

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

2397 ), 

2398 }, 502 

2399 

2400 ai = NoteAIService(username) 

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

2402 

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

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

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

2406 with get_user_db_session(username) as db: 

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

2408 if research is None: 

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

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

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

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

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

2414 # the conflict explicitly instead. 

2415 return { 

2416 "success": False, 

2417 "error": ( 

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

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

2420 ), 

2421 }, 409 

2422 try: 

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

2424 new_meta["fact_check"] = { 

2425 "note_id": note_id, 

2426 "claims": claims, 

2427 "verdicts": verdicts, 

2428 } 

2429 research.research_meta = new_meta 

2430 db.commit() 

2431 except Exception: 

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

2433 db.rollback() 

2434 raise 

2435 

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

2437 

2438 

2439@router.post("/api/notes/{note_id}/fact-check/{research_id}/grade") 

2440@_notes_factcheck_limit 

2441def grade_note_fact_check( 

2442 request: Request, 

2443 note_id: str, 

2444 research_id: str, 

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

2446 body: _NotesBody, 

2447): 

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

2449 

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

2451 """ 

2452 if isinstance(body, JSONResponse): 2452 ↛ 2453line 2452 didn't jump to line 2453 because the condition on line 2452 was never true

2453 return body 

2454 

2455 try: 

2456 data = body 

2457 raw_claims = data.get("claims") 

2458 if not isinstance(raw_claims, list): 

2459 return JSONResponse( 

2460 {"success": False, "error": "claims required"}, status_code=400 

2461 ) 

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

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

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

2465 claims = [ 

2466 c.strip()[:MAX_CLAIM_LEN] 

2467 for c in raw_claims 

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

2469 ][: NoteAIService.MAX_CLAIMS_PER_NOTE] 

2470 if not claims: 

2471 return JSONResponse( 

2472 {"success": False, "error": "claims required"}, status_code=400 

2473 ) 

2474 

2475 payload, status_code = _grade_note_claims( 

2476 username, note_id, research_id, claims 

2477 ) 

2478 return JSONResponse(payload, status_code=status_code) 

2479 

2480 except Exception as e: 

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

2482 

2483 

2484@router.post("/api/notes/resolve-link") 

2485@_notes_search_limit 

2486def resolve_link( 

2487 request: Request, 

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

2489 body: _NotesBody, 

2490): 

2491 """ 

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

2493 

2494 Body: 

2495 link_text: The text inside [[brackets]] 

2496 """ 

2497 if isinstance(body, JSONResponse): 2497 ↛ 2498line 2497 didn't jump to line 2498 because the condition on line 2497 was never true

2498 return body 

2499 

2500 try: 

2501 data = body 

2502 if not data or not data.get("link_text"): 

2503 return JSONResponse( 

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

2505 status_code=400, 

2506 ) 

2507 try: 

2508 link_text = _clamp_text_query( 

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

2510 ) 

2511 except ValueError as exc: 

2512 return JSONResponse( 

2513 {"success": False, "error": str(exc)}, status_code=400 

2514 ) 

2515 

2516 service = NoteService(username) 

2517 result = service.resolve_link(link_text) 

2518 

2519 if result: 

2520 return {"success": True, "note": result} 

2521 return JSONResponse( 

2522 {"success": False, "error": "Note not found"}, status_code=404 

2523 ) 

2524 

2525 except Exception as e: 

2526 return handle_api_error("resolving link", e) 

2527 

2528 

2529# ============================================================================ 

2530# API Routes - Note Synthesis 

2531# ============================================================================ 

2532 

2533 

2534@router.post("/api/notes/synthesize/preview") 

2535@_notes_synthesize_limit 

2536def preview_synthesis( 

2537 request: Request, 

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

2539 body: _NotesBody, 

2540): 

2541 """ 

2542 Preview what a synthesis would produce. 

2543 

2544 Body: 

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

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

2547 """ 

2548 if isinstance(body, JSONResponse): 2548 ↛ 2549line 2548 didn't jump to line 2549 because the condition on line 2548 was never true

2549 return body 

2550 

2551 try: 

2552 data = body 

2553 if not data or not data.get("note_ids"): 

2554 return JSONResponse( 

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

2556 status_code=400, 

2557 ) 

2558 

2559 note_ids = data["note_ids"] 

2560 if not isinstance(note_ids, list) or not all( 

2561 isinstance(n, str) for n in note_ids 

2562 ): 

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

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

2565 return JSONResponse( 

2566 { 

2567 "success": False, 

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

2569 }, 

2570 status_code=400, 

2571 ) 

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

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

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

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

2576 if not isinstance(synthesis_type, str): 

2577 return JSONResponse( 

2578 { 

2579 "success": False, 

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

2581 }, 

2582 status_code=400, 

2583 ) 

2584 

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

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

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

2588 service = NoteAIService(username) 

2589 preview = service.synthesize_notes(note_ids, synthesis_type) 

2590 

2591 if "error" in preview: 

2592 return JSONResponse( 

2593 {"success": False, "error": preview["error"]}, 

2594 status_code=400, 

2595 ) 

2596 

2597 return {"success": True, "result": preview} 

2598 

2599 except Exception as e: 

2600 return handle_api_error("previewing synthesis", e) 

2601 

2602 

2603@router.post("/api/notes/synthesize") 

2604@_notes_synthesize_limit 

2605def synthesize_notes( 

2606 request: Request, 

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

2608 body: _NotesBody, 

2609): 

2610 """ 

2611 Synthesize multiple notes into one. 

2612 

2613 Body: 

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

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

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

2617 """ 

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

2619 

2620 if isinstance(body, JSONResponse): 2620 ↛ 2621line 2620 didn't jump to line 2621 because the condition on line 2620 was never true

2621 return body 

2622 

2623 try: 

2624 data = body 

2625 if not data or not data.get("note_ids"): 

2626 return JSONResponse( 

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

2628 status_code=400, 

2629 ) 

2630 

2631 note_ids = data["note_ids"] 

2632 if not isinstance(note_ids, list) or not all( 

2633 isinstance(n, str) for n in note_ids 

2634 ): 

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

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

2637 return JSONResponse( 

2638 { 

2639 "success": False, 

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

2641 }, 

2642 status_code=400, 

2643 ) 

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

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

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

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

2648 if not isinstance(synthesis_type, str): 

2649 return JSONResponse( 

2650 { 

2651 "success": False, 

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

2653 }, 

2654 status_code=400, 

2655 ) 

2656 # Omitted: defaults to True (persist synthesis as a new note). 

2657 create_note = data.get("create_note", True) 

2658 if not isinstance(create_note, bool): 

2659 logger.warning( 

2660 "notes_api validation reject: create_note={} (type={}) " 

2661 "(user={!r})", 

2662 _log_value_preview(create_note), 

2663 type(create_note).__name__, 

2664 username, 

2665 ) 

2666 return JSONResponse( 

2667 { 

2668 "success": False, 

2669 "error": "create_note must be a boolean", 

2670 }, 

2671 status_code=400, 

2672 ) 

2673 

2674 ai_service = NoteAIService(username) 

2675 result = ai_service.synthesize_notes(note_ids, synthesis_type) 

2676 

2677 if "error" in result: 

2678 return JSONResponse( 

2679 {"success": False, "error": result["error"]}, 

2680 status_code=400, 

2681 ) 

2682 

2683 # Create the note if requested 

2684 if create_note: 

2685 note_service = NoteService(username) 

2686 new_note_id = note_service.create_note( 

2687 title=result["suggested_title"], 

2688 content=result["content"], 

2689 tags=["synthesized", synthesis_type], 

2690 ) 

2691 

2692 # Record the synthesis. Use the FILTERED source set the AI 

2693 # service actually fed to the LLM (result["source_notes"]) 

2694 # rather than the raw client input — NoteAIService.synthesize_notes 

2695 # silently drops any non-note Document IDs from the prompt, so 

2696 # persisting raw note_ids would (a) record IDs the LLM never 

2697 # saw as "sources" and (b) FK-violate on any bogus ID after 

2698 # create_note has already committed. 

2699 from ...database.session_context import get_user_db_session 

2700 

2701 filtered_source_ids = [ 

2702 src["id"] 

2703 for src in result.get("source_notes", []) 

2704 if "id" in src 

2705 ] 

2706 

2707 db_session = None 

2708 try: 

2709 with get_user_db_session(username) as db_session: 

2710 synthesis_record = NoteSynthesis( 

2711 result_document_id=new_note_id, 

2712 synthesis_type=synthesis_type, 

2713 ) 

2714 for doc_id in filtered_source_ids: 

2715 synthesis_record.sources.append( 

2716 NoteSynthesisSource(source_document_id=doc_id) 

2717 ) 

2718 db_session.add(synthesis_record) 

2719 db_session.commit() 

2720 except Exception: 

2721 # create_note() already committed the new Document in its own 

2722 # session, so a failure here would leave an orphaned 

2723 # "synthesized" note with no provenance record. Roll back the 

2724 # poisoned shared session and delete the just-created note 

2725 # before surfacing the error. 

2726 logger.exception( 

2727 "Failed to persist synthesis record for note {}; " 

2728 "deleting the orphaned note", 

2729 new_note_id, 

2730 ) 

2731 NoteService._rollback_quietly(db_session) 

2732 try: 

2733 note_service.delete_note(new_note_id) 

2734 except Exception: 

2735 logger.exception( 

2736 "Failed to delete orphaned synthesized note {}", 

2737 new_note_id, 

2738 ) 

2739 raise 

2740 

2741 result["note_id"] = new_note_id 

2742 

2743 status_code = 201 if create_note else 200 

2744 return JSONResponse( 

2745 {"success": True, "result": result}, status_code=status_code 

2746 ) 

2747 

2748 except Exception as e: 

2749 return handle_api_error("synthesizing notes", e) 

2750 

2751 

2752# ============================================================================ 

2753# API Routes - Version History 

2754# ============================================================================ 

2755 

2756 

2757@router.get("/api/notes/{note_id}/versions") 

2758def get_note_versions( 

2759 request: Request, 

2760 note_id: str, 

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

2762): 

2763 """ 

2764 Get version history for a note. 

2765 

2766 Query params: 

2767 limit: Maximum number of versions to return (default 20, max 200) 

2768 offset: Number of versions to skip (default 0). Combined with 

2769 limit, this makes older versions reachable when total > limit. 

2770 Pre-fix the route had no offset support, so versions 1 

2771 through (total - limit) were unreachable through the UI. 

2772 """ 

2773 from ...database.models import Document, NoteVersion 

2774 from ...database.session_context import get_user_db_session 

2775 

2776 note_service = NoteService(username) 

2777 

2778 try: 

2779 try: 

2780 limit = _clamp_limit(request, 20) 

2781 except (ValueError, TypeError): 

2782 return JSONResponse( 

2783 {"success": False, "error": "Invalid limit"}, status_code=400 

2784 ) 

2785 

2786 try: 

2787 offset = max(0, int(request.query_params.get("offset", 0))) 

2788 except (ValueError, TypeError): 

2789 return JSONResponse( 

2790 {"success": False, "error": "Invalid offset"}, status_code=400 

2791 ) 

2792 

2793 with get_user_db_session(username) as db_session: 

2794 # Defense-in-depth: confirm the document is a note before 

2795 # serving anything from its version history. Mirrors the 

2796 # guard on the sibling version routes (get_note_version, 

2797 # get_semantic_diff, restore_note_version). load_only keeps 

2798 # the guard from hydrating text_content (up to 50 MB) just 

2799 # to check the source type. 

2800 doc = ( 

2801 db_session.query(Document) 

2802 .options(load_only(Document.id, Document.source_type_id)) 

2803 .filter_by(id=note_id) 

2804 .first() 

2805 ) 

2806 if not doc or not note_service._is_note(db_session, doc): 

2807 return JSONResponse( 

2808 {"success": False, "error": "Note not found"}, 

2809 status_code=404, 

2810 ) 

2811 

2812 total = ( 

2813 db_session.query(NoteVersion) 

2814 .filter_by(document_id=note_id) 

2815 .count() 

2816 ) 

2817 # Cap offset at total: an unbounded OFFSET (e.g. 

2818 # ?offset=1_000_000_000) forces SQLite to row-walk the 

2819 # whole table in a backward index scan even though the 

2820 # result is empty. Clamping is cheap and harmless — a real 

2821 # caller with offset > total wants the empty page anyway. 

2822 if offset > total: 2822 ↛ 2823line 2822 didn't jump to line 2823 because the condition on line 2822 was never true

2823 offset = total 

2824 # Column projection: each NoteVersion row carries a full 

2825 # content snapshot (up to the 50 MB cap); loading whole 

2826 # entities transferred `limit` note bodies per call to render 

2827 # a metadata-only list. 

2828 versions = ( 

2829 db_session.query( 

2830 NoteVersion.id, 

2831 NoteVersion.title, 

2832 NoteVersion.change_type, 

2833 NoteVersion.change_summary, 

2834 NoteVersion.created_at, 

2835 ) 

2836 .filter_by(document_id=note_id) 

2837 .order_by( 

2838 NoteVersion.created_at.desc(), 

2839 NoteVersion.id.desc(), # id tiebreaker for stable paging 

2840 ) 

2841 .offset(offset) 

2842 .limit(limit) 

2843 .all() 

2844 ) 

2845 return { 

2846 "success": True, 

2847 "total": total, 

2848 "limit": limit, 

2849 "offset": offset, 

2850 "versions": [ 

2851 { 

2852 "id": v.id, 

2853 # Global version index — newest is `total`, 

2854 # oldest is 1. Stable across pages. 

2855 "version_number": total - offset - i, 

2856 "title": v.title, 

2857 "change_type": v.change_type, 

2858 "change_summary": v.change_summary, 

2859 "created_at": v.created_at.isoformat() 

2860 if v.created_at 

2861 else None, 

2862 } 

2863 for i, v in enumerate(versions) 

2864 ], 

2865 } 

2866 

2867 except Exception as e: 

2868 return handle_api_error("getting note versions", e) 

2869 

2870 

2871@router.get("/api/notes/{note_id}/versions/semantic-diff") 

2872@_notes_ai_limit 

2873def get_semantic_diff( 

2874 request: Request, 

2875 note_id: str, 

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

2877): 

2878 """ 

2879 Get AI-powered semantic diff between two versions. 

2880 

2881 Query params: 

2882 version1: First version UUID 

2883 version2: Second version UUID, or the literal "current" to diff 

2884 against the note's current content. 

2885 """ 

2886 from ...database.models import Document, NoteVersion 

2887 from ...database.session_context import get_user_db_session 

2888 

2889 note_service = NoteService(username) 

2890 

2891 try: 

2892 version1 = request.query_params.get("version1") 

2893 version2 = request.query_params.get("version2") 

2894 

2895 if not version1 or not version2: 

2896 return JSONResponse( 

2897 { 

2898 "success": False, 

2899 "error": "version1 and version2 are required", 

2900 }, 

2901 status_code=400, 

2902 ) 

2903 

2904 with get_user_db_session(username) as db_session: 

2905 # Defense-in-depth: confirm the document is a note before 

2906 # serving anything from its version history. load_only defers 

2907 # text_content; the "current" branch below lazy-loads it only 

2908 # when actually diffing against the live note. 

2909 doc = ( 

2910 db_session.query(Document) 

2911 .options(load_only(Document.id, Document.source_type_id)) 

2912 .filter_by(id=note_id) 

2913 .first() 

2914 ) 

2915 if not doc or not note_service._is_note(db_session, doc): 

2916 return JSONResponse( 

2917 {"success": False, "error": "Note not found"}, 

2918 status_code=404, 

2919 ) 

2920 

2921 v1 = ( 

2922 db_session.query(NoteVersion) 

2923 .filter_by(document_id=note_id, id=version1) 

2924 .first() 

2925 ) 

2926 if not v1: 

2927 return JSONResponse( 

2928 {"success": False, "error": "Version not found"}, 

2929 status_code=404, 

2930 ) 

2931 

2932 if version2 == "current": 

2933 v2_content = doc.text_content or "" 

2934 else: 

2935 v2 = ( 

2936 db_session.query(NoteVersion) 

2937 .filter_by(document_id=note_id, id=version2) 

2938 .first() 

2939 ) 

2940 if not v2: 2940 ↛ 2945line 2940 didn't jump to line 2945 because the condition on line 2940 was always true

2941 return JSONResponse( 

2942 {"success": False, "error": "Version not found"}, 

2943 status_code=404, 

2944 ) 

2945 v2_content = v2.content 

2946 

2947 ai_service = NoteAIService(username) 

2948 diff = ai_service.semantic_diff(v1.content, v2_content) 

2949 

2950 return {"success": True, "diff": diff} 

2951 

2952 except Exception as e: 

2953 return handle_api_error("getting semantic diff", e) 

2954 

2955 

2956@router.get("/api/notes/{note_id}/versions/{version_id}") 

2957def get_note_version( 

2958 request: Request, 

2959 note_id: str, 

2960 version_id: str, 

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

2962): 

2963 """Get a specific version of a note.""" 

2964 from ...database.models import Document, NoteVersion 

2965 from ...database.session_context import get_user_db_session 

2966 

2967 note_service = NoteService(username) 

2968 

2969 try: 

2970 with get_user_db_session(username) as db_session: 

2971 # Defense-in-depth: confirm the parent document is a note. 

2972 # load_only keeps the guard from hydrating text_content. 

2973 doc = ( 

2974 db_session.query(Document) 

2975 .options(load_only(Document.id, Document.source_type_id)) 

2976 .filter_by(id=note_id) 

2977 .first() 

2978 ) 

2979 if not doc or not note_service._is_note(db_session, doc): 

2980 return JSONResponse( 

2981 {"success": False, "error": "Note not found"}, 

2982 status_code=404, 

2983 ) 

2984 

2985 version = ( 

2986 db_session.query(NoteVersion) 

2987 .filter_by(document_id=note_id, id=version_id) 

2988 .first() 

2989 ) 

2990 

2991 if not version: 

2992 return JSONResponse( 

2993 {"success": False, "error": "Version not found"}, 

2994 status_code=404, 

2995 ) 

2996 

2997 return { 

2998 "success": True, 

2999 "version": { 

3000 "id": version.id, 

3001 "title": version.title, 

3002 "content": version.content, 

3003 "tags": version.tags, 

3004 "change_type": version.change_type, 

3005 "change_summary": version.change_summary, 

3006 "created_at": version.created_at.isoformat() 

3007 if version.created_at 

3008 else None, 

3009 }, 

3010 } 

3011 

3012 except Exception as e: 

3013 return handle_api_error("getting note version", e) 

3014 

3015 

3016@router.post("/api/notes/{note_id}/versions/{version_id}/restore") 

3017@_notes_write_limit 

3018def restore_note_version( 

3019 request: Request, 

3020 note_id: str, 

3021 version_id: str, 

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

3023 body: _NotesBody, 

3024): 

3025 """Restore a note to a previous version. 

3026 

3027 Delegates to ``NoteService.restore_with_bookends`` which wraps 

3028 PRE_RESTORE + content update + RESTORE in a single transaction. 

3029 Earlier code orchestrated this from three separate sessions — process 

3030 death between writes could leave a restored note with no audit-trail 

3031 row. Keep the orchestration in the service; this handler just routes. 

3032 """ 

3033 if isinstance(body, JSONResponse): 3033 ↛ 3034line 3033 didn't jump to line 3034 because the condition on line 3033 was never true

3034 return body 

3035 

3036 note_service = NoteService(username) 

3037 

3038 try: 

3039 success, error = note_service.restore_with_bookends(note_id, version_id) 

3040 if not success: 

3041 if error == "note_not_found": 3041 ↛ 3046line 3041 didn't jump to line 3046 because the condition on line 3041 was always true

3042 return JSONResponse( 

3043 {"success": False, "error": "Note not found"}, 

3044 status_code=404, 

3045 ) 

3046 if error == "version_not_found": 

3047 return JSONResponse( 

3048 {"success": False, "error": "Version not found"}, 

3049 status_code=404, 

3050 ) 

3051 return JSONResponse( 

3052 {"success": False, "error": "Failed to restore version"}, 

3053 status_code=500, 

3054 ) 

3055 

3056 _trigger_note_auto_index( 

3057 note_id, note_service, username, request.session.get("session_id") 

3058 ) 

3059 

3060 return { 

3061 "success": True, 

3062 "message": f"Restored to version {version_id[:8]}", 

3063 } 

3064 

3065 except Exception as e: 

3066 return handle_api_error("restoring note version", e)