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

1180 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 preserves the same paths, methods, 

6and service calls. Three deliberate divergences from the Flask blueprint: 

7 

8 * error responses use fixed client-safe messages rather than 

9 exception-derived detail; 

10 * ``link_research_to_note`` checks that the note exists both before 

11 calling the service and again when the service raises (the Flask route 

12 recovered that 404 by matching "not found" in the exception text, which 

13 the fixed messages no longer allow; the second check covers a note 

14 deleted between the first check and the call); 

15 * the nine LLM-backed AI routes classify an unconfigured LLM separately 

16 from an invalid request and from a server fault -- 400 with 

17 ``error_type: model_not_configured`` via 

18 ``_llm_not_configured_response``, where Flask answered a generic 500 

19 (and, on ``suggest-tags``, a request-validation 400). 

20 

21Notes on the port: 

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

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

24 already raises 401 when there is no authenticated user. 

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

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

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

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

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

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

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

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

33 priority automatically; FastAPI does not). 

34""" 

35 

36import asyncio 

37import builtins 

38import itertools 

39import json 

40import math 

41import reprlib 

42 

43from fastapi import APIRouter, Depends, Request 

44from fastapi.responses import JSONResponse 

45from loguru import logger 

46from sqlalchemy.exc import IntegrityError 

47from sqlalchemy.orm import load_only 

48 

49from ...config.llm_config import ( 

50 LLM_NOT_CONFIGURED_CLIENT_MESSAGE, 

51 LLMNotConfiguredError, 

52) 

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

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

55 MAX_TAG_LENGTH, 

56 MAX_TAGS_PER_NOTE, 

57 NOTE_CONTENT_MAX_BYTES, 

58 NoteService, 

59 _assert_content_size, 

60 escape_markdown_link_label, 

61) 

62from ...research_library.utils import handle_api_error 

63from ...security.data_sanitizer import REDACTION_TEXT 

64from ...security.log_sanitizer import ( 

65 _LOG_REDACTION_MAX_CHARS, 

66 _is_empty_log_value, 

67 _is_sensitive_log_key, 

68 _truncate_for_log_redaction, 

69 redact_for_log, 

70) 

71from ..dependencies.auth import require_auth 

72from ..dependencies.json_body import is_json_request 

73from ..dependencies.rate_limit import async_shared_limit, limiter, _user_key 

74from ..dependencies.threadpool import run_db_sync 

75from ..template_config import templates 

76 

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

78# over unbounded TEXT columns — shared with unified_search_routes via 

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

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

81from ..routes._search_constants import MAX_SEARCH_LEN 

82from typing import Annotated, Any 

83 

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

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

86_MAX_GRADE_SOURCES = NoteAIService.MAX_GRADE_SOURCES 

87 

88MAX_LINK_TEXT_LEN = 500 

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

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

91MAX_CLAIM_LEN = 1000 

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

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

94MAX_LIMIT = 200 

95 

96 

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

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

99 

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

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

102 """ 

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

104 

105 

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

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

108 

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

110 same as before. The caller maps the failure to a fixed 400 response. 

111 

112 Two distinct failures share the ``ValueError``: a non-string value and 

113 an over-length one. A caller that passes ``request.query_params.get(...)`` 

114 can only ever hit the length branch (Starlette's query params are always 

115 ``str``), so a single fixed length message is truthful there. A caller 

116 reading a JSON body must decide by type BEFORE calling this — see 

117 ``resolve_link`` — because the body is an uncoerced ``dict``. 

118 

119 That makes the non-string branch DEAD at every current call site (three 

120 query-param readers plus ``resolve_link``, which type-checks first). It 

121 is kept as a defensive guard, not as live behaviour: without it a future 

122 caller that forgets the type check would reach ``len(value)`` and raise 

123 ``TypeError``, which no caller here catches, turning a bad body into a 

124 500 instead of a 400. Any new call site that can pass a non-string must 

125 still do its own type check so its fixed message stays truthful. 

126 """ 

127 if value is None: 

128 return value 

129 if not isinstance(value, str): 

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

131 if len(value) > max_len: 

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

133 return value 

134 

135 

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

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

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

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

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

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

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

143# element before any truncation happens. 

144# 

145# ``reprlib.Repr`` avoids this for STRINGS and LISTS/TUPLES only: its 

146# ``repr_str`` slices, and ``repr_list``/``repr_tuple`` ``itertools.islice``, 

147# BEFORE recursing/stringifying (see ``reprlib.Repr.repr_str`` / 

148# ``repr_list`` in the stdlib), so those costs are bounded by the limits 

149# below regardless of the input's real size. It does NOT hold for 

150# dict/set/frozenset: ``repr_dict`` / ``repr_set`` / ``repr_frozenset`` 

151# call ``reprlib._possibly_sorted()`` — a plain ``sorted()`` over the FULL 

152# container — and slice to ``maxdict``/``maxset``/``maxfrozenset`` only 

153# afterwards. That is why values reach ``reprlib`` here only through the 

154# bounded walk in ``_bound_dict_shaped_walk`` below. ``maxlevel`` also caps 

155# recursion depth for a deeply nested dict. 

156# 

157# Redaction runs BEFORE any cut. ``repr_str`` keeps a long string's first 

158# and last ~48 characters and drops the middle; the final ``[:100]`` 

159# backstop below keeps only a prefix. Either cut can drop a credential's 

160# label (``?key=``, ``api_key=``, the ``user:`` ... ``@`` around URL 

161# userinfo) while keeping part of the secret, and the loguru patcher's 

162# redaction — the backstop for every sink — then has nothing to match. The 

163# value was often logged in full before it went through this preview, so 

164# the patcher used to see the whole thing. ``_RedactingRepr`` therefore runs 

165# the patcher's own redaction (``redact_for_log``) on every string, and on 

166# every ``builtins.repr`` text ``repr_instance`` falls back to, before 

167# reprlib shortens it; ``_bound_dict_shaped_walk`` masks a value under a 

168# sensitive key name (``api_key``, ``password``, ...), as the patcher's 

169# ``extra`` redaction does, because per-string redaction cannot see a label 

170# that lives in the dict key (and masks the value under any key its 

171# key-check budget cannot cover, see ``_PREVIEW_KEY_CHECK_BUDGET``). 

172# 

173# Redaction costs about 2 microseconds per character on adversarial input 

174# (a run of spaces, or of ``https://a:b@ ``): ~70-120 ms for a 32 KiB 

175# string, where the unredacted preview cost well under 0.1 ms. The preview 

176# shows at most 100 characters, so each string is redacted only up to 

177# ``_PREVIEW_STRING_REDACTION_MAX_CHARS``: it is cut first by the 

178# patcher's own rule (``_truncate_for_log_redaction`` with that bound, 

179# only at a whitespace boundary, dropping the whole run the bound falls 

180# in), the kept head is redacted, and the rest is replaced by the 

181# omitted-length marker. A credential token holds no boundary character, 

182# so the head never keeps part of a token whose label or ``@`` lies past 

183# the cut, and nothing past the cut is shown. A bounded container can 

184# still hold over twenty thousand strings (reprlib renders up to ten keys 

185# and ten values per dict at each of four levels), so each preview also 

186# has a total redaction budget of ``_PREVIEW_REDACTION_BUDGET`` 

187# characters. Each redaction also has a fixed cost of about 25 

188# microseconds however short the string, so every call is charged at 

189# least ``_PREVIEW_REDACTION_MIN_CHARGE`` characters: a preview runs at 

190# most ``_PREVIEW_REDACTION_BUDGET // _PREVIEW_REDACTION_MIN_CHARGE`` 

191# (32) redactions over at most ``_PREVIEW_REDACTION_BUDGET`` characters 

192# in total, a few milliseconds at worst. The 100-character preview shows 

193# only the first few strings reprlib renders, and those are the ones 

194# redacted first. A string that does not fit the remaining budget is 

195# shown as ``<N-char string not shown>``, never raw. 

196_PREVIEW_STRING_REDACTION_MAX_CHARS = 512 

197_PREVIEW_REDACTION_BUDGET = 1_024 

198_PREVIEW_REDACTION_MIN_CHARGE = 32 

199 

200 

201class _RedactingRepr(reprlib.Repr): 

202 """``reprlib.Repr`` that redacts each string before shortening it. 

203 

204 Stateful (the remaining budget), so ``_log_value_preview`` builds a new 

205 one per call; the module-level ``_log_value_repr`` instance only holds 

206 the shared limits. 

207 """ 

208 

209 def __init__(self) -> None: 

210 super().__init__( 

211 maxlevel=4, 

212 maxtuple=10, 

213 maxlist=10, 

214 maxdict=10, 

215 maxset=10, 

216 maxfrozenset=10, 

217 maxstring=100, 

218 maxlong=100, 

219 maxother=100, 

220 ) 

221 self._budget = _PREVIEW_REDACTION_BUDGET 

222 

223 def _redacted(self, text: str): 

224 cost = max( 

225 min(len(text), _PREVIEW_STRING_REDACTION_MAX_CHARS), 

226 _PREVIEW_REDACTION_MIN_CHARGE, 

227 ) 

228 if cost > self._budget: 

229 return None 

230 self._budget -= cost 

231 head, marker = _truncate_for_log_redaction( 

232 text, _PREVIEW_STRING_REDACTION_MAX_CHARS 

233 ) 

234 return redact_for_log(head) + marker 

235 

236 def repr_str(self, x, level): 

237 redacted = self._redacted(x) 

238 if redacted is None: 

239 return f"<{len(x)}-char string not shown>" 

240 return super().repr_str(redacted, level) 

241 

242 def repr_instance(self, x, level): 

243 try: 

244 text = builtins.repr(x) 

245 except Exception: 

246 return f"<{x.__class__.__name__} instance at {id(x):#x}>" 

247 redacted = self._redacted(text) 

248 if redacted is None: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 return f"<{x.__class__.__name__} repr not shown>" 

250 if len(redacted) > self.maxother: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true

251 i = max(0, (self.maxother - 3) // 2) 

252 j = max(0, self.maxother - 3 - i) 

253 redacted = ( 

254 redacted[:i] + self.fillvalue + redacted[len(redacted) - j :] 

255 ) 

256 return redacted 

257 

258 

259_log_value_repr = _RedactingRepr() 

260 

261 

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

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

264 

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

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

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

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

269 

270 ``value`` is pre-bounded by ``_bound_dict_shaped_walk`` before it 

271 reaches ``reprlib`` — see that function for why reprlib's own limits do 

272 not bound dict/set/frozenset cost on their own. 

273 

274 The final bound is a cheap backstop, not the primary defense — the 

275 limits on ``_log_value_repr`` plus the pre-bounding step already keep 

276 the *work* bounded; this just keeps the *output* bounded too (a 

277 container's ten per-element reprs, plus reprlib's own ``...`` marker, 

278 can still sum past 100 chars). But the backstop must not slice 

279 truncation *markers* off with the content, and it must keep the two 

280 truncation conditions distinguishable. When it truncates, the preview 

281 gets one of exactly two endings, each carrying its own meaning: 

282 

283 * an items-omitted tail — ``', ...}'``, ``', ...]'`` or ``', ...)'``: 

284 ENTRIES were dropped from some container (it held strictly more 

285 than ``reprlib``'s per-container limit of 10). A repr that already 

286 ends in one of ``reprlib``'s own container tails keeps it inside 

287 the cap; a repr whose dropped entries are NESTED — the tail sits 

288 inside the repr, followed by the outer container's closing 

289 delimiters, so it is not the suffix of the whole repr and the 

290 tail check cannot see it — instead gets 

291 ``_PREVIEW_ITEMS_OMITTED_TAIL`` appended, guided by the 

292 ``items_omitted`` flag the pre-bounding walk reports. 

293 * a bare ``...`` (``_PREVIEW_ELLIPSIS``): LENGTH-only truncation — 

294 no visited container lost entries; the repr simply exceeded the 

295 char cap. 

296 

297 The two endings differ in their final character and so can never be 

298 byte-equal: an 11-key (or 50,010-key) payload nested anywhere the 

299 pre-bounding walk visits always previews differently from the 

300 corresponding all-≤10-key payload. Reprs that fit the cap are 

301 returned untouched, and the preview never exceeds the cap. 

302 

303 Every string (and every ``repr`` text of a non-container object) has 

304 already been through the log patcher's credential redaction before 

305 ``reprlib`` or the backstop shortens it, and a value under a sensitive 

306 dict key (or under a key the key-check budget could not cover) is 

307 masked whole — see ``_RedactingRepr`` above. A cut 

308 therefore only removes text the patcher already redacted or would 

309 leave as is: it cannot strip a recognised credential's label while 

310 keeping part of the secret. 

311 """ 

312 bounded, items_omitted = _bound_dict_shaped_walk(value, 0) 

313 text = _RedactingRepr().repr(bounded) 

