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
« 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).
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:
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).
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"""
36import asyncio
37import builtins
38import itertools
39import json
40import math
41import reprlib
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
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
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
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
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
97def _clamp_limit(request: Request, default, max_=MAX_LIMIT):
98 """Parse ``?limit`` into ``[1, max_]``, defaulting to ``default``.
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_))
106def _clamp_text_query(value, name, max_len=MAX_SEARCH_LEN):
107 """Return value or raise ValueError if it exceeds the cap.
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.
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``.
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
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
201class _RedactingRepr(reprlib.Repr):
202 """``reprlib.Repr`` that redacts each string before shortening it.
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 """
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
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
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)
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
259_log_value_repr = _RedactingRepr()
262def _log_value_preview(value: Any) -> str:
263 """Bounded, ``logger.warning``-safe preview of an untrusted JSON value.
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.
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.
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:
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.
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.
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 )
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
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.
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.
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.
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.
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.
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.
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
540def _llm_not_configured_response(operation: str) -> JSONResponse:
541 """400 for a notes AI route whose LLM is not configured.
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.
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.
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 )
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)
626router = APIRouter(prefix="/notes", tags=["notes"])
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
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
657async def _parse_json_off_loop(body):
658 """``json.loads`` without stalling the event loop on a large body.
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).
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)
673async def _notes_json_body(
674 request: Request,
675) -> dict[str, Any] | JSONResponse:
676 """Shared body gate for every mutating notes route.
678 Replaces the blueprint's two ``before_request`` hooks:
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.
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 """
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 )
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()
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()
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 {}
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 {}
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
755_NotesBody = Annotated[
756 dict[str, Any] | JSONResponse,
757 Depends(_notes_json_body),
758]
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
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")
788# ============================================================================
789# Page Routes
790# ============================================================================
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 )
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
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 )
822# ============================================================================
823# API Routes - Notes CRUD
824# ============================================================================
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.
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)
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 )
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
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 )
917 return {
918 "success": True,
919 "notes": notes,
920 "total": total,
921 "limit": limit,
922 "offset": offset,
923 }
925 except Exception as e:
926 return handle_api_error("listing notes", e)
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.
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
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 )
960 title = data.get("title")
961 content = data.get("content")
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 )
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 )
982 # Trigger async auto-indexing (non-blocking)
983 _trigger_note_auto_index(
984 note_id, service, username, request.session.get("session_id")
985 )
987 return JSONResponse({"success": True, "id": note_id}, status_code=201)
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)
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# ---------------------------------------------------------------------------
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.
1017 Finds notes by meaning rather than keyword matching.
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 )
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 )
1067 collection_id = request.query_params.get("collection_id") or None
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 )
1077 return {
1078 "success": True,
1079 "query": query,
1080 "results": results,
1081 "count": len(results),
1082 }
1084 except Exception as e:
1085 return handle_api_error("semantic search", e)
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)
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.
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": []}
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 )
1144 service = NoteService(username)
1145 notes = service.search_notes_for_linking(query, exclude_note_id, limit)
1147 return {"success": True, "notes": notes}
1149 except Exception as e:
1150 return handle_api_error("searching notes for linking", e)
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)
1164 if not note:
1165 return JSONResponse(
1166 {"success": False, "error": "Note not found"}, status_code=404
1167 )
1169 return {"success": True, "note": note}
1171 except Exception as e:
1172 return handle_api_error("getting note", e)
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.
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
1196 try:
1197 data = body
1198 if not data:
1199 return JSONResponse(
1200 {"success": False, "error": "No data provided"},
1201 status_code=400,
1202 )
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 )
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 )
1231 if not success:
1232 return JSONResponse(
1233 {"success": False, "error": "Note not found"}, status_code=404
1234 )
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 )
1249 return {"success": True}
1251 except Exception as e:
1252 return handle_api_error("updating note", e)
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)
1267 if not success:
1268 return JSONResponse(
1269 {"success": False, "error": "Note not found"}, status_code=404
1270 )
1272 return {"success": True}
1274 except Exception as e:
1275 return handle_api_error("deleting note", e)
1278# ============================================================================
1279# API Routes - Collections
1280# ============================================================================
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)
1303 return {"success": True, "collections": collections}
1305 except Exception as e:
1306 return handle_api_error("getting note collections", e)
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.
1320 Body:
1321 collection_id: The collection ID
1322 """
1323 if isinstance(body, JSONResponse):
1324 return body
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 )
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"])
1345 if not success:
1346 return JSONResponse(
1347 {"success": False, "error": "Already in collection"},
1348 status_code=409,
1349 )
1351 return {"success": True}
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)
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)
1381 if not success:
1382 return JSONResponse(
1383 {"success": False, "error": "Not in collection"},
1384 status_code=404,
1385 )
1387 return {"success": True}
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)
1401# ============================================================================
1402# API Routes - Research
1403# ============================================================================
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)
1423 return {"success": True, "research": research}
1425 except Exception as e:
1426 return handle_api_error("getting note research", e)
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.
1440 Body:
1441 research_id: The research history ID
1442 """
1443 if isinstance(body, JSONResponse):
1444 return body
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 )
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 )
1464 return JSONResponse({"success": True, "id": link_id}, status_code=201)
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)
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).
1528 Body:
1529 is_collapsed: bool (optional)
1530 """
1531 if isinstance(body, JSONResponse):
1532 return body
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}
1568 except Exception as e:
1569 return handle_api_error("updating note research", e)
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.
1582 Body:
1583 research_ids: list of research_id strings in desired display order.
1584 """
1585 if isinstance(body, JSONResponse):
1586 return body
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 )
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}
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)
1641# ============================================================================
1642# API Routes - Research → Notes (results-page Notes panel)
1643# ============================================================================
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.
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}
1669 except Exception as e:
1670 return handle_api_error("listing notes for research", e)
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).
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
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 )
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)
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.
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
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
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 {})
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)
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 )
1795 from ...research_library.notes.services.note_service import (
1796 MAX_TITLE_LENGTH,
1797 )
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 )
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 )
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)
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# ============================================================================
1848MAX_ANNOTATION_COMMENT_LEN = 5000
1849MAX_ANNOTATION_QUOTE_LEN = 1000
1850MAX_ANNOTATION_CONTEXT_LEN = 100
1853def _validated_annotation_fields(data):
1854 """Validate/trim an annotation payload; raises ValueError.
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
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}"
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
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 }
1911def _comment_note_content(comment, quote, target_label, target_url):
1912 """Comment-first note body: remark, quoted passage, provenance.
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 )
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
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()
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}
1962 except Exception as e:
1963 return handle_api_error("listing research annotations", e)
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.
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
1984 try:
1985 data = body
1986 comment, quote, prefix, suffix = _validated_annotation_fields(data)
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 )
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 )
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)
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}
2055 except Exception as e:
2056 return handle_api_error("deleting research annotation", e)
2059# ---- Library documents: same surface, document targets --------------------
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}
2080 except Exception as e:
2081 return handle_api_error("listing notes for document", e)
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).
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
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 )
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)
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}
2153 except Exception as e:
2154 return handle_api_error("listing document annotations", e)
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
2169 try:
2170 data = body
2171 comment, quote, prefix, suffix = _validated_annotation_fields(data)
2173 from ...database.models import Document
2174 from ...database.session_context import get_user_db_session
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
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 )
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)
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}
2249 except Exception as e:
2250 return handle_api_error("deleting document annotation", e)
2253# ============================================================================
2254# API Routes - RAG Indexing
2255# ============================================================================
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.
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
2275 if isinstance(body, JSONResponse):
2276 return body
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 )
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 )
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 )
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 )
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 )
2333 return {"success": True, "result": result}
2335 except Exception as e:
2336 return handle_api_error("indexing note", e)
2339# ============================================================================
2340# API Routes - AI Features
2341# (semantic_search_notes is registered above, before GET /api/notes/{note_id})
2342# ============================================================================
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.
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)
2376 return {"success": True, "similar_notes": similar}
2378 except Exception as e:
2379 return handle_api_error("finding similar notes", e)
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
2394 try:
2395 service = NoteAIService(username)
2396 summary = await service.summarize_note(note_id)
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 )
2407 return {"success": True, "summary": summary}
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)
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
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)
2439 return {"success": True, "questions": questions}
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)
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.
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
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 )
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"])
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 )
2515 service = NoteAIService(username)
2516 tags = await service.suggest_tags(
2517 content=data["content"],
2518 existing_tags=existing_tags,
2519 )
2521 return {"success": True, "tags": tags}
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)
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
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)
2560 return {"success": True, "concepts": concepts}
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)
2571# ============================================================================
2572# API Routes - Smart Note Linking
2573# ============================================================================
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)
2594 return {
2595 "success": True,
2596 "backlinks": backlinks,
2597 "total": len(backlinks),
2598 }
2600 except Exception as e:
2601 return handle_api_error("getting backlinks", e)
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)
2621 return {
2622 "success": True,
2623 "links": links,
2624 "total": len(links),
2625 }
2627 except Exception as e:
2628 return handle_api_error("getting outgoing links", e)
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)
2656 return {"success": True, "suggestions": suggestions}
2658 except Exception as e:
2659 return handle_api_error("getting suggested links", e)
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.
2673 Body:
2674 target_note_id: The ID of the note to link to.
2675 """
2676 if isinstance(body, JSONResponse):
2677 return body
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 )
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 )
2696 return {"success": True, "note": result}
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)
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.
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)
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
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.
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
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]
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 )
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)
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# ============================================================================
2789# ============================================================================
2790# API Routes - Verify Note with Research (fact-check)
2791# ============================================================================
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.
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
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 )
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}
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)
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']``.
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).
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 )
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 {})
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
2902 if status != ResearchStatus.COMPLETED:
2903 return {
2904 "success": False,
2905 "error": "Research is not complete yet.",
2906 "status": status,
2907 }, 409
2909 settings_snapshot = meta.get("settings_snapshot")
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
2933 report, sources = await run_db_sync(_fetch_report_and_sources)
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
2948 ai = NoteAIService(username)
2949 verdicts = await ai.grade_all_claims(claims, report, sources)
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
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
2991 return {"success": True, "verdicts": verdicts}, 200
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.
3005 Body: ``{"claims": ["...", ...]}`` — the claims returned by /fact-check.
3006 """
3007 if isinstance(body, JSONResponse):
3008 return body
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 )
3030 payload, status_code = await _grade_note_claims(
3031 username, note_id, research_id, claims
3032 )
3033 return JSONResponse(payload, status_code=status_code)
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)
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.
3054 Body:
3055 link_text: The text inside [[brackets]]
3056 """
3057 if isinstance(body, JSONResponse):
3058 return body
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 )
3095 service = NoteService(username)
3096 result = service.resolve_link(link_text)
3098 if result:
3099 return {"success": True, "note": result}
3100 return JSONResponse(
3101 {"success": False, "error": "Note not found"}, status_code=404
3102 )
3104 except Exception as e:
3105 return handle_api_error("resolving link", e)
3108# ============================================================================
3109# API Routes - Note Synthesis
3110# ============================================================================
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.
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
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 )
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 )
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)
3170 if "error" in preview:
3171 return JSONResponse(
3172 {"success": False, "error": preview["error"]},
3173 status_code=400,
3174 )
3176 return {"success": True, "result": preview}
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)
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.
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
3204 if isinstance(body, JSONResponse):
3205 return body
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 )
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 )
3258 ai_service = NoteAIService(username)
3259 result = await ai_service.synthesize_notes(note_ids, synthesis_type)
3261 if "error" in result:
3262 return JSONResponse(
3263 {"success": False, "error": result["error"]},
3264 status_code=400,
3265 )
3267 # Create the note if requested
3268 if create_note:
3269 note_service = NoteService(username)
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 )
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
3290 filtered_source_ids = [
3291 src["id"]
3292 for src in result.get("source_notes", [])
3293 if "id" in src
3294 ]
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
3331 result["note_id"] = await run_db_sync(
3332 _create_note_and_record_synthesis
3333 )
3335 status_code = 201 if create_note else 200
3336 return JSONResponse(
3337 {"success": True, "result": result}, status_code=status_code
3338 )
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)
3349# ============================================================================
3350# API Routes - Version History
3351# ============================================================================
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.
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
3373 note_service = NoteService(username)
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 )
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 )
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 )
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 }
3464 except Exception as e:
3465 return handle_api_error("getting note versions", e)
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.
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
3486 note_service = NoteService(username)
3488 try:
3489 version1 = request.query_params.get("version1")
3490 version2 = request.query_params.get("version2")
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 )
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
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
3529 if version2 == "current":
3530 return None, v1.content, doc.text_content or ""
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
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 )
3553 ai_service = NoteAIService(username)
3554 diff = await ai_service.semantic_diff(v1_content, v2_content)
3556 return {"success": True, "diff": diff}
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)
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
3578 note_service = NoteService(username)
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 )
3596 version = (
3597 db_session.query(NoteVersion)
3598 .filter_by(document_id=note_id, id=version_id)
3599 .first()
3600 )
3602 if not version:
3603 return JSONResponse(
3604 {"success": False, "error": "Version not found"},
3605 status_code=404,
3606 )
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 }
3623 except Exception as e:
3624 return handle_api_error("getting note version", e)
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.
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
3647 note_service = NoteService(username)
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 )
3667 _trigger_note_auto_index(
3668 note_id, note_service, username, request.session.get("session_id")
3669 )
3671 return {
3672 "success": True,
3673 "message": f"Restored to version {version_id[:8]}",
3674 }
3676 except Exception as e:
3677 return handle_api_error("restoring note version", e)