314 if len(text) <= _PREVIEW_MAX_CHARS: 

315 return text 

316 for tail in _PREVIEW_CONTAINER_TAILS: 

317 if text.endswith(tail): 

318 return text[: _PREVIEW_MAX_CHARS - len(tail)] + tail 

319 if items_omitted: 

320 return ( 

321 text[: _PREVIEW_MAX_CHARS - len(_PREVIEW_ITEMS_OMITTED_TAIL)] 

322 + _PREVIEW_ITEMS_OMITTED_TAIL 

323 ) 

324 return ( 

325 text[: _PREVIEW_MAX_CHARS - len(_PREVIEW_ELLIPSIS)] + _PREVIEW_ELLIPSIS 

326 ) 

327 

328 

329# One MORE than ``_log_value_repr``'s own maxdict/maxlist/maxset/maxtuple 

330# (10), deliberately. reprlib only appends its ``...`` truncation marker 

331# when the container handed to it is STRICTLY longer than that limit (``if 

332# n > self.maxdict: pieces.append(self.fillvalue)``). Pre-truncating to 

333# exactly 10 would make an attacker's 400,000-key body render byte-identical 

334# to a legitimate 10-key one — silently erasing "there was more" from the 

335# audit line, which is the one thing this log site exists to record. Keeping 

336# 11 costs nothing (reprlib discards the 11th) and restores the marker. 

337# ``_PREVIEW_MAX_DEPTH`` matches ``_log_value_repr``'s ``maxlevel``. 

338_PREVIEW_MAX_ITEMS = 11 

339_PREVIEW_MAX_DEPTH = 4 

340# Final output cap for ``_log_value_preview`` (previously an inline 

341# ``[:100]`` slice). 

342_PREVIEW_MAX_CHARS = 100 

343# Matches reprlib's ``fillvalue`` and the ``...`` marker convention used 

344# throughout this module. 

345_PREVIEW_ELLIPSIS = "..." 

346# reprlib's "the container had more items than I showed" tails — its ``...`` 

347# fillvalue joined onto the container's closing bracket. When the final 

348# char bound bites, preserving the tail keeps an item-bounded payload (e.g. 

349# a 50,010-key dict whose first ten ~100-char values fill the cap on their 

350# own) distinguishable from a merely char-bounded one, instead of slicing 

351# the marker off entirely. 

352_PREVIEW_CONTAINER_TAILS = (", ...}", ", ...]", ", ...)") 

353# The nested counterpart to the tails above. When the pre-bounding walk 

354# reports that some container it visited held more entries than 

355# ``reprlib``'s per-container limit (so entries were dropped) but the 

356# ``reprlib`` fill sits INSIDE the repr — followed by the outer 

357# container's closing delimiters, not at the very end — the tail check 

358# in ``_log_value_preview`` cannot preserve it. Appending this explicit 

359# tail keeps such a payload (e.g. ``{"outer": {<11 keys with ~100-char 

360# values>}}``) byte-distinguishable from the corresponding all-≤10-key 

361# payload, whose only truncation is the char cap and which therefore 

362# ends in a bare ``...``. Deliberately identical to the dict tail: both 

363# spell "entries were omitted". 

364_PREVIEW_ITEMS_OMITTED_TAIL = ", ...}" 

365# Budget for the sensitive-key check in ``_bound_dict_shaped_walk``. The 

366# check walks the whole key (``DataSanitizer.is_sensitive_setting`` 

367# normalises it character by character), JSON keys have no length limit, 

368# and the walk can visit over sixteen thousand keys (up to 11 per dict in 

369# up to 1,464 dicts). Each check of a str key is charged the key's length, 

370# at least ``_PREVIEW_KEY_CHECK_MIN_CHARGE`` (a non-str key is never 

371# sensitive, and its check is free), so one preview checks at most 

372# ``_PREVIEW_KEY_CHECK_BUDGET`` characters of keys in at most 512 checks. 

373# A key the remaining budget cannot cover is not checked, and its value is 

374# masked as if the key were sensitive (fail closed). 

375_PREVIEW_KEY_CHECK_BUDGET = _LOG_REDACTION_MAX_CHARS 

376_PREVIEW_KEY_CHECK_MIN_CHARGE = 64 

377 

378 

379def _bound_dict_shaped_walk( 

380 value: Any, _depth: int, _key_budget: list[int] | None = None 

381) -> tuple[Any, bool]: 

382 """Return a size-bounded copy of ``value``, safe to hand to 

383 ``_log_value_repr.repr()``, plus an "entries were dropped" flag. 

384 

385 ``reprlib.Repr`` bounds *string* and *list/tuple* reprs by slicing 

386 BEFORE recursing (see ``repr_str`` / ``repr_list`` in the stdlib), so 

387 those are already O(1) regardless of input size — the module comment 

388 above relies on that. But ``repr_dict`` / ``repr_set`` / 

389 ``repr_frozenset`` call stdlib ``reprlib._possibly_sorted()`` — which is 

390 a plain ``sorted(x)`` over the FULL container — and only slice to 

391 ``maxdict``/``maxset``/``maxfrozenset`` afterwards. For a dict/set built 

392 from a large attacker-controlled JSON body (this router allows up to 

393 ~100 MB, e.g. ``{"is_collapsed": {<N keys>}}`` reaching 

394 ``patch_note_research``) that reintroduces the exact O(N log N) time / 

395 O(N) memory cost ``_log_value_repr`` exists to eliminate — measured at 

396 ~10-22 ms for a 400k-entry dict/set on py3.14, scaling linearly, versus 

397 ~0 ms for an equivalently large string or list through the same helper. 

398 

399 Fix: truncate every dict/set/frozenset to ``_PREVIEW_MAX_ITEMS`` entries 

400 OURSELVES — via ``itertools.islice`` over the container's natural 

401 iteration order (insertion order for dicts, arbitrary-but-cheap for 

402 sets), never ``sorted()`` — before reprlib ever sees it. Once bounded 

403 this way, reprlib's own ``repr_dict``/``repr_set`` still calls 

404 ``sorted()``, but over at most ``_PREVIEW_MAX_ITEMS`` items, which is 

405 negligible. 

406 

407 Recurses into dict values and list/tuple elements (only the first 

408 ``_PREVIEW_MAX_ITEMS`` of each, mirroring reprlib's own slicing) up to 

409 ``_PREVIEW_MAX_DEPTH`` — matching ``_log_value_repr``'s ``maxlevel`` — 

410 so a large dict/set nested a few levels down (e.g. inside a list inside 

411 a dict) is bounded too, not just one sitting at the top level. The 

412 number of containers copied is the geometric sum 

413 ``1 + M + M**2 + M**3`` for ``M = _PREVIEW_MAX_ITEMS`` (1,464 at the 

414 current limits), NOT ``M ** _PREVIEW_MAX_DEPTH``, regardless of the real 

415 structure's size. Dict/set *keys* are returned unbounded: JSON object 

416 keys are always strings, and rendering a huge key is bounded by 

417 ``_RedactingRepr``'s redaction budget and ``repr_str`` slicing. The 

418 sensitive-key check on each visited dict key is NOT free — it walks the 

419 whole key — so it draws on a per-preview budget, 

420 ``_PREVIEW_KEY_CHECK_BUDGET`` (``_key_budget`` holds what remains; the 

421 outermost call creates it). A key the remaining budget cannot cover is 

422 not checked and its value is masked whole, as for a sensitive key. The 

423 walk still descends into that masked value (with no budget, so every 

424 key under it is masked unchecked) to compute the items-omitted flag. 

425 

426 Past ``_PREVIEW_MAX_DEPTH`` the walk stops and the sub-value is handed 

427 to reprlib untouched. For a **dict** that costs nothing: ``repr_dict`` 

428 checks ``level <= 0`` *before* calling ``_possibly_sorted``, so an 

429 over-deep dict collapses to ``{...}`` unsorted. It is NOT free for a 

430 **set/frozenset**: ``repr_set``/``repr_frozenset`` call 

431 ``_possibly_sorted`` at the top of the method, *before* 

432 ``_repr_iterable`` gets to check ``level``, so a huge set nested past 

433 the cap is still sorted in full and the result thrown away (measured 

434 ~13 ms for ``[[[[set(range(400_000))]]]]``, output ``[[[[{...}]]]]``). 

435 Likewise, set/frozenset *elements* are never recursed into, so a 

436 container inside a set — a frozenset or tuple, the only hashable 

437 candidates — stays unbounded at any depth (~13 ms for 

438 ``{frozenset(range(400_000))}``). Neither shape is reachable from this 

439 router (JSON has no set literal), but both matter if this helper is 

440 reused elsewhere. 

441 

442 Values that are not dict/set/frozenset/list/tuple (str, int, float, 

443 bool, None) pass through unchanged — reprlib's ``maxstring`` / 

444 ``maxlong`` bound those. ``maxlong`` only slices an ALREADY-built 

445 string, though: ``reprlib.repr_int`` calls ``builtins.repr(x)`` in full 

446 before truncating, so a huge ``int`` is the one JSON-native type whose 

447 repr cost this helper does not bound (measured ~2 s for a 2,000,000- 

448 digit int). It is unreachable from this router only because 

449 ``json.loads`` refuses an integer literal longer than 

450 ``sys.get_int_max_str_digits()`` (4300 by default). Nor does the 

451 pass-through extend to an arbitrary object: reprlib has no per-type 

452 method for a custom class, so it falls through to ``repr_instance``, 

453 which calls ``builtins.repr(x)`` in FULL and ignores ``level`` 

454 entirely — a hostile ``__repr__`` can allocate whatever it likes 

455 (measured ~170 ms / 200 MB) before reprlib truncates the resulting 

456 string. Nothing here bounds that. It is unreachable at this router's 

457 call sites, whose values all come out of ``json.loads`` and so can only 

458 be JSON-native types, but any reuse of this helper at a call site 

459 logging a non-JSON-sourced value must account for it. 

460 

461 Returns ``(bounded_value, items_omitted)``. ``items_omitted`` is True 

462 iff any dict/set/frozenset/list/tuple visited at a depth ``reprlib`` 

463 still enumerates (below ``_PREVIEW_MAX_DEPTH``, which matches its 

464 ``maxlevel``) holds at least ``_PREVIEW_MAX_ITEMS`` entries — i.e. 

465 strictly more than ``reprlib``'s per-container limit of one less, the 

466 condition under which ``reprlib`` drops entries and appends its 

467 ``...`` fill. ``_log_value_preview`` uses the flag to give over-cap 

468 reprs whose item truncation is nested (fill not at the end of the 

469 repr) the explicit ``_PREVIEW_ITEMS_OMITTED_TAIL`` ending. 

470 Containers at or past the depth cap are handed to ``reprlib`` 

471 untouched and collapse there to ``{...}`` / ``[...]`` wholesale — no 

472 per-entry output at any item count — so they do not set the flag. 

473 """ 

474 if _depth >= _PREVIEW_MAX_DEPTH: 

475 return value, False 

476 if _key_budget is None: 

477 _key_budget = [_PREVIEW_KEY_CHECK_BUDGET] 

478 if isinstance(value, dict): 

479 bounded: dict[Any, Any] = {} 

480 omitted = len(value) >= _PREVIEW_MAX_ITEMS 

481 for k, v in itertools.islice(value.items(), _PREVIEW_MAX_ITEMS): 

482 # A non-str key is never sensitive and costs nothing to check. 

483 cost = ( 

484 max(len(k), _PREVIEW_KEY_CHECK_MIN_CHARGE) 

485 if isinstance(k, str) 

486 else 0 

487 ) 

488 if cost > _key_budget[0]: 

489 # Not checked (see ``_PREVIEW_KEY_CHECK_BUDGET``), so 

490 # masked unless empty. The walk below only computes the 

491 # flag: with no budget, everything under it is masked too. 

492 if not _is_empty_log_value(v): 492 ↛ 507line 492 didn't jump to line 507 because the condition on line 492 was always true

493 bounded[k] = REDACTION_TEXT 

494 _, child_omitted = _bound_dict_shaped_walk( 

495 v, _depth + 1, [0] 

496 ) 

497 omitted = omitted or child_omitted 

498 continue 

499 else: 

500 _key_budget[0] -= cost 

501 if _is_sensitive_log_key(k) and not _is_empty_log_value(v): 

502 # Masked whole, as the log patcher masks a sensitive 

503 # ``extra`` key: string redaction cannot see a label 

504 # that lives in the key (see ``_RedactingRepr``). 

505 bounded[k] = REDACTION_TEXT 

506 continue 

507 bounded[k], child_omitted = _bound_dict_shaped_walk( 

508 v, _depth + 1, _key_budget 

509 ) 

510 omitted = omitted or child_omitted 

511 return bounded, omitted 

512 if isinstance(value, (set, frozenset)): 

513 # Bound the count only — set/frozenset elements are NOT recursed 

514 # into (see the docstring: a frozenset or tuple nested inside a set 

515 # therefore stays unbounded; unreachable from JSON input, which has 

516 # no set literal). Rebuilt as a plain ``set``/``frozenset`` rather 

517 # than ``type(value)(...)``, because a set SUBCLASS with a custom 

518 # ``__init__`` would come back silently EMPTY or raise ``TypeError`` 

519 # from inside a logging call — turning a 400 into a 500. 

520 bounded_items = itertools.islice(value, _PREVIEW_MAX_ITEMS) 

521 if isinstance(value, frozenset): 

522 return frozenset(bounded_items), len(value) >= _PREVIEW_MAX_ITEMS 

523 return set(bounded_items), len(value) >= _PREVIEW_MAX_ITEMS 

524 if isinstance(value, (list, tuple)): 

525 bounded = [] 

526 omitted = len(value) >= _PREVIEW_MAX_ITEMS 

527 for v in itertools.islice(value, _PREVIEW_MAX_ITEMS): 

528 bounded_item, child_omitted = _bound_dict_shaped_walk( 

529 v, _depth + 1, _key_budget 

530 ) 

531 bounded.append(bounded_item) 

532 omitted = omitted or child_omitted 

533 # Rebuilt as a plain ``tuple``, so a namedtuple degrades to a 

534 # bare tuple in the preview — acceptable for an audit line. 

535 bounded_tuple = tuple(bounded) if isinstance(value, tuple) else bounded 

536 return bounded_tuple, omitted 

537 return value, False 

538 

539 

540def _llm_not_configured_response(operation: str) -> JSONResponse: 

541 """400 for a notes AI route whose LLM is not configured. 

542 

543 Nine routes in this file run an LLM (``summarize``, 

544 ``research-questions``, ``suggest-tags``, ``key-concepts``, 

545 ``fact-check``, ``fact-check/.../grade``, ``synthesize/preview``, 

546 ``synthesize``, ``versions/semantic-diff``). Every one of them reaches 

547 ``NoteAIService._get_llm`` -> ``config.llm_config.get_llm``, which raises 

548 ``LLMNotConfiguredError`` when the user has not chosen a provider/model 

549 yet -- and on a fresh install that is the FIRST thing these endpoints 

550 hit. Left to ``handle_api_error`` they answer 500 "contact support"; in 

551 ``suggest_tags``, whose handler also maps ``ValueError`` to a 

552 request-validation 400, it came back as a bad-request verdict on a body 

553 that was perfectly fine. Both send the user looking in the wrong place. 

554 

555 400 with ``error_type: model_not_configured`` is this repository's 

556 existing answer for an unconfigured LLM, not a 5xx: see 

557 ``web/routers/api.py::check_ollama_model`` (the same ``error_type`` for 

558 an empty ``llm.model``), ``web/routers/followup.py::start_followup`` 

559 (400 before any DB write when ``llm.model`` is empty), and 

560 ``web/routers/zotero.py::_not_configured_response`` -- "that is a 

561 client-state problem, not a server error". The 503s in ``rag.py`` and 

562 ``settings.py`` are NOT the precedent: those are egress-policy 

563 ``settings_unavailable`` arms, a different condition. 

564 

565 One helper rather than nine copies so the status, the message and the 

566 ``error_type`` cannot drift apart route by route. ``operation`` names 

567 the route in the server-side log only; it never reaches the client, and 

568 the client message is always the module constant -- never ``str(exc)``, 

569 whose setup guidance can carry a settings path. 

570 """ 

571 logger.warning("Notes AI: {} refused, LLM not configured", operation) 

572 return JSONResponse( 

573 { 

574 "success": False, 

575 "error": LLM_NOT_CONFIGURED_CLIENT_MESSAGE, 

576 "error_type": "model_not_configured", 

577 }, 

578 status_code=400, 

579 ) 

580 

581 

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

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

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

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

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

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

588# 

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

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

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

592# LLM/synthesis budgets. 

593_notes_ai_limit = async_shared_limit( 

594 limiter, "10 per minute", scope="notes_ai", key_func=_user_key 

595) 

596_notes_search_limit = limiter.shared_limit( 

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

598) 

599_notes_synthesize_limit = async_shared_limit( 

600 limiter, "5 per minute", scope="notes_synthesize", key_func=_user_key 

601) 

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

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

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

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

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

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

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

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

610_notes_write_limit = limiter.shared_limit( 

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

612) 

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

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

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

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

617_notes_save_as_note_limit = limiter.shared_limit( 

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

619) 

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

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

622_notes_factcheck_limit = async_shared_limit( 

623 limiter, "3 per minute", scope="notes_factcheck", key_func=_user_key 

624) 

625 

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

627 

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

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

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

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

632# _assert_content_size could reject it at 50 MB. 

633# 

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

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

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

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

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

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

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

641# a memory-exhausting multi-GB body. 

642_MAX_JSON_BODY_BYTES = 2 * NOTE_CONTENT_MAX_BYTES 

643 

644 

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

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

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

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

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

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

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

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

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

654_JSON_OFFLOAD_THRESHOLD_BYTES = 64 * 1024 

655 

656 

657async def _parse_json_off_loop(body): 

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

659 

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

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

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

663 

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

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

666 mutates the buffer after this point. 

667 """ 

668 if len(body) < _JSON_OFFLOAD_THRESHOLD_BYTES: 

669 return json.loads(body) 

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

671 

672 

673async def _notes_json_body( 

674 request: Request, 

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

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

677 

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

679 

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

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

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

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

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

685 ``.get`` as an opaque 500. 

686 

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

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

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

690 """ 

691 

692 def _too_large(): 

693 return JSONResponse( 

694 { 

695 "success": False, 

696 "error": ( 

697 "Request body too large " 

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

699 ), 

700 }, 

701 status_code=413, 

702 ) 

703 

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

705 # reading anything. 

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

707 if content_length is not None: 

708 try: 

709 declared = int(content_length) 

710 except (TypeError, ValueError): 

711 declared = None 

712 if declared is not None and declared > _MAX_JSON_BODY_BYTES: 

713 return _too_large() 

714 

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

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

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

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

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

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

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

722 body = bytearray() 

723 async for chunk in request.stream(): 

724 body.extend(chunk) 

725 if len(body) > _MAX_JSON_BODY_BYTES: 

726 return _too_large() 

727 

728 # Keep the body cap for every media type. Flask's get_json(silent=True) 

729 # then treated a non-JSON media type as no data, even when its bytes 

730 # looked like JSON. 

731 if not is_json_request(request): 

732 return {} 

733 

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

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

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

737 return {} 

738 try: 

739 data = await _parse_json_off_loop(body) 

740 except Exception: 

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

742 return {} 

743 

744 if not isinstance(data, dict): 

745 return JSONResponse( 

746 { 

747 "success": False, 

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

749 }, 

750 status_code=400, 

751 ) 

752 return data 

753 

754 

755_NotesBody = Annotated[ 

756 dict[str, Any] | JSONResponse, 

757 Depends(_notes_json_body), 

758] 

759 

760 

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

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

763 try: 

764 from ...web.routers.rag import trigger_auto_index 

765 from ...database.session_passwords import session_password_store 

766 

767 db_password = session_password_store.get_session_password( 

768 username, session_id 

769 ) 

770 if not db_password: 

771 logger.debug( 

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

773 note_id, 

774 ) 

775 return 

776 collection_id = service.get_notes_collection_id() 

777 if not collection_id: 

778 logger.debug( 

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

780 note_id, 

781 ) 

782 return 

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

784 except Exception: 

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

786 

787 

788# ============================================================================ 

789# Page Routes 

790# ============================================================================ 

791 

792 

793@router.get("/") 

794def notes_page( 

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

796): 

797 """Render the notes list page.""" 

798 return templates.TemplateResponse( 

799 request=request, 

800 name="pages/notes.html", 

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

802 ) 

803 

804 

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

806def note_detail_page( 

807 request: Request, 

808 note_id: str, 

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

810): 

811 """Render the note detail page.""" 

812 from ..utils.path_ids import validated_uuid_path_param 

813 

814 note_id = validated_uuid_path_param(note_id, "Note") 

815 return templates.TemplateResponse( 

816 request=request, 

817 name="pages/note_detail.html", 

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

819 ) 

820 

821 

822# ============================================================================ 

823# API Routes - Notes CRUD 

824# ============================================================================ 

825 

826 

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

828@_notes_search_limit 

829def list_notes( 

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

831): 

832 """ 

833 List notes with optional filtering. 

834 

835 Query params: 

836 collection_id: Filter by collection 

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

838 pinned_only: Only return pinned notes 

839 limit: Maximum number (default 100) 

840 offset: Offset for pagination (default 0) 

841 """ 

842 try: 

843 service = NoteService(username) 

844 

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

846 try: 

847 search = _clamp_text_query( 

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

849 ) 

850 except ValueError: 

851 return JSONResponse( 

852 { 

853 "success": False, 

854 "error": ( 

855 f"search exceeds maximum length " 

856 f"({MAX_SEARCH_LEN} chars)" 

857 ), 

858 }, 

859 status_code=400, 

860 ) 

861 pinned_only = ( 

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

863 ) 

864 try: 

865 limit = _clamp_limit(request, 100) 

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

867 except (ValueError, TypeError): 

868 return JSONResponse( 

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

870 status_code=400, 

871 ) 

872 

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

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

875 # limit. count_notes repeats the filtered scan list_notes runs 

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

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

878 # reveals the total. 

879 if offset == 0: 

880 notes = service.list_notes( 

881 collection_id=collection_id, 

882 search=search, 

883 pinned_only=pinned_only, 

884 limit=limit, 

885 offset=0, 

886 ) 

887 total = ( 

888 len(notes) 

889 if len(notes) < limit 

890 else service.count_notes( 

891 collection_id=collection_id, 

892 search=search, 

893 pinned_only=pinned_only, 

894 ) 

895 ) 

896 else: 

897 total = service.count_notes( 

898 collection_id=collection_id, 

899 search=search, 

900 pinned_only=pinned_only, 

901 ) 

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

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

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

905 # get_note_versions). 

906 if offset > total: 

907 offset = total 

908 

909 notes = service.list_notes( 

910 collection_id=collection_id, 

911 search=search, 

912 pinned_only=pinned_only, 

913 limit=limit, 

914 offset=offset, 

915 ) 

916 

917 return { 

918 "success": True, 

919 "notes": notes, 

920 "total": total, 

921 "limit": limit, 

922 "offset": offset, 

923 } 

924 

925 except Exception as e: 

926 return handle_api_error("listing notes", e) 

927 

928 

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

930@_notes_write_limit 

931def create_note( 

932 request: Request, 

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

934 body: _NotesBody, 

935): 

936 """ 

937 Create a new note. 

938 

939 Body: 

940 title: Note title (required) 

941 content: Note content (required) 

942 tags: List of tags (optional) 

943 collection_ids: List of collection IDs (optional) 

944 """ 

945 if isinstance(body, JSONResponse): 

946 return body 

947 

948 try: 

949 data = body 

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

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

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

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

954 # object. 

955 return JSONResponse( 

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

957 status_code=400, 

958 ) 

959 

960 title = data.get("title") 

961 content = data.get("content") 

962 

963 if not title: 

964 return JSONResponse( 

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

966 status_code=400, 

967 ) 

968 if not content: 

969 return JSONResponse( 

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

971 status_code=400, 

972 ) 

973 

974 service = NoteService(username) 

975 note_id = service.create_note( 

976 title=title, 

977 content=content, 

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

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

980 ) 

981 

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

983 _trigger_note_auto_index( 

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

985 ) 

986 

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

988 

989 except ValueError: 

990 # Never serialize exception text at the HTTP boundary. Even callers 

991 # that normally raise fixed validation messages can regress to an 

992 # exception containing paths, SQL, credentials, or library internals. 

993 return JSONResponse( 

994 {"success": False, "error": "Invalid note data"}, status_code=400 

995 ) 

996 except Exception as e: 

997 return handle_api_error("creating note", e) 

998 

999 

1000# --------------------------------------------------------------------------- 

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

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

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

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

1005# automatically; FastAPI does not.) 

1006# --------------------------------------------------------------------------- 

1007 

1008 

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

1010@_notes_search_limit 

1011def semantic_search_notes( 

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

1013): 

1014 """ 

1015 Search notes using semantic similarity. 

1016 

1017 Finds notes by meaning rather than keyword matching. 

1018 

1019 Query params: 

1020 q: Search query (required) 

1021 limit: Maximum results (default 10) 

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

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

1024 (matches the text-search filter). 

1025 """ 

1026 try: 

1027 try: 

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

1029 except ValueError: 

1030 return JSONResponse( 

1031 { 

1032 "success": False, 

1033 "error": f"q exceeds maximum length ({MAX_SEARCH_LEN} chars)", 

1034 }, 

1035 status_code=400, 

1036 ) 

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

1038 if not query: 

1039 return JSONResponse( 

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

1041 status_code=400, 

1042 ) 

1043 

1044 try: 

1045 limit = _clamp_limit(request, 10) 

1046 min_similarity_raw = float( 

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

1048 ) 

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

1050 except (ValueError, TypeError): 

1051 return JSONResponse( 

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

1053 status_code=400, 

1054 ) 

1055 if not math.isfinite(min_similarity_raw): 

1056 logger.warning( 

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

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

1059 min_similarity_raw, 

1060 username, 

1061 ) 

1062 return JSONResponse( 

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

1064 status_code=400, 

1065 ) 

1066 

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

1068 

1069 service = NoteAIService(username) 

1070 results = service.semantic_search( 

1071 query, 

1072 limit=limit, 

1073 min_similarity=min_similarity, 

1074 collection_id=collection_id, 

1075 ) 

1076 

1077 return { 

1078 "success": True, 

1079 "query": query, 

1080 "results": results, 

1081 "count": len(results), 

1082 } 

1083 

1084 except Exception as e: 

1085 return handle_api_error("semantic search", e) 

1086 

1087 

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

1089@_notes_search_limit 

1090def ask_context( 

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

1092): 

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

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

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

1096 """ 

1097 try: 

1098 service = NoteService(username) 

1099 status = service.get_notes_index_status() 

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

1101 except Exception as e: 

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

1103 

1104 

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

1106@_notes_search_limit 

1107def search_notes_for_linking( 

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

1109): 

1110 """ 

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

1112 

1113 Query params: 

1114 query: Search query (partial title match) 

1115 exclude_note_id: Note ID to exclude from results 

1116 limit: Maximum number of results (default 10) 

1117 """ 

1118 try: 

1119 try: 

1120 query = _clamp_text_query( 

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

1122 ) 

1123 except ValueError: 

1124 return JSONResponse( 

1125 { 

1126 "success": False, 

1127 "error": ( 

1128 f"query exceeds maximum length ({MAX_SEARCH_LEN} chars)" 

1129 ), 

1130 }, 

1131 status_code=400, 

1132 ) 

1133 if not query: 

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

1135 

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

1137 try: 

1138 limit = _clamp_limit(request, 10) 

1139 except (ValueError, TypeError): 

1140 return JSONResponse( 

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

1142 ) 

1143 

1144 service = NoteService(username) 

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

1146 

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

1148 

1149 except Exception as e: 

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

1151 

1152 

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

1154def get_note( 

1155 request: Request, 

1156 note_id: str, 

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

1158): 

1159 """Get a specific note.""" 

1160 try: 

1161 service = NoteService(username) 

1162 note = service.get_note(note_id) 

1163 

1164 if not note: 

1165 return JSONResponse( 

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

1167 ) 

1168 

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

1170 

1171 except Exception as e: 

1172 return handle_api_error("getting note", e) 

1173 

1174 

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

1176@_notes_write_limit 

1177def update_note( 

1178 request: Request, 

1179 note_id: str, 

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

1181 body: _NotesBody, 

1182): 

1183 """ 

1184 Update a note. 

1185 

1186 Body: 

1187 title: New title (optional) 

1188 content: New content (optional) 

1189 tags: New tags (optional) 

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

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

1192 """ 

1193 if isinstance(body, JSONResponse): 

1194 return body 

1195 

1196 try: 

1197 data = body 

1198 if not data: 

1199 return JSONResponse( 

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

1201 status_code=400, 

1202 ) 

1203 

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

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

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

1207 # raises StatementError at flush → an opaque 500. 

1208 pinned = data.get("pinned") 

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

1210 return JSONResponse( 

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

1212 status_code=400, 

1213 ) 

1214 

1215 service = NoteService(username) 

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

1217 try: 

1218 success = service.update_note( 

1219 note_id=note_id, 

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

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

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

1223 favorite=pinned, 

1224 ) 

1225 except ValueError: 

1226 return JSONResponse( 

1227 {"success": False, "error": "Invalid note data"}, 

1228 status_code=400, 

1229 ) 

1230 

1231 if not success: 

1232 return JSONResponse( 

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

1234 ) 

1235 

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

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

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

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

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

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

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

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

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

1245 _trigger_note_auto_index( 

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

1247 ) 

1248 

1249 return {"success": True} 

1250 

1251 except Exception as e: 

1252 return handle_api_error("updating note", e) 

1253 

1254 

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

1256@_notes_write_limit 

1257def delete_note( 

1258 request: Request, 

1259 note_id: str, 

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

1261): 

1262 """Delete a note.""" 

1263 try: 

1264 service = NoteService(username) 

1265 success = service.delete_note(note_id) 

1266 

1267 if not success: 

1268 return JSONResponse( 

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

1270 ) 

1271 

1272 return {"success": True} 

1273 

1274 except Exception as e: 

1275 return handle_api_error("deleting note", e) 

1276 

1277 

1278# ============================================================================ 

1279# API Routes - Collections 

1280# ============================================================================ 

1281 

1282 

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

1284def get_note_collections( 

1285 request: Request, 

1286 note_id: str, 

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

1288): 

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

1290 try: 

1291 service = NoteService(username) 

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

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

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

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

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

1297 if not service.note_exists(note_id): 

1298 return JSONResponse( 

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

1300 ) 

1301 collections = service.get_note_collections(note_id) 

1302 

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

1304 

1305 except Exception as e: 

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

1307 

1308 

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

1310@_notes_write_limit 

1311def add_note_to_collection( 

1312 request: Request, 

1313 note_id: str, 

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

1315 body: _NotesBody, 

1316): 

1317 """ 

1318 Add a note to a collection. 

1319 

1320 Body: 

1321 collection_id: The collection ID 

1322 """ 

1323 if isinstance(body, JSONResponse): 

1324 return body 

1325 

1326 try: 

1327 data = body 

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

1329 return JSONResponse( 

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

1331 status_code=400, 

1332 ) 

1333 

1334 service = NoteService(username) 

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

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

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

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

1339 if not service.note_exists(note_id): 

1340 return JSONResponse( 

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

1342 ) 

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

1344 

1345 if not success: 

1346 return JSONResponse( 

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

1348 status_code=409, 

1349 ) 

1350 

1351 return {"success": True} 

1352 

1353 except LookupError: 

1354 return JSONResponse( 

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

1356 status_code=404, 

1357 ) 

1358 except Exception as e: 

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

1360 

1361 

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

1363@_notes_write_limit 

1364def remove_note_from_collection( 

1365 request: Request, 

1366 note_id: str, 

1367 collection_id: str, 

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

1369): 

1370 """Remove a note from a collection.""" 

1371 try: 

1372 service = NoteService(username) 

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

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

1375 if not service.note_exists(note_id): 

1376 return JSONResponse( 

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

1378 ) 

1379 success = service.remove_from_collection(note_id, collection_id) 

1380 

1381 if not success: 

1382 return JSONResponse( 

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

1384 status_code=404, 

1385 ) 

1386 

1387 return {"success": True} 

1388 

1389 except ValueError: 

1390 return JSONResponse( 

1391 { 

1392 "success": False, 

1393 "error": "Note cannot be removed from this collection", 

1394 }, 

1395 status_code=400, 

1396 ) 

1397 except Exception as e: 

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

1399 

1400 

1401# ============================================================================ 

1402# API Routes - Research 

1403# ============================================================================ 

1404 

1405 

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

1407def get_note_research( 

1408 request: Request, 

1409 note_id: str, 

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

1411): 

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

1413 try: 

1414 service = NoteService(username) 

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

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

1417 if not service.note_exists(note_id): 

1418 return JSONResponse( 

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

1420 ) 

1421 research = service.get_note_research(note_id) 

1422 

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

1424 

1425 except Exception as e: 

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

1427 

1428 

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

1430@_notes_write_limit 

1431def link_research_to_note( 

1432 request: Request, 

1433 note_id: str, 

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

1435 body: _NotesBody, 

1436): 

1437 """ 

1438 Link a research run to a note. 

1439 

1440 Body: 

1441 research_id: The research history ID 

1442 """ 

1443 if isinstance(body, JSONResponse): 

1444 return body 

1445 

1446 try: 

1447 data = body 

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

1449 return JSONResponse( 

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

1451 status_code=400, 

1452 ) 

1453 

1454 service = NoteService(username) 

1455 if not service.note_exists(note_id): 

1456 return JSONResponse( 

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

1458 ) 

1459 link_id = service.link_research_to_note( 

1460 note_id=note_id, 

1461 research_id=data["research_id"], 

1462 ) 

1463 

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

1465 

1466 except ValueError: 

1467 # The pre-check above answers 404 for an id that was already unknown. 

1468 # A note DELETED between that check and the service call lands here 

1469 # instead (the service raises ValueError("... not found")), so re-run 

1470 # the existence check to keep that narrow race answering 404 rather 

1471 # than a misleading "cannot be linked" 400. Best-effort: this runs 

1472 # inside an except handler, so a failure of the re-check itself must 

1473 # not replace the 400 with a 500. 

1474 try: 

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

1476 return JSONResponse( 

1477 {"success": False, "error": "Note not found"}, 

1478 status_code=404, 

1479 ) 

1480 except Exception: 

1481 logger.warning( 

1482 "notes_api: note_exists re-check failed while classifying a " 

1483 "link-research failure (user={!r})", 

1484 username, 

1485 ) 

1486 return JSONResponse( 

1487 { 

1488 "success": False, 

1489 "error": "Research cannot be linked to this note", 

1490 }, 

1491 status_code=400, 

1492 ) 

1493 except IntegrityError as exc: 

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

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

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

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

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

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

1500 if "foreign key" in msg: 

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

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

1503 return JSONResponse( 

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

1505 status_code=404, 

1506 ) 

1507 if "display_order" in msg: 

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

1509 return JSONResponse( 

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

1511 status_code=409, 

1512 ) 

1513 except Exception as e: 

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

1515 

1516 

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

1518@_notes_write_limit 

1519def patch_note_research( 

1520 request: Request, 

1521 note_id: str, 

1522 research_id: str, 

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

1524 body: _NotesBody, 

1525): 

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

1527 

1528 Body: 

1529 is_collapsed: bool (optional) 

1530 """ 

1531 if isinstance(body, JSONResponse): 

1532 return body 

1533 

1534 try: 

1535 data = body 

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

1537 is_collapsed = data.get("is_collapsed") 

1538 if "is_collapsed" in data and not isinstance( 

1539 data["is_collapsed"], bool 

1540 ): 

1541 logger.warning( 

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

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

1544 _log_value_preview(data["is_collapsed"]), 

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

1546 username, 

1547 ) 

1548 return JSONResponse( 

1549 { 

1550 "success": False, 

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

1552 }, 

1553 status_code=400, 

1554 ) 

1555 service = NoteService(username) 

1556 updated = service.update_note_research( 

1557 note_id=note_id, 

1558 research_id=research_id, 

1559 is_collapsed=is_collapsed, 

1560 ) 

1561 if not updated: 

1562 return JSONResponse( 

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

1564 status_code=404, 

1565 ) 

1566 return {"success": True} 

1567 

1568 except Exception as e: 

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

1570 

1571 

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

1573@_notes_write_limit 

1574def reorder_note_research( 

1575 request: Request, 

1576 note_id: str, 

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

1578 body: _NotesBody, 

1579): 

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

1581 

1582 Body: 

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

1584 """ 

1585 if isinstance(body, JSONResponse): 

1586 return body 

1587 

1588 try: 

1589 data = body 

1590 research_ids = data.get("research_ids") 

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

1592 return JSONResponse( 

1593 { 

1594 "success": False, 

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

1596 }, 

1597 status_code=400, 

1598 ) 

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

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

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

1602 return JSONResponse( 

1603 { 

1604 "success": False, 

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

1606 }, 

1607 status_code=400, 

1608 ) 

1609 

1610 service = NoteService(username) 

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

1612 # every sibling note-scoped route (get_note_research, 

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

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

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

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

1617 if not service.note_exists(note_id): 

1618 return JSONResponse( 

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

1620 ) 

1621 ok = service.reorder_note_research(note_id, research_ids) 

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

1623 return JSONResponse( 

1624 { 

1625 "success": False, 

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

1627 }, 

1628 status_code=400, 

1629 ) 

1630 return {"success": True} 

1631 

1632 except ValueError: 

1633 return JSONResponse( 

1634 {"success": False, "error": "Invalid research order"}, 

1635 status_code=400, 

1636 ) 

1637 except Exception as e: 

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

1639 

1640 

1641# ============================================================================ 

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

1643# ============================================================================ 

1644 

1645 

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

1647@_notes_search_limit 

1648def get_research_notes( 

1649 request: Request, 

1650 research_id: str, 

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

1652): 

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

1654 

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

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

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

1658 """ 

1659 try: 

1660 service = NoteService(username) 

1661 notes = service.get_notes_for_research(research_id) 

1662 if notes is None: 

1663 return JSONResponse( 

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

1665 status_code=404, 

1666 ) 

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

1668 

1669 except Exception as e: 

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

1671 

1672 

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

1674@_notes_write_limit 

1675def create_research_note( 

1676 request: Request, 

1677 research_id: str, 

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

1679 body: _NotesBody, 

1680): 

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

1682 

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

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

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

1686 selection) both come through here. 

1687 """ 

1688 if isinstance(body, JSONResponse): 

1689 return body 

1690 

1691 try: 

1692 data = body 

1693 service = NoteService(username) 

1694 try: 

1695 note_id = service.create_note_for_research( 

1696 research_id, 

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

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

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

1700 ) 

1701 except LookupError: 

1702 return JSONResponse( 

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

1704 status_code=404, 

1705 ) 

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

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

1708 _trigger_note_auto_index( 

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

1710 ) 

1711 return JSONResponse( 

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

1713 ) 

1714 

1715 except ValueError: 

1716 return JSONResponse( 

1717 {"success": False, "error": "Invalid note data"}, status_code=400 

1718 ) 

1719 except Exception as e: 

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

1721 

1722 

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

1724@_notes_save_as_note_limit 

1725def save_research_as_note( 

1726 request: Request, 

1727 research_id: str, 

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

1729 body: _NotesBody, 

1730): 

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

1732 

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

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

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

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

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

1738 it is later unlinked. 

1739 """ 

1740 if isinstance(body, JSONResponse): 

1741 return body 

1742 

1743 try: 

1744 from ...constants import ResearchStatus 

1745 from ...database.models import ResearchHistory 

1746 from ...database.session_context import get_user_db_session 

1747 from ...storage import get_report_storage 

1748 

1749 with get_user_db_session(username) as db: 

1750 research = ( 

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

1752 ) 

1753 if research is None: 

1754 return JSONResponse( 

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

1756 status_code=404, 

1757 ) 

1758 if research.status != ResearchStatus.COMPLETED: 

1759 return JSONResponse( 

1760 { 

1761 "success": False, 

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

1763 "status": research.status, 

1764 }, 

1765 status_code=409, 

1766 ) 

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

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

1769 

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

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

1772 # here, unlike in a background worker). 

1773 settings_snapshot = meta.get("settings_snapshot") 

1774 with get_user_db_session(username) as db: 

1775 storage = get_report_storage( 

1776 session=db, settings_snapshot=settings_snapshot 

1777 ) 

1778 report = storage.get_report(research_id, username) 

1779 

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

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

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

1783 # success while losing the content. Refuse instead. 

1784 return JSONResponse( 

1785 { 

1786 "success": False, 

1787 "error": ( 

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

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

1790 ), 

1791 }, 

1792 status_code=502, 

1793 ) 

1794 

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

1796 MAX_TITLE_LENGTH, 

1797 ) 

1798 

1799 title = ( 

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

1801 if query_text 

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

1803 ) 

1804 provenance = ( 

1805 f"> Saved from the research run " 

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

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

1808 ) 

1809 

1810 service = NoteService(username) 

1811 try: 

1812 note_id = service.create_note_for_research( 

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

1814 ) 

1815 except LookupError: 

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

1817 # any other missing research. 

1818 return JSONResponse( 

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

1820 status_code=404, 

1821 ) 

1822 _trigger_note_auto_index( 

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

1824 ) 

1825 return JSONResponse( 

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

1827 ) 

1828 

1829 except ValueError: 

1830 return JSONResponse( 

1831 { 

1832 "success": False, 

1833 "error": "Research cannot be saved as a note", 

1834 }, 

1835 status_code=400, 

1836 ) 

1837 except Exception as e: 

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

1839 

1840 

1841# ============================================================================ 

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

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

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

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

1846# ============================================================================ 

1847 

1848MAX_ANNOTATION_COMMENT_LEN = 5000 

1849MAX_ANNOTATION_QUOTE_LEN = 1000 

1850MAX_ANNOTATION_CONTEXT_LEN = 100 

1851 

1852 

1853def _validated_annotation_fields(data): 

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

1855 

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

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

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

1859 length checks produce. 

1860 """ 

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

1862 value = data.get(field) 

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

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

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

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

1867 if not comment: 

1868 raise ValueError("comment is required") 

1869 if not quote: 

1870 raise ValueError("quote is required") 

1871 if len(comment) > MAX_ANNOTATION_COMMENT_LEN: 

1872 raise ValueError( 

1873 f"comment exceeds maximum length " 

1874 f"({MAX_ANNOTATION_COMMENT_LEN} chars)" 

1875 ) 

1876 if len(quote) > MAX_ANNOTATION_QUOTE_LEN: 

1877 raise ValueError( 

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

1879 ) 

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

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

1882 return comment, quote, prefix, suffix 

1883 

1884 

1885def _comment_note_title(comment): 

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

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

1888 if len(one_line) > 60: 

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

1890 return f"Comment: {one_line}" 

1891 

1892 

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

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

1895_ANNOTATION_COMMENT_PREVIEW_CHARS = 300 

1896 

1897 

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

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

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

1901 return { 

1902 "note_id": note_id, 

1903 "quote": quote, 

1904 "prefix": prefix, 

1905 "suffix": suffix, 

1906 "note_title": _comment_note_title(comment), 

1907 "comment_preview": comment[:_ANNOTATION_COMMENT_PREVIEW_CHARS], 

1908 } 

1909 

1910 

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

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

1913 

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

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

1916 provenance link — see ``escape_markdown_link_label``. 

1917 """ 

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

1919 label = escape_markdown_link_label(target_label) 

1920 return ( 

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

1922 ) 

1923 

1924 

1925def _research_exists(username, research_id): 

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

1927 from ...database.models import ResearchHistory 

1928 from ...database.session_context import get_user_db_session 

1929 

1930 with get_user_db_session(username) as db: 

1931 row = ( 

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

1933 .filter_by(id=research_id) 

1934 .first() 

1935 ) 

1936 if row is None: 

1937 return False, None 

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

1939 

1940 

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

1942@_notes_search_limit 

1943def get_research_annotations( 

1944 request: Request, 

1945 research_id: str, 

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

1947): 

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

1949 try: 

1950 exists, _query = _research_exists(username, research_id) 

1951 if not exists: 

1952 return JSONResponse( 

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

1954 status_code=404, 

1955 ) 

1956 service = NoteService(username) 

1957 annotations = service.get_annotations_for_target( 

1958 research_id=research_id 

1959 ) 

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

1961 

1962 except Exception as e: 

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

1964 

1965 

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

1967@_notes_write_limit 

1968def create_research_annotation( 

1969 request: Request, 

1970 research_id: str, 

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

1972 body: _NotesBody, 

1973): 

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

1975 

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

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

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

1979 note_references row. 

1980 """ 

1981 if isinstance(body, JSONResponse): 

1982 return body 

1983 

1984 try: 

1985 data = body 

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

1987 

1988 exists, query_text = _research_exists(username, research_id) 

1989 if not exists: 

1990 return JSONResponse( 

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

1992 status_code=404, 

1993 ) 

1994 

1995 service = NoteService(username) 

1996 note_id = service.create_note_for_research( 

1997 research_id, 

1998 title=_comment_note_title(comment), 

1999 content=_comment_note_content( 

2000 comment, 

2001 quote, 

2002 query_text or research_id, 

2003 f"/results/{research_id}", 

2004 ), 

2005 quote=quote, 

2006 prefix=prefix, 

2007 suffix=suffix, 

2008 ) 

2009 _trigger_note_auto_index( 

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

2011 ) 

2012 return JSONResponse( 

2013 { 

2014 "success": True, 

2015 "annotation": _annotation_response( 

2016 note_id, comment, quote, prefix, suffix 

2017 ), 

2018 }, 

2019 status_code=201, 

2020 ) 

2021 

2022 except LookupError: 

2023 return JSONResponse( 

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

2025 ) 

2026 except ValueError: 

2027 return JSONResponse( 

2028 {"success": False, "error": "Invalid annotation data"}, 

2029 status_code=400, 

2030 ) 

2031 except Exception as e: 

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

2033 

2034 

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

2036@_notes_write_limit 

2037def delete_research_annotation( 

2038 request: Request, 

2039 research_id: str, 

2040 note_id: str, 

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

2042): 

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

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

2045 try: 

2046 service = NoteService(username) 

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

2048 return JSONResponse( 

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

2050 status_code=404, 

2051 ) 

2052 service.delete_note(note_id) 

2053 return {"success": True} 

2054 

2055 except Exception as e: 

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

2057 

2058 

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

2060 

2061 

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

2063@_notes_search_limit 

2064def get_document_notes( 

2065 request: Request, 

2066 document_id: str, 

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

2068): 

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

2070 try: 

2071 service = NoteService(username) 

2072 notes = service.get_notes_for_document(document_id) 

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

2074 return JSONResponse( 

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

2076 status_code=404, 

2077 ) 

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

2079 

2080 except Exception as e: 

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

2082 

2083 

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

2085@_notes_write_limit 

2086def create_document_note( 

2087 request: Request, 

2088 document_id: str, 

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

2090 body: _NotesBody, 

2091): 

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

2093 

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

2095 produce a starter note titled after the document. 

2096 """ 

2097 if isinstance(body, JSONResponse): 

2098 return body 

2099 

2100 try: 

2101 data = body 

2102 service = NoteService(username) 

2103 try: 

2104 note_id = service.create_note_for_document( 

2105 document_id, 

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

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

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

2109 ) 

2110 except LookupError: 

2111 return JSONResponse( 

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

2113 status_code=404, 

2114 ) 

2115 _trigger_note_auto_index( 

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

2117 ) 

2118 return JSONResponse( 

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

2120 ) 

2121 

2122 except ValueError: 

2123 return JSONResponse( 

2124 {"success": False, "error": "Invalid document note data"}, 

2125 status_code=400, 

2126 ) 

2127 except Exception as e: 

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

2129 

2130 

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

2132@_notes_search_limit 

2133def get_document_annotations( 

2134 request: Request, 

2135 document_id: str, 

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

2137): 

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

2139 try: 

2140 service = NoteService(username) 

2141 # Existence gate doubles as the 404 (get_notes_for_document 

2142 # checks the Document row). 

2143 if service.get_notes_for_document(document_id) is None: 

2144 return JSONResponse( 

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

2146 status_code=404, 

2147 ) 

2148 annotations = service.get_annotations_for_target( 

2149 document_id=document_id 

2150 ) 

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

2152 

2153 except Exception as e: 

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

2155 

2156 

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

2158@_notes_write_limit 

2159def create_document_annotation( 

2160 request: Request, 

2161 document_id: str, 

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

2163 body: _NotesBody, 

2164): 

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

2166 if isinstance(body, JSONResponse): 

2167 return body 

2168 

2169 try: 

2170 data = body 

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

2172 

2173 from ...database.models import Document 

2174 from ...database.session_context import get_user_db_session 

2175 

2176 with get_user_db_session(username) as db: 

2177 row = ( 

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

2179 .filter_by(id=document_id) 

2180 .first() 

2181 ) 

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

2183 return JSONResponse( 

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

2185 status_code=404, 

2186 ) 

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

2188 

2189 service = NoteService(username) 

2190 note_id = service.create_note_for_document( 

2191 document_id, 

2192 title=_comment_note_title(comment), 

2193 content=_comment_note_content( 

2194 comment, 

2195 quote, 

2196 doc_title, 

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

2198 ), 

2199 quote=quote, 

2200 prefix=prefix, 

2201 suffix=suffix, 

2202 ) 

2203 _trigger_note_auto_index( 

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

2205 ) 

2206 return JSONResponse( 

2207 { 

2208 "success": True, 

2209 "annotation": _annotation_response( 

2210 note_id, comment, quote, prefix, suffix 

2211 ), 

2212 }, 

2213 status_code=201, 

2214 ) 

2215 

2216 except LookupError: 

2217 return JSONResponse( 

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

2219 ) 

2220 except ValueError: 

2221 return JSONResponse( 

2222 {"success": False, "error": "Invalid annotation data"}, 

2223 status_code=400, 

2224 ) 

2225 except Exception as e: 

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

2227 

2228 

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

2230@_notes_write_limit 

2231def delete_document_annotation( 

2232 request: Request, 

2233 document_id: str, 

2234 note_id: str, 

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

2236): 

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

2238 anchor row cascades).""" 

2239 try: 

2240 service = NoteService(username) 

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

2242 return JSONResponse( 

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

2244 status_code=404, 

2245 ) 

2246 service.delete_note(note_id) 

2247 return {"success": True} 

2248 

2249 except Exception as e: 

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

2251 

2252 

2253# ============================================================================ 

2254# API Routes - RAG Indexing 

2255# ============================================================================ 

2256 

2257 

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

2259@_notes_write_limit 

2260def index_note_to_collection( 

2261 request: Request, 

2262 note_id: str, 

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

2264 body: _NotesBody, 

2265): 

2266 """ 

2267 Index a note into a collection for RAG search. 

2268 

2269 Body: 

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

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

2272 """ 

2273 from ...web.routers.rag import _client_indexing_error, get_rag_service 

2274 

2275 if isinstance(body, JSONResponse): 

2276 return body 

2277 

2278 try: 

2279 data = body 

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

2281 return JSONResponse( 

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

2283 status_code=400, 

2284 ) 

2285 

2286 collection_id = data["collection_id"] 

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

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

2289 if not isinstance(force_reindex, bool): 

2290 logger.warning( 

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

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

2293 _log_value_preview(force_reindex), 

2294 type(force_reindex).__name__, 

2295 username, 

2296 ) 

2297 return JSONResponse( 

2298 { 

2299 "success": False, 

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

2301 }, 

2302 status_code=400, 

2303 ) 

2304 

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

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

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

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

2309 return JSONResponse( 

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

2311 ) 

2312 

2313 # Get RAG service configured for the collection 

2314 with get_rag_service(request, username, collection_id) as rag_service: 

2315 # Index the note (notes are now documents) 

2316 result = rag_service.index_document( 

2317 document_id=note_id, 

2318 collection_id=collection_id, 

2319 force_reindex=force_reindex, 

2320 ) 

2321 

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

2323 # Same boundary as the RAG document routes: known document 

2324 # guidance passes, any other service text is withheld. 

2325 return JSONResponse( 

2326 { 

2327 "success": False, 

2328 "error": _client_indexing_error(result.get("error")), 

2329 }, 

2330 status_code=400, 

2331 ) 

2332 

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

2334 

2335 except Exception as e: 

2336 return handle_api_error("indexing note", e) 

2337 

2338 

2339# ============================================================================ 

2340# API Routes - AI Features 

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

2342# ============================================================================ 

2343 

2344 

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

2346@_notes_search_limit 

2347def get_similar_notes( 

2348 request: Request, 

2349 note_id: str, 

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

2351): 

2352 """ 

2353 Find notes similar to the given note using embeddings. 

2354 

2355 Query params: 

2356 limit: Maximum number of results (default 5) 

2357 """ 

2358 try: 

2359 try: 

2360 limit = _clamp_limit(request, 5) 

2361 except (ValueError, TypeError): 

2362 return JSONResponse( 

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

2364 ) 

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

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

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

2368 # as get_note_collections / get_note_research. 

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

2370 return JSONResponse( 

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

2372 ) 

2373 service = NoteAIService(username) 

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

2375 

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

2377 

2378 except Exception as e: 

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

2380 

2381 

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

2383@_notes_ai_limit 

2384async def summarize_note( 

2385 request: Request, 

2386 note_id: str, 

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

2388 body: _NotesBody, 

2389): 

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

2391 if isinstance(body, JSONResponse): 

2392 return body 

2393 

2394 try: 

2395 service = NoteAIService(username) 

2396 summary = await service.summarize_note(note_id) 

2397 

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

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

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

2401 if summary is None: 

2402 return JSONResponse( 

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

2404 status_code=404, 

2405 ) 

2406 

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

2408 

2409 except LLMNotConfiguredError: 

2410 # Same contract as suggest-tags: an unconfigured LLM is a 

2411 # client-state problem, not a 500. See 

2412 # ``_llm_not_configured_response``. 

2413 return _llm_not_configured_response("summarize") 

2414 except Exception as e: 

2415 return handle_api_error("summarizing note", e) 

2416 

2417 

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

2419@_notes_ai_limit 

2420async def extract_research_questions( 

2421 request: Request, 

2422 note_id: str, 

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

2424 body: _NotesBody, 

2425): 

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

2427 if isinstance(body, JSONResponse): 

2428 return body 

2429 

2430 try: 

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

2432 if not await run_db_sync(NoteService(username).note_exists, note_id): 

2433 return JSONResponse( 

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

2435 ) 

2436 service = NoteAIService(username) 

2437 questions = await service.extract_research_questions(note_id) 

2438 

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

2440 

2441 except LLMNotConfiguredError: 

2442 # Same contract as suggest-tags: an unconfigured LLM is a 

2443 # client-state problem, not a 500. See 

2444 # ``_llm_not_configured_response``. 

2445 return _llm_not_configured_response("research-questions") 

2446 except Exception as e: 

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

2448 

2449 

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

2451@_notes_ai_limit 

2452async def suggest_tags( 

2453 request: Request, 

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

2455 body: _NotesBody, 

2456): 

2457 """ 

2458 Suggest tags for note content. 

2459 

2460 Body: 

2461 content: The note content to analyze 

2462 existing_tags: Current tags to avoid duplicates (optional) 

2463 """ 

2464 if isinstance(body, JSONResponse): 

2465 return body 

2466 

2467 try: 

2468 data = body 

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

2470 return JSONResponse( 

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

2472 status_code=400, 

2473 ) 

2474 

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

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

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

2478 _assert_content_size(data["content"]) 

2479 

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

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

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

2483 existing_tags = data.get("existing_tags") 

2484 if existing_tags is not None and ( 

2485 not isinstance(existing_tags, list) 

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

2487 ): 

2488 return JSONResponse( 

2489 { 

2490 "success": False, 

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

2492 }, 

2493 status_code=400, 

2494 ) 

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

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

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

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

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

2500 if existing_tags is not None and ( 

2501 len(existing_tags) > MAX_TAGS_PER_NOTE 

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

2503 ): 

2504 return JSONResponse( 

2505 { 

2506 "success": False, 

2507 "error": ( 

2508 f"existing_tags exceeds limits (max " 

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

2510 ), 

2511 }, 

2512 status_code=400, 

2513 ) 

2514 

2515 service = NoteAIService(username) 

2516 tags = await service.suggest_tags( 

2517 content=data["content"], 

2518 existing_tags=existing_tags, 

2519 ) 

2520 

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

2522 

2523 except LLMNotConfiguredError: 

2524 # MUST precede the ValueError arm: this is a ValueError subclass, so 

2525 # ordering is what separates "you have not configured a model" from 

2526 # "your request body was invalid" here. See 

2527 # ``_llm_not_configured_response`` for why the answer is a 400 with 

2528 # ``error_type: model_not_configured``. 

2529 return _llm_not_configured_response("suggest-tags") 

2530 except ValueError: 

2531 return JSONResponse( 

2532 {"success": False, "error": "Invalid tag request"}, 

2533 status_code=400, 

2534 ) 

2535 except Exception as e: 

2536 return handle_api_error("suggesting tags", e) 

2537 

2538 

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

2540@_notes_ai_limit 

2541async def extract_key_concepts( 

2542 request: Request, 

2543 note_id: str, 

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

2545 body: _NotesBody, 

2546): 

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

2548 if isinstance(body, JSONResponse): 

2549 return body 

2550 

2551 try: 

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

2553 if not await run_db_sync(NoteService(username).note_exists, note_id): 

2554 return JSONResponse( 

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

2556 ) 

2557 service = NoteAIService(username) 

2558 concepts = await service.extract_key_concepts(note_id) 

2559 

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

2561 

2562 except LLMNotConfiguredError: 

2563 # Same contract as suggest-tags: an unconfigured LLM is a 

2564 # client-state problem, not a 500. See 

2565 # ``_llm_not_configured_response``. 

2566 return _llm_not_configured_response("key-concepts") 

2567 except Exception as e: 

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

2569 

2570 

2571# ============================================================================ 

2572# API Routes - Smart Note Linking 

2573# ============================================================================ 

2574 

2575 

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

2577@_notes_search_limit 

2578def get_backlinks( 

2579 request: Request, 

2580 note_id: str, 

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

2582): 

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

2584 try: 

2585 service = NoteService(username) 

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

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

2588 if not service.note_exists(note_id): 

2589 return JSONResponse( 

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

2591 ) 

2592 backlinks = service.get_backlinks(note_id) 

2593 

2594 return { 

2595 "success": True, 

2596 "backlinks": backlinks, 

2597 "total": len(backlinks), 

2598 } 

2599 

2600 except Exception as e: 

2601 return handle_api_error("getting backlinks", e) 

2602 

2603 

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

2605@_notes_search_limit 

2606def get_outgoing_links( 

2607 request: Request, 

2608 note_id: str, 

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

2610): 

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

2612 try: 

2613 service = NoteService(username) 

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

2615 if not service.note_exists(note_id): 

2616 return JSONResponse( 

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

2618 ) 

2619 links = service.get_outgoing_links(note_id) 

2620 

2621 return { 

2622 "success": True, 

2623 "links": links, 

2624 "total": len(links), 

2625 } 

2626 

2627 except Exception as e: 

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

2629 

2630 

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

2632@_notes_search_limit 

2633def get_suggested_links( 

2634 request: Request, 

2635 note_id: str, 

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

2637): 

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

2639 try: 

2640 try: 

2641 limit = _clamp_limit(request, 5) 

2642 except (ValueError, TypeError): 

2643 return JSONResponse( 

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

2645 ) 

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

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

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

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

2650 return JSONResponse( 

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

2652 ) 

2653 service = NoteAIService(username) 

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

2655 

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

2657 

2658 except Exception as e: 

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

2660 

2661 

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

2663@_notes_write_limit 

2664def accept_suggested_link( 

2665 request: Request, 

2666 note_id: str, 

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

2668 body: _NotesBody, 

2669): 

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

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

2672 

2673 Body: 

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

2675 """ 

2676 if isinstance(body, JSONResponse): 

2677 return body 

2678 

2679 try: 

2680 data = body 

2681 target_note_id = data.get("target_note_id") 

2682 if not target_note_id: 

2683 return JSONResponse( 

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

2685 status_code=400, 

2686 ) 

2687 

2688 service = NoteService(username) 

2689 result = service.accept_suggested_link(note_id, target_note_id) 

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

2691 return JSONResponse( 

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

2693 status_code=404, 

2694 ) 

2695 

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

2697 

2698 except ValueError: 

2699 return JSONResponse( 

2700 { 

2701 "success": False, 

2702 "error": "Suggested link cannot be accepted", 

2703 }, 

2704 status_code=400, 

2705 ) 

2706 except Exception as e: 

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

2708 

2709 

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

2711@_notes_search_limit 

2712def get_unlinked_mentions( 

2713 request: Request, 

2714 note_id: str, 

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

2716): 

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

2718 

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

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

2721 """ 

2722 try: 

2723 service = NoteService(username) 

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

2725 if not service.note_exists(note_id): 

2726 return JSONResponse( 

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

2728 ) 

2729 mentions = service.get_unlinked_mentions(note_id) 

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

2731 except Exception as e: 

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

2733 

2734 

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

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

2737MAX_PASSAGE_LEN = 2000 

2738 

2739 

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

2741@_notes_search_limit 

2742def similar_passages( 

2743 request: Request, 

2744 note_id: str, 

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

2746 body: _NotesBody, 

2747): 

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

2749 

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

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

2752 """ 

2753 if isinstance(body, JSONResponse): 

2754 return body 

2755 

2756 try: 

2757 data = body 

2758 text = data.get("text") 

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

2760 return JSONResponse( 

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

2762 ) 

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

2764 

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

2766 logger.warning( 

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

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

2769 _log_value_preview(note_id), 

2770 username, 

2771 ) 

2772 return JSONResponse( 

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

2774 ) 

2775 

2776 service = NoteAIService(username) 

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

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

2779 except Exception as e: 

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

2781 

2782 

2783# ============================================================================ 

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

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

2786# ============================================================================ 

2787 

2788 

2789# ============================================================================ 

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

2791# ============================================================================ 

2792 

2793 

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

2795@_notes_factcheck_limit 

2796async def fact_check_note( 

2797 request: Request, 

2798 note_id: str, 

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

2800 body: _NotesBody, 

2801): 

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

2803 query for verifying them. 

2804 

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

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

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

2808 """ 

2809 if isinstance(body, JSONResponse): 

2810 return body 

2811 

2812 try: 

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

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

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

2816 if not await run_db_sync(NoteService(username).note_exists, note_id): 

2817 return JSONResponse( 

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

2819 ) 

2820 

2821 ai = NoteAIService(username) 

2822 claims = await ai.extract_claims(note_id) 

2823 if not claims: 

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

2825 return JSONResponse( 

2826 { 

2827 "success": False, 

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

2829 }, 

2830 status_code=422, 

2831 ) 

2832 query = ai.synthesize_factcheck_query(claims) 

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

2834 

2835 except LLMNotConfiguredError: 

2836 # Same contract as suggest-tags: an unconfigured LLM is a 

2837 # client-state problem, not a 500. See 

2838 # ``_llm_not_configured_response``. 

2839 return _llm_not_configured_response("fact-check") 

2840 except Exception as e: 

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

2842 

2843 

2844async def _grade_note_claims(username, note_id, research_id, claims): 

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

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

2847 

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

2849 can be unit-tested without a client. DB steps run on worker threads via 

2850 ``run_db_sync`` (thread-local sessions must stay off the event loop — 

2851 #6043), and the LLM grade awaits on the loop so the request thread is 

2852 freed for the length of the model's response (#5854). 

2853 

2854 The per-user DB password stays resolvable on those worker threads 

2855 because ``get_user_db_session`` resolves it from the PROCESS-GLOBAL 

2856 ``session_password_store`` (``get_any_session_password(username)``), 

2857 which is neither thread- nor context-bound; the thread-local context 

2858 is only its third fallback, after an explicit ``session_id`` lookup. 

2859 Nothing has to be propagated to the pool here — unlike the 

2860 change-summary executor worker, which is off the request path entirely 

2861 and has the password threaded in explicitly as ``dbpw``. 

2862 """ 

2863 from ...constants import ResearchStatus 

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

2865 from ...database.session_context import get_user_db_session 

2866 from ...storage import get_report_storage 

2867 from ..services.report_assembly_service import ( 

2868 get_research_source_links_batch, 

2869 ) 

2870 

2871 def _load_research(): 

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

2873 # require it to have completed before grading. 

2874 with get_user_db_session(username) as db: 

2875 research = ( 

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

2877 ) 

2878 if not research: 

2879 return None 

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

2881 # client could grade an arbitrary completed research against an 

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

2883 linked = ( 

2884 db.query(NoteResearch) 

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

2886 .first() 

2887 ) 

2888 if not linked: 

2889 return False 

2890 return research.status, dict(research.research_meta or {}) 

2891 

2892 loaded = await run_db_sync(_load_research) 

2893 if loaded is None: 

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

2895 if loaded is False: 

2896 return { 

2897 "success": False, 

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

2899 }, 404 

2900 status, meta = loaded 

2901 

2902 if status != ResearchStatus.COMPLETED: 

2903 return { 

2904 "success": False, 

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

2906 "status": status, 

2907 }, 409 

2908 

2909 settings_snapshot = meta.get("settings_snapshot") 

2910 

2911 def _fetch_report_and_sources(): 

2912 # Fetch the report markdown + sources. 

2913 with get_user_db_session(username) as db: 

2914 storage = get_report_storage( 

2915 session=db, settings_snapshot=settings_snapshot 

2916 ) 

2917 report = storage.get_report(research_id, username) 

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

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

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

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

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

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

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

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

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

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

2928 sources = get_research_source_links_batch( 

2929 [research_id], db, limit=_MAX_GRADE_SOURCES 

2930 ).get(research_id, []) 

2931 return report, sources 

2932 

2933 report, sources = await run_db_sync(_fetch_report_and_sources) 

2934 

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

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

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

2938 # Refuse to grade instead. 

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

2940 return { 

2941 "success": False, 

2942 "error": ( 

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

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

2945 ), 

2946 }, 502 

2947 

2948 ai = NoteAIService(username) 

2949 verdicts = await ai.grade_all_claims(claims, report, sources) 

2950 

2951 def _persist_verdicts(): 

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

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

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

2955 with get_user_db_session(username) as db: 

2956 research = ( 

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

2958 ) 

2959 if research is None: 

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

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

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

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

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

2965 # the conflict explicitly instead. 

2966 return False 

2967 try: 

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

2969 new_meta["fact_check"] = { 

2970 "note_id": note_id, 

2971 "claims": claims, 

2972 "verdicts": verdicts, 

2973 } 

2974 research.research_meta = new_meta 

2975 db.commit() 

2976 except Exception: 

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

2978 db.rollback() 

2979 raise 

2980 return True 

2981 

2982 if not await run_db_sync(_persist_verdicts): 

2983 return { 

2984 "success": False, 

2985 "error": ( 

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

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

2988 ), 

2989 }, 409 

2990 

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

2992 

2993 

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

2995@_notes_factcheck_limit 

2996async def grade_note_fact_check( 

2997 request: Request, 

2998 note_id: str, 

2999 research_id: str, 

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

3001 body: _NotesBody, 

3002): 

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

3004 

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

3006 """ 

3007 if isinstance(body, JSONResponse): 

3008 return body 

3009 

3010 try: 

3011 data = body 

3012 raw_claims = data.get("claims") 

3013 if not isinstance(raw_claims, list): 

3014 return JSONResponse( 

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

3016 ) 

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

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

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

3020 claims = [ 

3021 c.strip()[:MAX_CLAIM_LEN] 

3022 for c in raw_claims 

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

3024 ][: NoteAIService.MAX_CLAIMS_PER_NOTE] 

3025 if not claims: 

3026 return JSONResponse( 

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

3028 ) 

3029 

3030 payload, status_code = await _grade_note_claims( 

3031 username, note_id, research_id, claims 

3032 ) 

3033 return JSONResponse(payload, status_code=status_code) 

3034 

3035 except LLMNotConfiguredError: 

3036 # Same contract as suggest-tags: an unconfigured LLM is a 

3037 # client-state problem, not a 500. See 

3038 # ``_llm_not_configured_response``. 

3039 return _llm_not_configured_response("fact-check grade") 

3040 except Exception as e: 

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

3042 

3043 

3044@router.post("/api/notes/resolve-link") 

3045@_notes_search_limit 

3046def resolve_link( 

3047 request: Request, 

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

3049 body: _NotesBody, 

3050): 

3051 """ 

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

3053 

3054 Body: 

3055 link_text: The text inside [[brackets]] 

3056 """ 

3057 if isinstance(body, JSONResponse): 

3058 return body 

3059 

3060 try: 

3061 data = body 

3062 if not data or not data.get("link_text"): 

3063 return JSONResponse( 

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

3065 status_code=400, 

3066 ) 

3067 link_text = data["link_text"] 

3068 # ``_NotesBody`` is a plain parsed-JSON dict with no per-field 

3069 # coercion, so ``link_text`` can be any JSON type. Select the fixed 

3070 # 400 message by TYPE rather than by matching the exception text: 

3071 # ``_clamp_text_query`` raises for two different reasons and only 

3072 # one of them is a length problem, so reporting "exceeds maximum 

3073 # length" for ``{"link_text": 123}`` would be false. 

3074 if not isinstance(link_text, str): 

3075 return JSONResponse( 

3076 {"success": False, "error": "link_text must be a string"}, 

3077 status_code=400, 

3078 ) 

3079 try: 

3080 link_text = _clamp_text_query( 

3081 link_text, "link_text", max_len=MAX_LINK_TEXT_LEN 

3082 ) 

3083 except ValueError: 

3084 return JSONResponse( 

3085 { 

3086 "success": False, 

3087 "error": ( 

3088 f"link_text exceeds maximum length " 

3089 f"({MAX_LINK_TEXT_LEN} chars)" 

3090 ), 

3091 }, 

3092 status_code=400, 

3093 ) 

3094 

3095 service = NoteService(username) 

3096 result = service.resolve_link(link_text) 

3097 

3098 if result: 

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

3100 return JSONResponse( 

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

3102 ) 

3103 

3104 except Exception as e: 

3105 return handle_api_error("resolving link", e) 

3106 

3107 

3108# ============================================================================ 

3109# API Routes - Note Synthesis 

3110# ============================================================================ 

3111 

3112 

3113@router.post("/api/notes/synthesize/preview") 

3114@_notes_synthesize_limit 

3115async def preview_synthesis( 

3116 request: Request, 

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

3118 body: _NotesBody, 

3119): 

3120 """ 

3121 Preview what a synthesis would produce. 

3122 

3123 Body: 

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

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

3126 """ 

3127 if isinstance(body, JSONResponse): 

3128 return body 

3129 

3130 try: 

3131 data = body 

3132 if not data or not data.get("note_ids"): 

3133 return JSONResponse( 

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

3135 status_code=400, 

3136 ) 

3137 

3138 note_ids = data["note_ids"] 

3139 if not isinstance(note_ids, list) or not all( 

3140 isinstance(n, str) for n in note_ids 

3141 ): 

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

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

3144 return JSONResponse( 

3145 { 

3146 "success": False, 

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

3148 }, 

3149 status_code=400, 

3150 ) 

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

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

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

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

3155 if not isinstance(synthesis_type, str): 

3156 return JSONResponse( 

3157 { 

3158 "success": False, 

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

3160 }, 

3161 status_code=400, 

3162 ) 

3163 

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

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

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

3167 service = NoteAIService(username) 

3168 preview = await service.synthesize_notes(note_ids, synthesis_type) 

3169 

3170 if "error" in preview: 

3171 return JSONResponse( 

3172 {"success": False, "error": preview["error"]}, 

3173 status_code=400, 

3174 ) 

3175 

3176 return {"success": True, "result": preview} 

3177 

3178 except LLMNotConfiguredError: 

3179 # Same contract as suggest-tags: an unconfigured LLM is a 

3180 # client-state problem, not a 500. See 

3181 # ``_llm_not_configured_response``. 

3182 return _llm_not_configured_response("synthesize preview") 

3183 except Exception as e: 

3184 return handle_api_error("previewing synthesis", e) 

3185 

3186 

3187@router.post("/api/notes/synthesize") 

3188@_notes_synthesize_limit 

3189async def synthesize_notes( 

3190 request: Request, 

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

3192 body: _NotesBody, 

3193): 

3194 """ 

3195 Synthesize multiple notes into one. 

3196 

3197 Body: 

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

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

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

3201 """ 

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

3203 

3204 if isinstance(body, JSONResponse): 

3205 return body 

3206 

3207 try: 

3208 data = body 

3209 if not data or not data.get("note_ids"): 

3210 return JSONResponse( 

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

3212 status_code=400, 

3213 ) 

3214 

3215 note_ids = data["note_ids"] 

3216 if not isinstance(note_ids, list) or not all( 

3217 isinstance(n, str) for n in note_ids 

3218 ): 

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

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

3221 return JSONResponse( 

3222 { 

3223 "success": False, 

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

3225 }, 

3226 status_code=400, 

3227 ) 

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

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

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

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

3232 if not isinstance(synthesis_type, str): 

3233 return JSONResponse( 

3234 { 

3235 "success": False, 

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

3237 }, 

3238 status_code=400, 

3239 ) 

3240 # Omitted: defaults to True (persist synthesis as a new note). 

3241 create_note = data.get("create_note", True) 

3242 if not isinstance(create_note, bool): 

3243 logger.warning( 

3244 "notes_api validation reject: create_note={} (type={}) " 

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

3246 _log_value_preview(create_note), 

3247 type(create_note).__name__, 

3248 username, 

3249 ) 

3250 return JSONResponse( 

3251 { 

3252 "success": False, 

3253 "error": "create_note must be a boolean", 

3254 }, 

3255 status_code=400, 

3256 ) 

3257 

3258 ai_service = NoteAIService(username) 

3259 result = await ai_service.synthesize_notes(note_ids, synthesis_type) 

3260 

3261 if "error" in result: 

3262 return JSONResponse( 

3263 {"success": False, "error": result["error"]}, 

3264 status_code=400, 

3265 ) 

3266 

3267 # Create the note if requested 

3268 if create_note: 

3269 note_service = NoteService(username) 

3270 

3271 def _create_note_and_record_synthesis(): 

3272 # Runs on a worker thread via run_db_sync: create_note and 

3273 # the synthesis-record persist both open thread-local DB 

3274 # sessions, which must stay off the event loop (#6043). 

3275 new_note_id = note_service.create_note( 

3276 title=result["suggested_title"], 

3277 content=result["content"], 

3278 tags=["synthesized", synthesis_type], 

3279 ) 

3280 

3281 # Record the synthesis. Use the FILTERED source set the AI 

3282 # service actually fed to the LLM (result["source_notes"]) 

3283 # rather than the raw client input — NoteAIService.synthesize_notes 

3284 # silently drops any non-note Document IDs from the prompt, so 

3285 # persisting raw note_ids would (a) record IDs the LLM never 

3286 # saw as "sources" and (b) FK-violate on any bogus ID after 

3287 # create_note has already committed. 

3288 from ...database.session_context import get_user_db_session 

3289 

3290 filtered_source_ids = [ 

3291 src["id"] 

3292 for src in result.get("source_notes", []) 

3293 if "id" in src 

3294 ] 

3295 

3296 db_session = None 

3297 try: 

3298 with get_user_db_session(username) as db_session: 

3299 synthesis_record = NoteSynthesis( 

3300 result_document_id=new_note_id, 

3301 synthesis_type=synthesis_type, 

3302 ) 

3303 for doc_id in filtered_source_ids: 

3304 synthesis_record.sources.append( 

3305 NoteSynthesisSource(source_document_id=doc_id) 

3306 ) 

3307 db_session.add(synthesis_record) 

3308 db_session.commit() 

3309 except Exception: 

3310 # create_note() already committed the new Document in its own 

3311 # session, so a failure here would leave an orphaned 

3312 # "synthesized" note with no provenance record. Roll back the 

3313 # poisoned shared session and delete the just-created note 

3314 # before surfacing the error. 

3315 logger.exception( 

3316 "Failed to persist synthesis record for note {}; " 

3317 "deleting the orphaned note", 

3318 new_note_id, 

3319 ) 

3320 NoteService._rollback_quietly(db_session) 

3321 try: 

3322 note_service.delete_note(new_note_id) 

3323 except Exception: 

3324 logger.exception( 

3325 "Failed to delete orphaned synthesized note {}", 

3326 new_note_id, 

3327 ) 

3328 raise 

3329 return new_note_id 

3330 

3331 result["note_id"] = await run_db_sync( 

3332 _create_note_and_record_synthesis 

3333 ) 

3334 

3335 status_code = 201 if create_note else 200 

3336 return JSONResponse( 

3337 {"success": True, "result": result}, status_code=status_code 

3338 ) 

3339 

3340 except LLMNotConfiguredError: 

3341 # Same contract as suggest-tags: an unconfigured LLM is a 

3342 # client-state problem, not a 500. See 

3343 # ``_llm_not_configured_response``. 

3344 return _llm_not_configured_response("synthesize") 

3345 except Exception as e: 

3346 return handle_api_error("synthesizing notes", e) 

3347 

3348 

3349# ============================================================================ 

3350# API Routes - Version History 

3351# ============================================================================ 

3352 

3353 

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

3355def get_note_versions( 

3356 request: Request, 

3357 note_id: str, 

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

3359): 

3360 """ 

3361 Get version history for a note. 

3362 

3363 Query params: 

3364 limit: Maximum number of versions to return (default 20, max 200) 

3365 offset: Number of versions to skip (default 0). Combined with 

3366 limit, this makes older versions reachable when total > limit. 

3367 Pre-fix the route had no offset support, so versions 1 

3368 through (total - limit) were unreachable through the UI. 

3369 """ 

3370 from ...database.models import Document, NoteVersion 

3371 from ...database.session_context import get_user_db_session 

3372 

3373 note_service = NoteService(username) 

3374 

3375 try: 

3376 try: 

3377 limit = _clamp_limit(request, 20) 

3378 except (ValueError, TypeError): 

3379 return JSONResponse( 

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

3381 ) 

3382 

3383 try: 

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

3385 except (ValueError, TypeError): 

3386 return JSONResponse( 

3387 {"success": False, "error": "Invalid offset"}, status_code=400 

3388 ) 

3389 

3390 with get_user_db_session(username) as db_session: 

3391 # Defense-in-depth: confirm the document is a note before 

3392 # serving anything from its version history. Mirrors the 

3393 # guard on the sibling version routes (get_note_version, 

3394 # get_semantic_diff, restore_note_version). load_only keeps 

3395 # the guard from hydrating text_content (up to 50 MB) just 

3396 # to check the source type. 

3397 doc = ( 

3398 db_session.query(Document) 

3399 .options(load_only(Document.id, Document.source_type_id)) 

3400 .filter_by(id=note_id) 

3401 .first() 

3402 ) 

3403 if not doc or not note_service._is_note(db_session, doc): 

3404 return JSONResponse( 

3405 {"success": False, "error": "Note not found"}, 

3406 status_code=404, 

3407 ) 

3408 

3409 total = ( 

3410 db_session.query(NoteVersion) 

3411 .filter_by(document_id=note_id) 

3412 .count() 

3413 ) 

3414 # Cap offset at total: an unbounded OFFSET (e.g. 

3415 # ?offset=1_000_000_000) forces SQLite to row-walk the 

3416 # whole table in a backward index scan even though the 

3417 # result is empty. Clamping is cheap and harmless — a real 

3418 # caller with offset > total wants the empty page anyway. 

3419 if offset > total: 3419 ↛ 3420line 3419 didn't jump to line 3420 because the condition on line 3419 was never true

3420 offset = total 

3421 # Column projection: each NoteVersion row carries a full 

3422 # content snapshot (up to the 50 MB cap); loading whole 

3423 # entities transferred `limit` note bodies per call to render 

3424 # a metadata-only list. 

3425 versions = ( 

3426 db_session.query( 

3427 NoteVersion.id, 

3428 NoteVersion.title, 

3429 NoteVersion.change_type, 

3430 NoteVersion.change_summary, 

3431 NoteVersion.created_at, 

3432 ) 

3433 .filter_by(document_id=note_id) 

3434 .order_by( 

3435 NoteVersion.created_at.desc(), 

3436 NoteVersion.id.desc(), # id tiebreaker for stable paging 

3437 ) 

3438 .offset(offset) 

3439 .limit(limit) 

3440 .all() 

3441 ) 

3442 return { 

3443 "success": True, 

3444 "total": total, 

3445 "limit": limit, 

3446 "offset": offset, 

3447 "versions": [ 

3448 { 

3449 "id": v.id, 

3450 # Global version index — newest is `total`, 

3451 # oldest is 1. Stable across pages. 

3452 "version_number": total - offset - i, 

3453 "title": v.title, 

3454 "change_type": v.change_type, 

3455 "change_summary": v.change_summary, 

3456 "created_at": v.created_at.isoformat() 

3457 if v.created_at 

3458 else None, 

3459 } 

3460 for i, v in enumerate(versions) 

3461 ], 

3462 } 

3463 

3464 except Exception as e: 

3465 return handle_api_error("getting note versions", e) 

3466 

3467 

3468@router.get("/api/notes/{note_id}/versions/semantic-diff") 

3469@_notes_ai_limit 

3470async def get_semantic_diff( 

3471 request: Request, 

3472 note_id: str, 

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

3474): 

3475 """ 

3476 Get AI-powered semantic diff between two versions. 

3477 

3478 Query params: 

3479 version1: First version UUID 

3480 version2: Second version UUID, or the literal "current" to diff 

3481 against the note's current content. 

3482 """ 

3483 from ...database.models import Document, NoteVersion 

3484 from ...database.session_context import get_user_db_session 

3485 

3486 note_service = NoteService(username) 

3487 

3488 try: 

3489 version1 = request.query_params.get("version1") 

3490 version2 = request.query_params.get("version2") 

3491 

3492 if not version1 or not version2: 

3493 return JSONResponse( 

3494 { 

3495 "success": False, 

3496 "error": "version1 and version2 are required", 

3497 }, 

3498 status_code=400, 

3499 ) 

3500 

3501 def _load_diff_versions(): 

3502 # Runs on a worker thread via run_db_sync: the version fetch 

3503 # opens a thread-local DB session, which must stay off the 

3504 # event loop (#6043). Returns ("note"|"version"|None, c1, c2): 

3505 # the first element names the 404 flavor, the strings are the 

3506 # two contents to diff (None when erroring). 

3507 with get_user_db_session(username) as db_session: 

3508 # Defense-in-depth: confirm the document is a note before 

3509 # serving anything from its version history. load_only defers 

3510 # text_content; the "current" branch below lazy-loads it only 

3511 # when actually diffing against the live note. 

3512 doc = ( 

3513 db_session.query(Document) 

3514 .options(load_only(Document.id, Document.source_type_id)) 

3515 .filter_by(id=note_id) 

3516 .first() 

3517 ) 

3518 if not doc or not note_service._is_note(db_session, doc): 

3519 return "note", None, None 

3520 

3521 v1 = ( 

3522 db_session.query(NoteVersion) 

3523 .filter_by(document_id=note_id, id=version1) 

3524 .first() 

3525 ) 

3526 if not v1: 

3527 return "version", None, None 

3528 

3529 if version2 == "current": 

3530 return None, v1.content, doc.text_content or "" 

3531 

3532 v2 = ( 

3533 db_session.query(NoteVersion) 

3534 .filter_by(document_id=note_id, id=version2) 

3535 .first() 

3536 ) 

3537 if not v2: 3537 ↛ 3539line 3537 didn't jump to line 3539 because the condition on line 3537 was always true

3538 return "version", None, None 

3539 return None, v1.content, v2.content 

3540 

3541 missing, v1_content, v2_content = await run_db_sync(_load_diff_versions) 

3542 if missing == "note": 

3543 return JSONResponse( 

3544 {"success": False, "error": "Note not found"}, 

3545 status_code=404, 

3546 ) 

3547 if missing == "version": 

3548 return JSONResponse( 

3549 {"success": False, "error": "Version not found"}, 

3550 status_code=404, 

3551 ) 

3552 

3553 ai_service = NoteAIService(username) 

3554 diff = await ai_service.semantic_diff(v1_content, v2_content) 

3555 

3556 return {"success": True, "diff": diff} 

3557 

3558 except LLMNotConfiguredError: 

3559 # Same contract as suggest-tags: an unconfigured LLM is a 

3560 # client-state problem, not a 500. See 

3561 # ``_llm_not_configured_response``. 

3562 return _llm_not_configured_response("semantic-diff") 

3563 except Exception as e: 

3564 return handle_api_error("getting semantic diff", e) 

3565 

3566 

3567@router.get("/api/notes/{note_id}/versions/{version_id}") 

3568def get_note_version( 

3569 request: Request, 

3570 note_id: str, 

3571 version_id: str, 

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

3573): 

3574 """Get a specific version of a note.""" 

3575 from ...database.models import Document, NoteVersion 

3576 from ...database.session_context import get_user_db_session 

3577 

3578 note_service = NoteService(username) 

3579 

3580 try: 

3581 with get_user_db_session(username) as db_session: 

3582 # Defense-in-depth: confirm the parent document is a note. 

3583 # load_only keeps the guard from hydrating text_content. 

3584 doc = ( 

3585 db_session.query(Document) 

3586 .options(load_only(Document.id, Document.source_type_id)) 

3587 .filter_by(id=note_id) 

3588 .first() 

3589 ) 

3590 if not doc or not note_service._is_note(db_session, doc): 

3591 return JSONResponse( 

3592 {"success": False, "error": "Note not found"}, 

3593 status_code=404, 

3594 ) 

3595 

3596 version = ( 

3597 db_session.query(NoteVersion) 

3598 .filter_by(document_id=note_id, id=version_id) 

3599 .first() 

3600 ) 

3601 

3602 if not version: 

3603 return JSONResponse( 

3604 {"success": False, "error": "Version not found"}, 

3605 status_code=404, 

3606 ) 

3607 

3608 return { 

3609 "success": True, 

3610 "version": { 

3611 "id": version.id, 

3612 "title": version.title, 

3613 "content": version.content, 

3614 "tags": version.tags, 

3615 "change_type": version.change_type, 

3616 "change_summary": version.change_summary, 

3617 "created_at": version.created_at.isoformat() 

3618 if version.created_at 

3619 else None, 

3620 }, 

3621 } 

3622 

3623 except Exception as e: 

3624 return handle_api_error("getting note version", e) 

3625 

3626 

3627@router.post("/api/notes/{note_id}/versions/{version_id}/restore") 

3628@_notes_write_limit 

3629def restore_note_version( 

3630 request: Request, 

3631 note_id: str, 

3632 version_id: str, 

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

3634 body: _NotesBody, 

3635): 

3636 """Restore a note to a previous version. 

3637 

3638 Delegates to ``NoteService.restore_with_bookends`` which wraps 

3639 PRE_RESTORE + content update + RESTORE in a single transaction. 

3640 Earlier code orchestrated this from three separate sessions — process 

3641 death between writes could leave a restored note with no audit-trail 

3642 row. Keep the orchestration in the service; this handler just routes. 

3643 """ 

3644 if isinstance(body, JSONResponse): 

3645 return body 

3646 

3647 note_service = NoteService(username) 

3648 

3649 try: 

3650 success, error = note_service.restore_with_bookends(note_id, version_id) 

3651 if not success: 

3652 if error == "note_not_found": 3652 ↛ 3657line 3652 didn't jump to line 3657 because the condition on line 3652 was always true

3653 return JSONResponse( 

3654 {"success": False, "error": "Note not found"}, 

3655 status_code=404, 

3656 ) 

3657 if error == "version_not_found": 

3658 return JSONResponse( 

3659 {"success": False, "error": "Version not found"}, 

3660 status_code=404, 

3661 ) 

3662 return JSONResponse( 

3663 {"success": False, "error": "Failed to restore version"}, 

3664 status_code=500, 

3665 ) 

3666 

3667 _trigger_note_auto_index( 

3668 note_id, note_service, username, request.session.get("session_id") 

3669 ) 

3670 

3671 return { 

3672 "success": True, 

3673 "message": f"Restored to version {version_id[:8]}", 

3674 } 

3675 

3676 except Exception as e: 

3677 return handle_api_error("restoring note version", e)