Coverage for src/local_deep_research/web/routers/notes.py: 84%
1032 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Notes Routes - API endpoints and pages for the notes feature (FastAPI).
4Ported from the Flask ``web/routes/notes_routes.py`` blueprint (``notes_bp``,
5url_prefix ``/notes``). Every endpoint is mirrored exactly — same paths,
6methods, status codes, service calls, validation, and error messages.
8Notes on the port:
9 * ``@login_required`` → ``Depends(require_auth)``; the per-route manual
10 ``session.get("username")`` / 401 guard is dropped because ``require_auth``
11 already raises 401 when there is no authenticated user.
12 * The two blueprint-wide ``before_request`` hooks (``_reject_oversized_bodies``
13 and ``_reject_non_object_json_body``) are collapsed into the shared
14 ``_notes_json_body`` dependency, injected into every mutating route.
15 * Flask ``request.args`` → ``request.query_params``; ``session[...]`` →
16 the ``username`` dependency / ``request.session``.
17 * Route registration order is deliberate: the literal ``/api/notes/...`` GET
18 routes are registered BEFORE ``GET /api/notes/{note_id}`` because Starlette
19 matches routes in registration order (Flask's map gave static rules
20 priority automatically; FastAPI does not).
21"""
23import asyncio
24import json
25import math
26import reprlib
28from fastapi import APIRouter, Depends, Request
29from fastapi.responses import JSONResponse
30from loguru import logger
31from sqlalchemy.exc import IntegrityError
32from sqlalchemy.orm import load_only
34from ...research_library.notes.services.note_ai_service import NoteAIService
35from ...research_library.notes.services.note_service import (
36 MAX_TAG_LENGTH,
37 MAX_TAGS_PER_NOTE,
38 NOTE_CONTENT_MAX_BYTES,
39 NoteService,
40 _assert_content_size,
41 escape_markdown_link_label,
42)
43from ...research_library.utils import handle_api_error
44from ..dependencies.auth import require_auth
45from ..dependencies.rate_limit import limiter, _user_key
46from ..template_config import templates
48# Cap on user-supplied free-text query params that flow into `ILIKE %...%`
49# over unbounded TEXT columns — shared with unified_search_routes via
50# _search_constants so the two route modules can't diverge. The router lives
51# in web/routers/, so reach the file in web/routes/.
52from ..routes._search_constants import MAX_SEARCH_LEN
53from typing import Annotated, Any
55# Bound at import time (not looked up on the class at call time) because
56# tests monkeypatch the ``NoteAIService`` name in this module.
57_MAX_GRADE_SOURCES = NoteAIService.MAX_GRADE_SOURCES
59MAX_LINK_TEXT_LEN = 500
60# Per-claim length cap for the fact-check grade endpoint (claims are free text
61# from the client; the grader bounds the count but not the per-string size).
62MAX_CLAIM_LEN = 1000
63# Upper bound on any ?limit query param across the list/search endpoints
64# (was an unnamed 200 repeated at six call sites).
65MAX_LIMIT = 200
68def _clamp_limit(request: Request, default, max_=MAX_LIMIT):
69 """Parse ``?limit`` into ``[1, max_]``, defaulting to ``default``.
71 Raises ``ValueError`` on a non-integer value; the caller wraps this in
72 ``try`` and returns the 400 (matching the six former inline copies).
73 """
74 return max(1, min(int(request.query_params.get("limit", default)), max_))
77def _clamp_text_query(value, name, max_len=MAX_SEARCH_LEN):
78 """Return value or raise ValueError if it exceeds the cap.
80 ``None``/empty pass through unchanged so optional params behave the
81 same as before. The caller wraps this in ``try`` and lets
82 ``handle_api_error`` produce the 4xx response.
83 """
84 if value is None:
85 return value
86 if not isinstance(value, str): 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true
87 raise ValueError(f"{name} must be a string")
88 if len(value) > max_len:
89 raise ValueError(f"{name} exceeds maximum length ({max_len} chars)")
90 return value
93# Bounded repr for an untrusted value going into an audit-log line. A naive
94# ``repr(value)[:100]`` slices AFTER building the full repr — for a value
95# that came out of ``_notes_json_body`` (up to the 100 MB notes JSON body
96# cap, see ``_MAX_JSON_BODY_BYTES`` above) that means materializing a second
97# ~100 MB+ string (more if it's escape-heavy, since ``repr`` expands each
98# control char to ``\xNN``) just to throw all but 100 chars away. Same
99# problem for a huge list/dict: ``repr()`` walks and stringifies every
100# element before any truncation happens.
101#
102# ``reprlib.Repr`` avoids this: its container/string methods slice or
103# ``itertools.islice`` BEFORE recursing/stringifying (see
104# ``reprlib.Repr.repr_str`` / ``repr_list`` / ``repr_dict`` in the stdlib),
105# so cost is bounded by the limits below regardless of the input's real
106# size. ``maxlevel`` also caps recursion depth for a deeply nested dict.
107_log_value_repr = reprlib.Repr(
108 maxlevel=4,
109 maxtuple=10,
110 maxlist=10,
111 maxdict=10,
112 maxset=10,
113 maxfrozenset=10,
114 maxstring=100,
115 maxlong=100,
116 maxother=100,
117)
120def _log_value_preview(value: Any) -> str:
121 """Bounded, ``logger.warning``-safe preview of an untrusted JSON value.
123 Use in place of ``repr(value)[:100]`` at any audit-log site whose value
124 comes from request input (query params, and especially JSON bodies,
125 which this router allows up to ~100 MB). See ``_log_value_repr`` above
126 for why plain ``repr()[:100]`` is unsafe here.
128 The final ``[:100]`` is a cheap backstop, not the primary defense — the
129 limits on ``_log_value_repr`` already keep the *work* bounded; this just
130 keeps the *output* bounded too (e.g. a container's per-element reprs can
131 still sum past 100 chars before ellipsis is added).
132 """
133 return _log_value_repr.repr(value)[:100]
136# Rate limits for AI-heavy endpoints. Split into two buckets so cheap
137# FAISS / local-embedding lookups (semantic search, similar notes,
138# suggested links, related research) don't drain the same budget as
139# expensive LLM calls (summarize, suggest_tags, extract_key_concepts,
140# semantic_diff). A user typing through the autocomplete UI shouldn't
141# block a single LLM call for a full minute.
142#
143# ``key_func=_user_key`` keys each bucket per authenticated user; without it
144# the global Limiter default (client IP) would let shared-IP users (NAT, lab,
145# reverse-proxy-without-trusted-forward-headers) drain each other's
146# LLM/synthesis budgets.
147_notes_ai_limit = limiter.shared_limit(
148 "10 per minute", scope="notes_ai", key_func=_user_key
149)
150_notes_search_limit = limiter.shared_limit(
151 "60 per minute", scope="notes_search", key_func=_user_key
152)
153_notes_synthesize_limit = limiter.shared_limit(
154 "5 per minute", scope="notes_synthesize", key_func=_user_key
155)
156# Notes write budget — applies to create_note, update_note, delete_note,
157# and add/remove from collection. 60/min comfortably covers active
158# editing (a save every second for a minute) but blocks scripted abuse:
159# pre-fix an authenticated user could rapid-POST PUT /api/notes/<id>
160# under the global IP cap (5000/hr) and saturate the 4-worker
161# summary_executor with bursty LLM change-summary tasks, queueing
162# hundreds of MB of (old, new) content pairs. Per-user keying so two
163# real users on shared NAT don't compete for the same bucket.
164_notes_write_limit = limiter.shared_limit(
165 "60 per minute", scope="notes_write", key_func=_user_key
166)
167# Save-as-note copies a whole completed report (up to the 50MB content
168# cap) into a fresh note + version snapshot per call. At the generic
169# notes_write 60/min that's on the order of GBs/min of per-user DB writes
170# from one client — a disk/backup-bloat vector. Tighter, cost-aware bucket.
171_notes_save_as_note_limit = limiter.shared_limit(
172 "10 per minute", scope="notes_save_as_note", key_func=_user_key
173)
174# Fact-check kicks off a full LDR research run downstream, so it's strictly
175# tighter than the LLM bucket — a few per minute bounds research load.
176_notes_factcheck_limit = limiter.shared_limit(
177 "3 per minute", scope="notes_factcheck", key_func=_user_key
178)
180router = APIRouter(prefix="/notes", tags=["notes"])
182# Pre-parse request-body ceiling for this JSON API surface. The app-wide
183# request size limit is sized for multi-file uploads (hundreds of GB), and
184# parsing buffers the WHOLE body before any service validation runs — so
185# without this check a multi-GB JSON body reached memory before
186# _assert_content_size could reject it at 50 MB.
187#
188# Budget 2× the content cap. This bounds pre-parse memory; it does NOT
189# precisely enforce the 50 MB content limit (that stays in
190# _assert_content_size after parsing). 2× covers the realistic worst case
191# where every character needs a 2-byte JSON escape (``"`` / ``\`` /
192# newline); content dominated by control chars (each expands to a 6-byte
193# ``\uXXXX``) can still exceed this and get a 413 even though its decoded
194# size is under 50 MB — an acceptable trade for keeping the cap far below
195# a memory-exhausting multi-GB body.
196_MAX_JSON_BODY_BYTES = 2 * NOTE_CONTENT_MAX_BYTES
199#: Above this size the JSON parse is handed to a worker thread. Measured on
200#: the pinned interpreter with an adversarial-but-valid document (many small
201#: objects, which is what an attacker sends -- not one long string, which is
202#: what a benign client sends), ``json.loads`` runs at ~110 ms/MB and scales
203#: linearly. So 64 KiB is ~7 ms on the loop, and the notes ceiling of 100 MB
204#: would be ~11 SECONDS -- a whole-instance freeze under single-worker
205#: uvicorn, where Flask's thread-per-request model only cost one thread.
206#: Below the threshold the parse is sub-10 ms and dispatching it would trade
207#: that for contention on the same threadpool the DB work uses.
208_JSON_OFFLOAD_THRESHOLD_BYTES = 64 * 1024
211async def _parse_json_off_loop(body):
212 """``json.loads`` without stalling the event loop on a large body.
214 ``asyncio.to_thread`` rather than ``run_db_sync``: this opens no DB
215 session, so the session cleanup that wrapper does is unnecessary
216 (see dependencies/threadpool.py::run_db_sync).
218 The bytearray is passed through without copying to ``bytes`` -- at the
219 notes cap that copy would itself be a 100 MB allocation, and nothing
220 mutates the buffer after this point.
221 """
222 if len(body) < _JSON_OFFLOAD_THRESHOLD_BYTES: 222 ↛ 224line 222 didn't jump to line 224 because the condition on line 222 was always true
223 return json.loads(body)
224 return await asyncio.to_thread(json.loads, body)
227async def _notes_json_body(
228 request: Request,
229) -> dict[str, Any] | JSONResponse:
230 """Shared body gate for every mutating notes route.
232 Replaces the blueprint's two ``before_request`` hooks:
234 * 413 a body whose ``Content-Length`` exceeds ``_MAX_JSON_BODY_BYTES``
235 (bounds pre-parse memory before ``request.json()`` buffers it).
236 * 400 a declared-JSON body that parses to something other than an
237 object — ``true`` / ``123`` / ``"x"`` / ``[1, 2]`` would otherwise
238 slip past the routes' ``... or {}`` idioms and crash on the first
239 ``.get`` as an opaque 500.
241 Returns either a ``JSONResponse`` (the caller must return it verbatim) or
242 the parsed dict. A missing/empty/malformed body becomes ``{}`` so each
243 route applies its own "no data provided" default exactly as before.
244 """
246 def _too_large():
247 return JSONResponse(
248 {
249 "success": False,
250 "error": (
251 "Request body too large "
252 f"(max {_MAX_JSON_BODY_BYTES // (1024 * 1024)} MB)"
253 ),
254 },
255 status_code=413,
256 )
258 # Fast path: reject a body that DECLARES an over-cap Content-Length before
259 # reading anything.
260 content_length = request.headers.get("content-length")
261 if content_length is not None:
262 try:
263 declared = int(content_length)
264 except (TypeError, ValueError):
265 declared = None
266 if declared is not None and declared > _MAX_JSON_BODY_BYTES:
267 return _too_large()
269 # Read the body from the stream with a running cap, so a chunked request
270 # (``Transfer-Encoding: chunked``, no Content-Length) is cut off at the
271 # notes ceiling BEFORE it is fully buffered — the Flask blueprint got this
272 # from ``arm_notes_body_cap`` setting ``request.max_content_length`` at the
273 # stream level; the plain Content-Length check above cannot (a chunked
274 # body has no Content-Length to inspect). No notes route reads the body
275 # again after this dependency, so consuming the stream here is safe.
276 body = bytearray()
277 async for chunk in request.stream():
278 body.extend(chunk)
279 if len(body) > _MAX_JSON_BODY_BYTES:
280 return _too_large()
282 if not body:
283 # Missing/empty body — leave the route to default as it sees fit
284 # (matches Flask's ``get_json(silent=True) -> None``).
285 return {}
286 try:
287 data = await _parse_json_off_loop(body)
288 except Exception:
289 # Malformed JSON — same "leave the route to default" behavior.
290 return {}
292 if not isinstance(data, dict):
293 return JSONResponse(
294 {
295 "success": False,
296 "error": "Request body must be a JSON object",
297 },
298 status_code=400,
299 )
300 return data
303_NotesBody = Annotated[
304 dict[str, Any] | JSONResponse,
305 Depends(_notes_json_body),
306]
309def _trigger_note_auto_index(note_id, service, username, session_id):
310 """Trigger async auto-indexing for a note (non-blocking)."""
311 try:
312 from ...web.routers.rag import trigger_auto_index
313 from ...database.session_passwords import session_password_store
315 db_password = session_password_store.get_session_password(
316 username, session_id
317 )
318 if not db_password: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 logger.debug(
320 "Skipping note auto-index for {}: no session password available",
321 note_id,
322 )
323 return
324 collection_id = service.get_notes_collection_id()
325 if not collection_id: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 logger.debug(
327 "Skipping note auto-index for {}: no notes collection",
328 note_id,
329 )
330 return
331 trigger_auto_index([note_id], collection_id, username, db_password)
332 except Exception:
333 logger.exception("Failed to trigger auto-indexing for note")
336# ============================================================================
337# Page Routes
338# ============================================================================
341@router.get("/")
342def notes_page(
343 request: Request, username: Annotated[str, Depends(require_auth)]
344):
345 """Render the notes list page."""
346 return templates.TemplateResponse(
347 request=request,
348 name="pages/notes.html",
349 context={"active_page": "notes"},
350 )
353@router.get("/{note_id}")
354def note_detail_page(
355 request: Request,
356 note_id: str,
357 username: Annotated[str, Depends(require_auth)],
358):
359 """Render the note detail page."""
360 return templates.TemplateResponse(
361 request=request,
362 name="pages/note_detail.html",
363 context={"active_page": "notes", "note_id": note_id},
364 )
367# ============================================================================
368# API Routes - Notes CRUD
369# ============================================================================
372@router.get("/api/notes")
373@_notes_search_limit
374def list_notes(
375 request: Request, username: Annotated[str, Depends(require_auth)]
376):
377 """
378 List notes with optional filtering.
380 Query params:
381 collection_id: Filter by collection
382 search: Search in title and content (capped at MAX_SEARCH_LEN)
383 pinned_only: Only return pinned notes
384 limit: Maximum number (default 100)
385 offset: Offset for pagination (default 0)
386 """
387 try:
388 service = NoteService(username)
390 collection_id = request.query_params.get("collection_id")
391 try:
392 search = _clamp_text_query(
393 request.query_params.get("search"), "search"
394 )
395 except ValueError as exc:
396 return JSONResponse(
397 {"success": False, "error": str(exc)}, status_code=400
398 )
399 pinned_only = (
400 request.query_params.get("pinned_only", "").lower() == "true"
401 )
402 try:
403 limit = _clamp_limit(request, 100)
404 offset = max(0, int(request.query_params.get("offset", 0)))
405 except (ValueError, TypeError):
406 return JSONResponse(
407 {"success": False, "error": "Invalid limit or offset"},
408 status_code=400,
409 )
411 # Total under the same filter — lets the frontend render a real
412 # page-N-of-M control rather than guessing from len(notes) ==
413 # limit. count_notes repeats the filtered scan list_notes runs
414 # (for a keyword search that is a second full ILIKE pass over
415 # text_content), so skip it when a short first page already
416 # reveals the total.
417 if offset == 0:
418 notes = service.list_notes(
419 collection_id=collection_id,
420 search=search,
421 pinned_only=pinned_only,
422 limit=limit,
423 offset=0,
424 )
425 total = (
426 len(notes)
427 if len(notes) < limit
428 else service.count_notes(
429 collection_id=collection_id,
430 search=search,
431 pinned_only=pinned_only,
432 )
433 )
434 else:
435 total = service.count_notes(
436 collection_id=collection_id,
437 search=search,
438 pinned_only=pinned_only,
439 )
440 # Clamp an offset past the end of the result set: it returns
441 # an empty page anyway, so don't make the DB scan-and-discard
442 # `offset` rows for a guaranteed-empty result (mirrors
443 # get_note_versions).
444 if offset > total:
445 offset = total
447 notes = service.list_notes(
448 collection_id=collection_id,
449 search=search,
450 pinned_only=pinned_only,
451 limit=limit,
452 offset=offset,
453 )
455 return {
456 "success": True,
457 "notes": notes,
458 "total": total,
459 "limit": limit,
460 "offset": offset,
461 }
463 except Exception as e:
464 return handle_api_error("listing notes", e)
467@router.post("/api/notes")
468@_notes_write_limit
469def create_note(
470 request: Request,
471 username: Annotated[str, Depends(require_auth)],
472 body: _NotesBody,
473):
474 """
475 Create a new note.
477 Body:
478 title: Note title (required)
479 content: Note content (required)
480 tags: List of tags (optional)
481 collection_ids: List of collection IDs (optional)
482 """
483 if isinstance(body, JSONResponse):
484 return body
486 try:
487 data = body
488 if not isinstance(data, dict) or not data:
489 # get_json returns the parsed body verbatim, so a top-level JSON
490 # array/string/number would pass a bare truthiness check and then
491 # crash data.get() with AttributeError → opaque 500. Require an
492 # object.
493 return JSONResponse(
494 {"success": False, "error": "No data provided"},
495 status_code=400,
496 )
498 title = data.get("title")
499 content = data.get("content")
501 if not title:
502 return JSONResponse(
503 {"success": False, "error": "Title is required"},
504 status_code=400,
505 )
506 if not content:
507 return JSONResponse(
508 {"success": False, "error": "Content is required"},
509 status_code=400,
510 )
512 service = NoteService(username)
513 note_id = service.create_note(
514 title=title,
515 content=content,
516 tags=data.get("tags"),
517 collection_ids=data.get("collection_ids"),
518 )
520 # Trigger async auto-indexing (non-blocking)
521 _trigger_note_auto_index(
522 note_id, service, username, request.session.get("session_id")
523 )
525 return JSONResponse({"success": True, "id": note_id}, status_code=201)
527 except ValueError as e:
528 # Validation failures (content size, tag shape) — surface the
529 # actual message so the client can fix and retry.
530 return JSONResponse(
531 {"success": False, "error": str(e)}, status_code=400
532 )
533 except Exception as e:
534 return handle_api_error("creating note", e)
537# ---------------------------------------------------------------------------
538# Literal-path GET routes under /api/notes/... MUST be registered before
539# GET /api/notes/{note_id} below — Starlette matches routes in registration
540# order, so otherwise ``/api/notes/semantic-search`` etc. would be captured
541# by the ``{note_id}`` route. (Flask's URL map gave static rules priority
542# automatically; FastAPI does not.)
543# ---------------------------------------------------------------------------
546@router.get("/api/notes/semantic-search")
547@_notes_search_limit
548def semantic_search_notes(
549 request: Request, username: Annotated[str, Depends(require_auth)]
550):
551 """
552 Search notes using semantic similarity.
554 Finds notes by meaning rather than keyword matching.
556 Query params:
557 q: Search query (required)
558 limit: Maximum results (default 10)
559 min_similarity: Minimum similarity threshold 0.0-1.0 (default 0.3)
560 collection_id: Optional — restrict results to this collection's notes
561 (matches the text-search filter).
562 """
563 try:
564 try:
565 query = _clamp_text_query(request.query_params.get("q", ""), "q")
566 except ValueError as exc:
567 return JSONResponse(
568 {"success": False, "error": str(exc)}, status_code=400
569 )
570 query = (query or "").strip()
571 if not query:
572 return JSONResponse(
573 {"success": False, "error": "Query is required"},
574 status_code=400,
575 )
577 try:
578 limit = _clamp_limit(request, 10)
579 min_similarity_raw = float(
580 request.query_params.get("min_similarity", 0.3)
581 )
582 min_similarity = max(0.0, min(min_similarity_raw, 1.0))
583 except (ValueError, TypeError):
584 return JSONResponse(
585 {"success": False, "error": "Invalid limit or min_similarity"},
586 status_code=400,
587 )
588 if not math.isfinite(min_similarity_raw):
589 logger.warning(
590 "notes_api validation reject: min_similarity={!r} (non-finite) "
591 "(user={!r})",
592 min_similarity_raw,
593 username,
594 )
595 return JSONResponse(
596 {"success": False, "error": "Invalid limit or min_similarity"},
597 status_code=400,
598 )
600 collection_id = request.query_params.get("collection_id") or None
602 service = NoteAIService(username)
603 results = service.semantic_search(
604 query,
605 limit=limit,
606 min_similarity=min_similarity,
607 collection_id=collection_id,
608 )
610 return {
611 "success": True,
612 "query": query,
613 "results": results,
614 "count": len(results),
615 }
617 except Exception as e:
618 return handle_api_error("semantic search", e)
621@router.get("/api/notes/ask-context")
622@_notes_search_limit
623def ask_context(
624 request: Request, username: Annotated[str, Depends(require_auth)]
625):
626 """Pre-flight context for the 'Ask your notes' box: the Notes collection
627 id + note/indexed counts, so the UI can warn when there's nothing to
628 search before starting a research run pinned to that collection.
629 """
630 try:
631 service = NoteService(username)
632 status = service.get_notes_index_status()
633 return {"success": True, **status}
634 except Exception as e:
635 return handle_api_error("getting ask-notes context", e)
638@router.get("/api/notes/search-for-linking")
639@_notes_search_limit
640def search_notes_for_linking(
641 request: Request, username: Annotated[str, Depends(require_auth)]
642):
643 """
644 Search notes for the [[link]] autocomplete feature.
646 Query params:
647 query: Search query (partial title match)
648 exclude_note_id: Note ID to exclude from results
649 limit: Maximum number of results (default 10)
650 """
651 try:
652 try:
653 query = _clamp_text_query(
654 request.query_params.get("query", ""), "query"
655 )
656 except ValueError as exc:
657 return JSONResponse(
658 {"success": False, "error": str(exc)}, status_code=400
659 )
660 if not query:
661 return {"success": True, "notes": []}
663 exclude_note_id = request.query_params.get("exclude_note_id")
664 try:
665 limit = _clamp_limit(request, 10)
666 except (ValueError, TypeError):
667 return JSONResponse(
668 {"success": False, "error": "Invalid limit"}, status_code=400
669 )
671 service = NoteService(username)
672 notes = service.search_notes_for_linking(query, exclude_note_id, limit)
674 return {"success": True, "notes": notes}
676 except Exception as e:
677 return handle_api_error("searching notes for linking", e)
680@router.get("/api/notes/{note_id}")
681def get_note(
682 request: Request,
683 note_id: str,
684 username: Annotated[str, Depends(require_auth)],
685):
686 """Get a specific note."""
687 try:
688 service = NoteService(username)
689 note = service.get_note(note_id)
691 if not note:
692 return JSONResponse(
693 {"success": False, "error": "Note not found"}, status_code=404
694 )
696 return {"success": True, "note": note}
698 except Exception as e:
699 return handle_api_error("getting note", e)
702@router.put("/api/notes/{note_id}")
703@_notes_write_limit
704def update_note(
705 request: Request,
706 note_id: str,
707 username: Annotated[str, Depends(require_auth)],
708 body: _NotesBody,
709):
710 """
711 Update a note.
713 Body:
714 title: New title (optional)
715 content: New content (optional)
716 tags: New tags (optional)
717 pinned: New pinned status (optional) — wire-level alias for the
718 ``Document.favorite`` column; the UI vocabulary is "pin".
719 """
720 if isinstance(body, JSONResponse):
721 return body
723 try:
724 data = body
725 if not data:
726 return JSONResponse(
727 {"success": False, "error": "No data provided"},
728 status_code=400,
729 )
731 # Every sibling field gets a clean 400 via the service validators
732 # (_validate_title/_assert_content_size/_validate_tags); pinned is
733 # assigned straight into the Boolean column, where a non-bool
734 # raises StatementError at flush → an opaque 500.
735 pinned = data.get("pinned")
736 if pinned is not None and not isinstance(pinned, bool):
737 return JSONResponse(
738 {"success": False, "error": "pinned must be a boolean"},
739 status_code=400,
740 )
742 service = NoteService(username)
743 content_provided = data.get("content") is not None
744 try:
745 success = service.update_note(
746 note_id=note_id,
747 title=data.get("title"),
748 content=data.get("content"),
749 tags=data.get("tags"),
750 favorite=pinned,
751 )
752 except ValueError as e:
753 # Validation failures (content size, tag shape) — surface the
754 # actual message so the client can fix and retry.
755 return JSONResponse(
756 {"success": False, "error": str(e)}, status_code=400
757 )
759 if not success:
760 return JSONResponse(
761 {"success": False, "error": "Note not found"}, status_code=404
762 )
764 # Trigger async auto-indexing if content OR title was updated
765 # (non-blocking). The service marks DocumentCollection.indexed=False
766 # for either change and relies on this post-commit trigger; the
767 # title is baked into every chunk's metadata at index time, so a
768 # rename leaves stale titles in search results until reindexed. A
769 # title-only PUT used to flip indexed=False without ever scheduling
770 # the reindex. Unchanged values are harmless: the worker runs with
771 # force_reindex=False and skips rows still marked indexed=True.
772 if content_provided or data.get("title") is not None:
773 _trigger_note_auto_index(
774 note_id, service, username, request.session.get("session_id")
775 )
777 return {"success": True}
779 except Exception as e:
780 return handle_api_error("updating note", e)
783@router.delete("/api/notes/{note_id}")
784@_notes_write_limit
785def delete_note(
786 request: Request,
787 note_id: str,
788 username: Annotated[str, Depends(require_auth)],
789):
790 """Delete a note."""
791 try:
792 service = NoteService(username)
793 success = service.delete_note(note_id)
795 if not success:
796 return JSONResponse(
797 {"success": False, "error": "Note not found"}, status_code=404
798 )
800 return {"success": True}
802 except Exception as e:
803 return handle_api_error("deleting note", e)
806# ============================================================================
807# API Routes - Collections
808# ============================================================================
811@router.get("/api/notes/{note_id}/collections")
812def get_note_collections(
813 request: Request,
814 note_id: str,
815 username: Annotated[str, Depends(require_auth)],
816):
817 """Get collections a note belongs to."""
818 try:
819 service = NoteService(username)
820 # 404 on unknown note id rather than returning an empty list.
821 # Pre-fix, a typo in the note UUID returned 200 with [] —
822 # indistinguishable from "this note has no collections" — making
823 # client-side bugs invisible to QA. The sibling get_note route
824 # already 404s for unknown ids; align with that contract.
825 if not service.note_exists(note_id):
826 return JSONResponse(
827 {"success": False, "error": "Note not found"}, status_code=404
828 )
829 collections = service.get_note_collections(note_id)
831 return {"success": True, "collections": collections}
833 except Exception as e:
834 return handle_api_error("getting note collections", e)
837@router.post("/api/notes/{note_id}/collections")
838@_notes_write_limit
839def add_note_to_collection(
840 request: Request,
841 note_id: str,
842 username: Annotated[str, Depends(require_auth)],
843 body: _NotesBody,
844):
845 """
846 Add a note to a collection.
848 Body:
849 collection_id: The collection ID
850 """
851 if isinstance(body, JSONResponse): 851 ↛ 852line 851 didn't jump to line 852 because the condition on line 851 was never true
852 return body
854 try:
855 data = body
856 if not data or not data.get("collection_id"):
857 return JSONResponse(
858 {"success": False, "error": "collection_id is required"},
859 status_code=400,
860 )
862 service = NoteService(username)
863 # add_to_collection returns the same False for "note id missing /
864 # not a note" and "already a member"; without this guard a stale
865 # id (note deleted in another tab) produced a factually wrong 409
866 # "Already in collection". 404 first, like get_note_collections.
867 if not service.note_exists(note_id):
868 return JSONResponse(
869 {"success": False, "error": "Note not found"}, status_code=404
870 )
871 success = service.add_to_collection(note_id, data["collection_id"])
873 if not success:
874 return JSONResponse(
875 {"success": False, "error": "Already in collection"},
876 status_code=409,
877 )
879 return {"success": True}
881 except LookupError:
882 return JSONResponse(
883 {"success": False, "error": "Collection not found"},
884 status_code=404,
885 )
886 except Exception as e:
887 return handle_api_error("adding note to collection", e)
890@router.delete("/api/notes/{note_id}/collections/{collection_id}")
891@_notes_write_limit
892def remove_note_from_collection(
893 request: Request,
894 note_id: str,
895 collection_id: str,
896 username: Annotated[str, Depends(require_auth)],
897):
898 """Remove a note from a collection."""
899 try:
900 service = NoteService(username)
901 # Same conflation as add_note_to_collection: a missing/non-note id
902 # used to 404 with the misleading "Not in collection" message.
903 if not service.note_exists(note_id):
904 return JSONResponse(
905 {"success": False, "error": "Note not found"}, status_code=404
906 )
907 success = service.remove_from_collection(note_id, collection_id)
909 if not success:
910 return JSONResponse(
911 {"success": False, "error": "Not in collection"},
912 status_code=404,
913 )
915 return {"success": True}
917 except ValueError as e:
918 # Guard failures (e.g. unlinking from the system Notes collection,
919 # which is every note's persistent home) — surface the message.
920 return JSONResponse(
921 {"success": False, "error": str(e)}, status_code=400
922 )
923 except Exception as e:
924 return handle_api_error("removing note from collection", e)
927# ============================================================================
928# API Routes - Research
929# ============================================================================
932@router.get("/api/notes/{note_id}/research")
933def get_note_research(
934 request: Request,
935 note_id: str,
936 username: Annotated[str, Depends(require_auth)],
937):
938 """Get research runs triggered from a note."""
939 try:
940 service = NoteService(username)
941 # 404 on unknown note id — see get_note_collections for the
942 # rationale. The two routes had the same contract gap.
943 if not service.note_exists(note_id):
944 return JSONResponse(
945 {"success": False, "error": "Note not found"}, status_code=404
946 )
947 research = service.get_note_research(note_id)
949 return {"success": True, "research": research}
951 except Exception as e:
952 return handle_api_error("getting note research", e)
955@router.post("/api/notes/{note_id}/research")
956@_notes_write_limit
957def link_research_to_note(
958 request: Request,
959 note_id: str,
960 username: Annotated[str, Depends(require_auth)],
961 body: _NotesBody,
962):
963 """
964 Link a research run to a note.
966 Body:
967 research_id: The research history ID
968 """
969 if isinstance(body, JSONResponse): 969 ↛ 970line 969 didn't jump to line 970 because the condition on line 969 was never true
970 return body
972 try:
973 data = body
974 if not data or not data.get("research_id"):
975 return JSONResponse(
976 {"success": False, "error": "research_id is required"},
977 status_code=400,
978 )
980 service = NoteService(username)
981 link_id = service.link_research_to_note(
982 note_id=note_id,
983 research_id=data["research_id"],
984 )
986 return JSONResponse({"success": True, "id": link_id}, status_code=201)
988 except ValueError as e:
989 # A missing/non-note id surfaces as ValueError("... not found") — a
990 # 404 like every sibling note route; the research-per-note cap is a
991 # client-fixable 400.
992 if "not found" in str(e).lower(): 992 ↛ 996line 992 didn't jump to line 996 because the condition on line 992 was always true
993 return JSONResponse(
994 {"success": False, "error": str(e)}, status_code=404
995 )
996 return JSONResponse(
997 {"success": False, "error": str(e)}, status_code=400
998 )
999 except IntegrityError as exc:
1000 # Only the (document_id, research_id) uniqueness collision means the
1001 # research is already linked → 409. A display_order retry-exhaustion
1002 # or an FK race (stale/deleted research_id) also surfaces as
1003 # IntegrityError but is NOT a duplicate — don't mislabel those as
1004 # "already linked"; let them fall through to a generic error.
1005 msg = str(getattr(exc, "orig", exc)).lower()
1006 if "foreign key" in msg:
1007 # research_id references a research run that doesn't exist (or
1008 # was deleted) — a client-fixable 404, not an opaque 500.
1009 return JSONResponse(
1010 {"success": False, "error": "Research run not found"},
1011 status_code=404,
1012 )
1013 if "display_order" in msg:
1014 return handle_api_error("linking research to note", exc)
1015 return JSONResponse(
1016 {"success": False, "error": "Research already linked to this note"},
1017 status_code=409,
1018 )
1019 except Exception as e:
1020 return handle_api_error("linking research to note", e)
1023@router.patch("/api/notes/{note_id}/research/{research_id}")
1024@_notes_write_limit
1025def patch_note_research(
1026 request: Request,
1027 note_id: str,
1028 research_id: str,
1029 username: Annotated[str, Depends(require_auth)],
1030 body: _NotesBody,
1031):
1032 """Update a NoteResearch row (currently supports is_collapsed toggle).
1034 Body:
1035 is_collapsed: bool (optional)
1036 """
1037 if isinstance(body, JSONResponse): 1037 ↛ 1038line 1037 didn't jump to line 1038 because the condition on line 1037 was never true
1038 return body
1040 try:
1041 data = body
1042 # Omitted: is_collapsed stays None and is passed through (no-op).
1043 is_collapsed = data.get("is_collapsed")
1044 if "is_collapsed" in data and not isinstance(
1045 data["is_collapsed"], bool
1046 ):
1047 logger.warning(
1048 "notes_api validation reject: is_collapsed={} (type={}) "
1049 "(user={!r})",
1050 _log_value_preview(data["is_collapsed"]),
1051 type(data["is_collapsed"]).__name__,
1052 username,
1053 )
1054 return JSONResponse(
1055 {
1056 "success": False,
1057 "error": "is_collapsed must be a boolean",
1058 },
1059 status_code=400,
1060 )
1061 service = NoteService(username)
1062 updated = service.update_note_research(
1063 note_id=note_id,
1064 research_id=research_id,
1065 is_collapsed=is_collapsed,
1066 )
1067 if not updated:
1068 return JSONResponse(
1069 {"success": False, "error": "Research link not found"},
1070 status_code=404,
1071 )
1072 return {"success": True}
1074 except Exception as e:
1075 return handle_api_error("updating note research", e)
1078@router.post("/api/notes/{note_id}/research/reorder")
1079@_notes_write_limit
1080def reorder_note_research(
1081 request: Request,
1082 note_id: str,
1083 username: Annotated[str, Depends(require_auth)],
1084 body: _NotesBody,
1085):
1086 """Atomically reorder research items for a note.
1088 Body:
1089 research_ids: list of research_id strings in desired display order.
1090 """
1091 if isinstance(body, JSONResponse): 1091 ↛ 1092line 1091 didn't jump to line 1092 because the condition on line 1091 was never true
1092 return body
1094 try:
1095 data = body
1096 research_ids = data.get("research_ids")
1097 if not isinstance(research_ids, list) or not research_ids:
1098 return JSONResponse(
1099 {
1100 "success": False,
1101 "error": "research_ids must be a non-empty list",
1102 },
1103 status_code=400,
1104 )
1105 if not all(isinstance(r, str) for r in research_ids):
1106 # Non-string elements (nested arrays/objects) are unhashable and
1107 # crash the dedup/reorder logic with TypeError → opaque 500.
1108 return JSONResponse(
1109 {
1110 "success": False,
1111 "error": "research_ids must be a list of strings",
1112 },
1113 status_code=400,
1114 )
1116 service = NoteService(username)
1117 # 404 a stale/deleted note id BEFORE the ids-match check, mirroring
1118 # every sibling note-scoped route (get_note_research,
1119 # add_note_to_collection, ...). Otherwise reorder_note_research finds
1120 # no NoteResearch rows for the missing note, returns False on the
1121 # ids-mismatch path, and the route misreports a 404 condition (note
1122 # deleted in another tab) as a 400 validation error.
1123 if not service.note_exists(note_id):
1124 return JSONResponse(
1125 {"success": False, "error": "Note not found"}, status_code=404
1126 )
1127 ok = service.reorder_note_research(note_id, research_ids)
1128 if not ok: 1128 ↛ 1136line 1128 didn't jump to line 1136 because the condition on line 1128 was always true
1129 return JSONResponse(
1130 {
1131 "success": False,
1132 "error": "research_ids do not match the note's linked research",
1133 },
1134 status_code=400,
1135 )
1136 return {"success": True}
1138 except ValueError as e:
1139 # Validation failures (research-per-note cap) — surface the
1140 # actual message so the client can fix and retry.
1141 return JSONResponse(
1142 {"success": False, "error": str(e)}, status_code=400
1143 )
1144 except Exception as e:
1145 return handle_api_error("reordering note research", e)
1148# ============================================================================
1149# API Routes - Research → Notes (results-page Notes panel)
1150# ============================================================================
1153@router.get("/api/research/{research_id}/notes")
1154@_notes_search_limit
1155def get_research_notes(
1156 request: Request,
1157 research_id: str,
1158 username: Annotated[str, Depends(require_auth)],
1159):
1160 """List the notes linked to a research run.
1162 The reverse of GET /api/notes/<id>/research — powers the Notes panel
1163 on the research results page, so commentary attached to a run is
1164 visible from the run itself, not only from the note.
1165 """
1166 try:
1167 service = NoteService(username)
1168 notes = service.get_notes_for_research(research_id)
1169 if notes is None:
1170 return JSONResponse(
1171 {"success": False, "error": "Research not found"},
1172 status_code=404,
1173 )
1174 return {"success": True, "notes": notes}
1176 except Exception as e:
1177 return handle_api_error("listing notes for research", e)
1180@router.post("/api/research/{research_id}/notes")
1181@_notes_write_limit
1182def create_research_note(
1183 request: Request,
1184 research_id: str,
1185 username: Annotated[str, Depends(require_auth)],
1186 body: _NotesBody,
1187):
1188 """Create a note pre-linked to a research run (all-or-nothing).
1190 Body (all optional): ``title``, ``content``, ``tags``. Defaults
1191 produce a starter note titled after the research query. The results
1192 page's "Add note" (no body) and "Clip to note" (content = the quoted
1193 selection) both come through here.
1194 """
1195 if isinstance(body, JSONResponse): 1195 ↛ 1196line 1195 didn't jump to line 1196 because the condition on line 1195 was never true
1196 return body
1198 try:
1199 data = body
1200 service = NoteService(username)
1201 try:
1202 note_id = service.create_note_for_research(
1203 research_id,
1204 title=data.get("title"),
1205 content=data.get("content"),
1206 tags=data.get("tags"),
1207 )
1208 except LookupError:
1209 return JSONResponse(
1210 {"success": False, "error": "Research not found"},
1211 status_code=404,
1212 )
1213 # Reach semantic search / "Ask your notes" like a normally-created
1214 # note (the plain POST /api/notes route does the same at its tail).
1215 _trigger_note_auto_index(
1216 note_id, service, username, request.session.get("session_id")
1217 )
1218 return JSONResponse(
1219 {"success": True, "note_id": note_id}, status_code=201
1220 )
1222 except ValueError as e:
1223 # Validation failures (title/content caps, tag shape) — surface
1224 # the actual message so the client can fix and retry.
1225 return JSONResponse(
1226 {"success": False, "error": str(e)}, status_code=400
1227 )
1228 except Exception as e:
1229 return handle_api_error("creating note for research", e)
1232@router.post("/api/research/{research_id}/save-as-note")
1233@_notes_save_as_note_limit
1234def save_research_as_note(
1235 request: Request,
1236 research_id: str,
1237 username: Annotated[str, Depends(require_auth)],
1238 body: _NotesBody,
1239):
1240 """Copy a COMPLETED research run's report into a new, linked note.
1242 Deliberately a derivation, not a conversion: the report stays the
1243 immutable record (fact-check grades claims against it and its
1244 provenance must survive), while the note is the user's editable
1245 copy. Provenance is kept twice — the NoteResearch link and a header
1246 line inside the note content — so the copy stays traceable even if
1247 it is later unlinked.
1248 """
1249 if isinstance(body, JSONResponse): 1249 ↛ 1250line 1249 didn't jump to line 1250 because the condition on line 1249 was never true
1250 return body
1252 try:
1253 from ...constants import ResearchStatus
1254 from ...database.models import ResearchHistory
1255 from ...database.session_context import get_user_db_session
1256 from ...storage import get_report_storage
1258 with get_user_db_session(username) as db:
1259 research = (
1260 db.query(ResearchHistory).filter_by(id=research_id).first()
1261 )
1262 if research is None:
1263 return JSONResponse(
1264 {"success": False, "error": "Research not found"},
1265 status_code=404,
1266 )
1267 if research.status != ResearchStatus.COMPLETED:
1268 return JSONResponse(
1269 {
1270 "success": False,
1271 "error": "Research is not complete yet.",
1272 "status": research.status,
1273 },
1274 status_code=409,
1275 )
1276 query_text = (research.query or "").strip()
1277 meta = dict(research.research_meta or {})
1279 # Fetch the report markdown in-request (same pattern as the
1280 # fact-check grade flow: the per-user DB password is available
1281 # here, unlike in a background worker).
1282 settings_snapshot = meta.get("settings_snapshot")
1283 with get_user_db_session(username) as db:
1284 storage = get_report_storage(
1285 session=db, settings_snapshot=settings_snapshot
1286 )
1287 report = storage.get_report(research_id, username)
1289 if not report or not str(report).strip():
1290 # get_report returns None for BOTH a missing report and a
1291 # swallowed read error; saving an empty note would look like
1292 # success while losing the content. Refuse instead.
1293 return JSONResponse(
1294 {
1295 "success": False,
1296 "error": (
1297 "The research report is unavailable, so it cannot "
1298 "be saved as a note. Try re-running the research."
1299 ),
1300 },
1301 status_code=502,
1302 )
1304 from ...research_library.notes.services.note_service import (
1305 MAX_TITLE_LENGTH,
1306 )
1308 title = (
1309 f"Research: {query_text}"[:MAX_TITLE_LENGTH]
1310 if query_text
1311 else f"Research {research_id[:8]}"
1312 )
1313 provenance = (
1314 f"> Saved from the research run "
1315 f"[{escape_markdown_link_label(query_text or research_id)}]"
1316 f"(/results/{research_id}).\n\n"
1317 )
1319 service = NoteService(username)
1320 try:
1321 note_id = service.create_note_for_research(
1322 research_id, title=title, content=provenance + str(report)
1323 )
1324 except LookupError:
1325 # Deleted between the checks above and the create — treat like
1326 # any other missing research.
1327 return JSONResponse(
1328 {"success": False, "error": "Research not found"},
1329 status_code=404,
1330 )
1331 _trigger_note_auto_index(
1332 note_id, service, username, request.session.get("session_id")
1333 )
1334 return JSONResponse(
1335 {"success": True, "note_id": note_id}, status_code=201
1336 )
1338 except ValueError as e:
1339 # Validation failures — most likely the report exceeding the note
1340 # content cap. Client-fixable only in the sense that the user
1341 # should know why it refused; surface the message.
1342 return JSONResponse(
1343 {"success": False, "error": str(e)}, status_code=400
1344 )
1345 except Exception as e:
1346 return handle_api_error("saving research as note", e)
1349# ============================================================================
1350# API Routes - Inline annotations (Word-review-style comments) and
1351# document-linked notes. Targets: research runs and library documents —
1352# both have IMMUTABLE rendered text, so quote anchors can never drift.
1353# The comment itself is a full NOTE; the anchor lives in note_references.
1354# ============================================================================
1356MAX_ANNOTATION_COMMENT_LEN = 5000
1357MAX_ANNOTATION_QUOTE_LEN = 1000
1358MAX_ANNOTATION_CONTEXT_LEN = 100
1361def _validated_annotation_fields(data):
1362 """Validate/trim an annotation payload; raises ValueError.
1364 Type-checks every field before touching it: a non-string comment
1365 (e.g. ``{"comment": 123}`` from a direct API caller) would otherwise
1366 crash on ``.strip()`` as an opaque 500 instead of the clean 400 the
1367 length checks produce.
1368 """
1369 for field in ("comment", "quote", "prefix", "suffix"):
1370 value = data.get(field)
1371 if value is not None and not isinstance(value, str):
1372 raise ValueError(f"{field} must be a string")
1373 comment = (data.get("comment") or "").strip()
1374 quote = (data.get("quote") or "").strip()
1375 if not comment:
1376 raise ValueError("comment is required")
1377 if not quote: 1377 ↛ 1378line 1377 didn't jump to line 1378 because the condition on line 1377 was never true
1378 raise ValueError("quote is required")
1379 if len(comment) > MAX_ANNOTATION_COMMENT_LEN: 1379 ↛ 1380line 1379 didn't jump to line 1380 because the condition on line 1379 was never true
1380 raise ValueError(
1381 f"comment exceeds maximum length "
1382 f"({MAX_ANNOTATION_COMMENT_LEN} chars)"
1383 )
1384 if len(quote) > MAX_ANNOTATION_QUOTE_LEN: 1384 ↛ 1385line 1384 didn't jump to line 1385 because the condition on line 1384 was never true
1385 raise ValueError(
1386 f"quote exceeds maximum length ({MAX_ANNOTATION_QUOTE_LEN} chars)"
1387 )
1388 prefix = (data.get("prefix") or "")[-MAX_ANNOTATION_CONTEXT_LEN:]
1389 suffix = (data.get("suffix") or "")[:MAX_ANNOTATION_CONTEXT_LEN]
1390 return comment, quote, prefix, suffix
1393def _comment_note_title(comment):
1394 """Derive the comment-note's title from its first words."""
1395 one_line = " ".join(comment.split())
1396 if len(one_line) > 60: 1396 ↛ 1397line 1396 didn't jump to line 1397 because the condition on line 1396 was never true
1397 one_line = one_line[:60].rstrip() + "…"
1398 return f"Comment: {one_line}"
1401# Length of the comment preview echoed back in an annotation response (was
1402# a bare 300 duplicated across both create-annotation handlers).
1403_ANNOTATION_COMMENT_PREVIEW_CHARS = 300
1406def _annotation_response(note_id, comment, quote, prefix, suffix):
1407 """The annotation-created response payload shared by the research and
1408 document create-annotation handlers (previously byte-identical dicts)."""
1409 return {
1410 "note_id": note_id,
1411 "quote": quote,
1412 "prefix": prefix,
1413 "suffix": suffix,
1414 "note_title": _comment_note_title(comment),
1415 "comment_preview": comment[:_ANNOTATION_COMMENT_PREVIEW_CHARS],
1416 }
1419def _comment_note_content(comment, quote, target_label, target_url):
1420 """Comment-first note body: remark, quoted passage, provenance.
1422 ``target_label`` (a research query or a library document title, both
1423 potentially untrusted) is escaped so it can't break out of the
1424 provenance link — see ``escape_markdown_link_label``.
1425 """
1426 quote_block = "\n".join(f"> {line}" for line in quote.split("\n"))
1427 label = escape_markdown_link_label(target_label)
1428 return (
1429 f"{comment}\n\n{quote_block}\n\n— comment on [{label}]({target_url})\n"
1430 )
1433def _research_exists(username, research_id):
1434 """(exists, query_text) for a research id in the user's DB."""
1435 from ...database.models import ResearchHistory
1436 from ...database.session_context import get_user_db_session
1438 with get_user_db_session(username) as db:
1439 row = (
1440 db.query(ResearchHistory.id, ResearchHistory.query)
1441 .filter_by(id=research_id)
1442 .first()
1443 )
1444 if row is None:
1445 return False, None
1446 return True, (row.query or "").strip()
1449@router.get("/api/research/{research_id}/annotations")
1450@_notes_search_limit
1451def get_research_annotations(
1452 request: Request,
1453 research_id: str,
1454 username: Annotated[str, Depends(require_auth)],
1455):
1456 """List a research run's inline annotations (anchored comment notes)."""
1457 try:
1458 exists, _query = _research_exists(username, research_id)
1459 if not exists:
1460 return JSONResponse(
1461 {"success": False, "error": "Research not found"},
1462 status_code=404,
1463 )
1464 service = NoteService(username)
1465 annotations = service.get_annotations_for_target(
1466 research_id=research_id
1467 )
1468 return {"success": True, "annotations": annotations}
1470 except Exception as e:
1471 return handle_api_error("listing research annotations", e)
1474@router.post("/api/research/{research_id}/annotations")
1475@_notes_write_limit
1476def create_research_annotation(
1477 request: Request,
1478 research_id: str,
1479 username: Annotated[str, Depends(require_auth)],
1480 body: _NotesBody,
1481):
1482 """Attach a comment to a passage of the research report.
1484 Body: ``quote`` (the selected rendered text), optional ``prefix`` /
1485 ``suffix`` (Hypothesis-style disambiguation context), ``comment``.
1486 The comment becomes a NOTE linked to the run; the anchor is a
1487 note_references row.
1488 """
1489 if isinstance(body, JSONResponse): 1489 ↛ 1490line 1489 didn't jump to line 1490 because the condition on line 1489 was never true
1490 return body
1492 try:
1493 data = body
1494 comment, quote, prefix, suffix = _validated_annotation_fields(data)
1496 exists, query_text = _research_exists(username, research_id)
1497 if not exists:
1498 return JSONResponse(
1499 {"success": False, "error": "Research not found"},
1500 status_code=404,
1501 )
1503 service = NoteService(username)
1504 note_id = service.create_note_for_research(
1505 research_id,
1506 title=_comment_note_title(comment),
1507 content=_comment_note_content(
1508 comment,
1509 quote,
1510 query_text or research_id,
1511 f"/results/{research_id}",
1512 ),
1513 quote=quote,
1514 prefix=prefix,
1515 suffix=suffix,
1516 )
1517 _trigger_note_auto_index(
1518 note_id, service, username, request.session.get("session_id")
1519 )
1520 return JSONResponse(
1521 {
1522 "success": True,
1523 "annotation": _annotation_response(
1524 note_id, comment, quote, prefix, suffix
1525 ),
1526 },
1527 status_code=201,
1528 )
1530 except LookupError:
1531 return JSONResponse(
1532 {"success": False, "error": "Research not found"}, status_code=404
1533 )
1534 except ValueError as e:
1535 # Validation failures (missing/oversized fields) — surface the
1536 # actual message so the client can fix and retry.
1537 return JSONResponse(
1538 {"success": False, "error": str(e)}, status_code=400
1539 )
1540 except Exception as e:
1541 return handle_api_error("creating research annotation", e)
1544@router.delete("/api/research/{research_id}/annotations/{note_id}")
1545@_notes_write_limit
1546def delete_research_annotation(
1547 request: Request,
1548 research_id: str,
1549 note_id: str,
1550 username: Annotated[str, Depends(require_auth)],
1551):
1552 """Remove an annotation: the comment note is deleted (standard delete
1553 path — version cascade, vector purge) and its anchor row cascades."""
1554 try:
1555 service = NoteService(username)
1556 if not service.has_annotation(note_id, research_id=research_id):
1557 return JSONResponse(
1558 {"success": False, "error": "Annotation not found"},
1559 status_code=404,
1560 )
1561 service.delete_note(note_id)
1562 return {"success": True}
1564 except Exception as e:
1565 return handle_api_error("deleting research annotation", e)
1568# ---- Library documents: same surface, document targets --------------------
1571@router.get("/api/documents/{document_id}/notes")
1572@_notes_search_limit
1573def get_document_notes(
1574 request: Request,
1575 document_id: str,
1576 username: Annotated[str, Depends(require_auth)],
1577):
1578 """List the notes referencing a library document (its Notes panel)."""
1579 try:
1580 service = NoteService(username)
1581 notes = service.get_notes_for_document(document_id)
1582 if notes is None: 1582 ↛ 1587line 1582 didn't jump to line 1587 because the condition on line 1582 was always true
1583 return JSONResponse(
1584 {"success": False, "error": "Document not found"},
1585 status_code=404,
1586 )
1587 return {"success": True, "notes": notes}
1589 except Exception as e:
1590 return handle_api_error("listing notes for document", e)
1593@router.post("/api/documents/{document_id}/notes")
1594@_notes_write_limit
1595def create_document_note(
1596 request: Request,
1597 document_id: str,
1598 username: Annotated[str, Depends(require_auth)],
1599 body: _NotesBody,
1600):
1601 """Create a note referencing a library document (all-or-nothing).
1603 Body (all optional): ``title``, ``content``, ``tags``. Defaults
1604 produce a starter note titled after the document.
1605 """
1606 if isinstance(body, JSONResponse): 1606 ↛ 1607line 1606 didn't jump to line 1607 because the condition on line 1606 was never true
1607 return body
1609 try:
1610 data = body
1611 service = NoteService(username)
1612 try:
1613 note_id = service.create_note_for_document(
1614 document_id,
1615 title=data.get("title"),
1616 content=data.get("content"),
1617 tags=data.get("tags"),
1618 )
1619 except LookupError:
1620 return JSONResponse(
1621 {"success": False, "error": "Document not found"},
1622 status_code=404,
1623 )
1624 _trigger_note_auto_index(
1625 note_id, service, username, request.session.get("session_id")
1626 )
1627 return JSONResponse(
1628 {"success": True, "note_id": note_id}, status_code=201
1629 )
1631 except ValueError as e:
1632 # Validation failures (caps, or trying to annotate a note —
1633 # mutable content, anchors would drift) — surface the message.
1634 return JSONResponse(
1635 {"success": False, "error": str(e)}, status_code=400
1636 )
1637 except Exception as e:
1638 return handle_api_error("creating note for document", e)
1641@router.get("/api/documents/{document_id}/annotations")
1642@_notes_search_limit
1643def get_document_annotations(
1644 request: Request,
1645 document_id: str,
1646 username: Annotated[str, Depends(require_auth)],
1647):
1648 """List a library document's inline annotations."""
1649 try:
1650 service = NoteService(username)
1651 # Existence gate doubles as the 404 (get_notes_for_document
1652 # checks the Document row).
1653 if service.get_notes_for_document(document_id) is None:
1654 return JSONResponse(
1655 {"success": False, "error": "Document not found"},
1656 status_code=404,
1657 )
1658 annotations = service.get_annotations_for_target(
1659 document_id=document_id
1660 )
1661 return {"success": True, "annotations": annotations}
1663 except Exception as e:
1664 return handle_api_error("listing document annotations", e)
1667@router.post("/api/documents/{document_id}/annotations")
1668@_notes_write_limit
1669def create_document_annotation(
1670 request: Request,
1671 document_id: str,
1672 username: Annotated[str, Depends(require_auth)],
1673 body: _NotesBody,
1674):
1675 """Attach a comment to a passage of a library document's text."""
1676 if isinstance(body, JSONResponse): 1676 ↛ 1677line 1676 didn't jump to line 1677 because the condition on line 1676 was never true
1677 return body
1679 try:
1680 data = body
1681 comment, quote, prefix, suffix = _validated_annotation_fields(data)
1683 from ...database.models import Document
1684 from ...database.session_context import get_user_db_session
1686 with get_user_db_session(username) as db:
1687 row = (
1688 db.query(Document.id, Document.title)
1689 .filter_by(id=document_id)
1690 .first()
1691 )
1692 if row is None: 1692 ↛ 1693line 1692 didn't jump to line 1693 because the condition on line 1692 was never true
1693 return JSONResponse(
1694 {"success": False, "error": "Document not found"},
1695 status_code=404,
1696 )
1697 doc_title = (row.title or "").strip() or document_id
1699 service = NoteService(username)
1700 note_id = service.create_note_for_document(
1701 document_id,
1702 title=_comment_note_title(comment),
1703 content=_comment_note_content(
1704 comment,
1705 quote,
1706 doc_title,
1707 f"/library/document/{document_id}",
1708 ),
1709 quote=quote,
1710 prefix=prefix,
1711 suffix=suffix,
1712 )
1713 _trigger_note_auto_index(
1714 note_id, service, username, request.session.get("session_id")
1715 )
1716 return JSONResponse(
1717 {
1718 "success": True,
1719 "annotation": _annotation_response(
1720 note_id, comment, quote, prefix, suffix
1721 ),
1722 },
1723 status_code=201,
1724 )
1726 except LookupError:
1727 return JSONResponse(
1728 {"success": False, "error": "Document not found"}, status_code=404
1729 )
1730 except ValueError as e:
1731 return JSONResponse(
1732 {"success": False, "error": str(e)}, status_code=400
1733 )
1734 except Exception as e:
1735 return handle_api_error("creating document annotation", e)
1738@router.delete("/api/documents/{document_id}/annotations/{note_id}")
1739@_notes_write_limit
1740def delete_document_annotation(
1741 request: Request,
1742 document_id: str,
1743 note_id: str,
1744 username: Annotated[str, Depends(require_auth)],
1745):
1746 """Remove a document annotation (deletes the comment note; the
1747 anchor row cascades)."""
1748 try:
1749 service = NoteService(username)
1750 if not service.has_annotation(note_id, document_id=document_id):
1751 return JSONResponse(
1752 {"success": False, "error": "Annotation not found"},
1753 status_code=404,
1754 )
1755 service.delete_note(note_id)
1756 return {"success": True}
1758 except Exception as e:
1759 return handle_api_error("deleting document annotation", e)
1762# ============================================================================
1763# API Routes - RAG Indexing
1764# ============================================================================
1767@router.post("/api/notes/{note_id}/index")
1768@_notes_write_limit
1769def index_note_to_collection(
1770 request: Request,
1771 note_id: str,
1772 username: Annotated[str, Depends(require_auth)],
1773 body: _NotesBody,
1774):
1775 """
1776 Index a note into a collection for RAG search.
1778 Body:
1779 collection_id: The collection to index the note into (required)
1780 force_reindex: Whether to force reindexing (optional, default false)
1781 """
1782 from ...web.routers.rag import get_rag_service
1784 if isinstance(body, JSONResponse): 1784 ↛ 1785line 1784 didn't jump to line 1785 because the condition on line 1784 was never true
1785 return body
1787 try:
1788 data = body
1789 if not data or not data.get("collection_id"):
1790 return JSONResponse(
1791 {"success": False, "error": "collection_id is required"},
1792 status_code=400,
1793 )
1795 collection_id = data["collection_id"]
1796 # Omitted: defaults to False (do not force-reindex).
1797 force_reindex = data.get("force_reindex", False)
1798 if not isinstance(force_reindex, bool):
1799 logger.warning(
1800 "notes_api validation reject: force_reindex={} (type={}) "
1801 "(user={!r})",
1802 _log_value_preview(force_reindex),
1803 type(force_reindex).__name__,
1804 username,
1805 )
1806 return JSONResponse(
1807 {
1808 "success": False,
1809 "error": "force_reindex must be a boolean",
1810 },
1811 status_code=400,
1812 )
1814 # Verify the id is actually a note before indexing — every sibling
1815 # note route guards this, but this one passed the id straight to the
1816 # RAG service, which would index an arbitrary user document.
1817 if not NoteService(username).note_exists(note_id):
1818 return JSONResponse(
1819 {"success": False, "error": "Note not found"}, status_code=404
1820 )
1822 # Get RAG service configured for the collection
1823 rag_service = get_rag_service(request, username, collection_id)
1825 # Index the note (notes are now documents)
1826 result = rag_service.index_document(
1827 document_id=note_id,
1828 collection_id=collection_id,
1829 force_reindex=force_reindex,
1830 )
1832 if result["status"] == "error":
1833 return JSONResponse(
1834 {"success": False, "error": result.get("error")},
1835 status_code=400,
1836 )
1838 return {"success": True, "result": result}
1840 except Exception as e:
1841 return handle_api_error("indexing note", e)
1844# ============================================================================
1845# API Routes - AI Features
1846# (semantic_search_notes is registered above, before GET /api/notes/{note_id})
1847# ============================================================================
1850@router.get("/api/notes/{note_id}/similar")
1851@_notes_search_limit
1852def get_similar_notes(
1853 request: Request,
1854 note_id: str,
1855 username: Annotated[str, Depends(require_auth)],
1856):
1857 """
1858 Find notes similar to the given note using embeddings.
1860 Query params:
1861 limit: Maximum number of results (default 5)
1862 """
1863 try:
1864 try:
1865 limit = _clamp_limit(request, 5)
1866 except (ValueError, TypeError):
1867 return JSONResponse(
1868 {"success": False, "error": "Invalid limit"}, status_code=400
1869 )
1870 # 404 on unknown note id — the AI service returns [] for both a
1871 # missing note and a note with no similar matches, which made a
1872 # stale id indistinguishable from a real-empty result. Same guard
1873 # as get_note_collections / get_note_research.
1874 if not NoteService(username).note_exists(note_id):
1875 return JSONResponse(
1876 {"success": False, "error": "Note not found"}, status_code=404
1877 )
1878 service = NoteAIService(username)
1879 similar = service.find_similar_notes(note_id, limit=limit)
1881 return {"success": True, "similar_notes": similar}
1883 except Exception as e:
1884 return handle_api_error("finding similar notes", e)
1887@router.post("/api/notes/{note_id}/summarize")
1888@_notes_ai_limit
1889def summarize_note(
1890 request: Request,
1891 note_id: str,
1892 username: Annotated[str, Depends(require_auth)],
1893 body: _NotesBody,
1894):
1895 """Generate an AI summary of the note."""
1896 if isinstance(body, JSONResponse): 1896 ↛ 1897line 1896 didn't jump to line 1897 because the condition on line 1896 was never true
1897 return body
1899 try:
1900 service = NoteAIService(username)
1901 summary = service.summarize_note(note_id)
1903 # summarize_note returns None only for a missing/empty note (real LLM
1904 # failures raise → handled below as 500). That's a client/data
1905 # condition, so 404 — consistent with the sibling AI endpoints.
1906 if summary is None:
1907 return JSONResponse(
1908 {"success": False, "error": "Note not found or empty"},
1909 status_code=404,
1910 )
1912 return {"success": True, "summary": summary}
1914 except Exception as e:
1915 return handle_api_error("summarizing note", e)
1918@router.post("/api/notes/{note_id}/research-questions")
1919@_notes_ai_limit
1920def extract_research_questions(
1921 request: Request,
1922 note_id: str,
1923 username: Annotated[str, Depends(require_auth)],
1924 body: _NotesBody,
1925):
1926 """Extract potential research questions from the note."""
1927 if isinstance(body, JSONResponse): 1927 ↛ 1928line 1927 didn't jump to line 1928 because the condition on line 1927 was never true
1928 return body
1930 try:
1931 # 404 on unknown note id — see get_similar_notes for the rationale.
1932 if not NoteService(username).note_exists(note_id): 1932 ↛ 1936line 1932 didn't jump to line 1936 because the condition on line 1932 was always true
1933 return JSONResponse(
1934 {"success": False, "error": "Note not found"}, status_code=404
1935 )
1936 service = NoteAIService(username)
1937 questions = service.extract_research_questions(note_id)
1939 return {"success": True, "questions": questions}
1941 except Exception as e:
1942 return handle_api_error("extracting research questions", e)
1945@router.post("/api/notes/suggest-tags")
1946@_notes_ai_limit
1947def suggest_tags(
1948 request: Request,
1949 username: Annotated[str, Depends(require_auth)],
1950 body: _NotesBody,
1951):
1952 """
1953 Suggest tags for note content.
1955 Body:
1956 content: The note content to analyze
1957 existing_tags: Current tags to avoid duplicates (optional)
1958 """
1959 if isinstance(body, JSONResponse): 1959 ↛ 1960line 1959 didn't jump to line 1960 because the condition on line 1959 was never true
1960 return body
1962 try:
1963 data = body
1964 if not data or not data.get("content"):
1965 return JSONResponse(
1966 {"success": False, "error": "content is required"},
1967 status_code=400,
1968 )
1970 # Cap body size — same MAX_CONTENT_SIZE used by create_note /
1971 # update_note. Without this, the route accepts unbounded text and
1972 # holds it all in memory while the LLM call runs.
1973 _assert_content_size(data["content"])
1975 # existing_tags is interpolated/joined downstream; a non-list (or a
1976 # list with non-string items) would raise deep in the service and
1977 # surface as an opaque 500. Validate to a clean 400 here.
1978 existing_tags = data.get("existing_tags")
1979 if existing_tags is not None and (
1980 not isinstance(existing_tags, list)
1981 or not all(isinstance(t, str) for t in existing_tags)
1982 ):
1983 return JSONResponse(
1984 {
1985 "success": False,
1986 "error": "existing_tags must be a list of strings",
1987 },
1988 status_code=400,
1989 )
1990 # Cap count + per-item length like _validate_tags does on the
1991 # create/update paths. suggest_tags joins the WHOLE list before
1992 # truncating the joined result, so without a cap a client could send a
1993 # multi-MB existing_tags array (within the body ceiling, at 10/min) and
1994 # force a large in-memory join on every call.
1995 if existing_tags is not None and (
1996 len(existing_tags) > MAX_TAGS_PER_NOTE
1997 or any(len(t) > MAX_TAG_LENGTH for t in existing_tags)
1998 ):
1999 return JSONResponse(
2000 {
2001 "success": False,
2002 "error": (
2003 f"existing_tags exceeds limits (max "
2004 f"{MAX_TAGS_PER_NOTE} tags, {MAX_TAG_LENGTH} chars each)"
2005 ),
2006 },
2007 status_code=400,
2008 )
2010 service = NoteAIService(username)
2011 tags = service.suggest_tags(
2012 content=data["content"],
2013 existing_tags=existing_tags,
2014 )
2016 return {"success": True, "tags": tags}
2018 except ValueError as e:
2019 return JSONResponse(
2020 {"success": False, "error": str(e)}, status_code=400
2021 )
2022 except Exception as e:
2023 return handle_api_error("suggesting tags", e)
2026@router.post("/api/notes/{note_id}/key-concepts")
2027@_notes_ai_limit
2028def extract_key_concepts(
2029 request: Request,
2030 note_id: str,
2031 username: Annotated[str, Depends(require_auth)],
2032 body: _NotesBody,
2033):
2034 """Extract key concepts, entities, and themes from the note."""
2035 if isinstance(body, JSONResponse): 2035 ↛ 2036line 2035 didn't jump to line 2036 because the condition on line 2035 was never true
2036 return body
2038 try:
2039 # 404 on unknown note id — see get_similar_notes for the rationale.
2040 if not NoteService(username).note_exists(note_id): 2040 ↛ 2044line 2040 didn't jump to line 2044 because the condition on line 2040 was always true
2041 return JSONResponse(
2042 {"success": False, "error": "Note not found"}, status_code=404
2043 )
2044 service = NoteAIService(username)
2045 concepts = service.extract_key_concepts(note_id)
2047 return {"success": True, "concepts": concepts}
2049 except Exception as e:
2050 return handle_api_error("extracting key concepts", e)
2053# ============================================================================
2054# API Routes - Smart Note Linking
2055# ============================================================================
2058@router.get("/api/notes/{note_id}/backlinks")
2059@_notes_search_limit
2060def get_backlinks(
2061 request: Request,
2062 note_id: str,
2063 username: Annotated[str, Depends(require_auth)],
2064):
2065 """Get notes that link to this note (backlinks)."""
2066 try:
2067 service = NoteService(username)
2068 # 404 an unknown id like the other note routes, so an empty list
2069 # for a real note is distinguishable from "no such note".
2070 if not service.note_exists(note_id):
2071 return JSONResponse(
2072 {"success": False, "error": "Note not found"}, status_code=404
2073 )
2074 backlinks = service.get_backlinks(note_id)
2076 return {
2077 "success": True,
2078 "backlinks": backlinks,
2079 "total": len(backlinks),
2080 }
2082 except Exception as e:
2083 return handle_api_error("getting backlinks", e)
2086@router.get("/api/notes/{note_id}/outgoing-links")
2087@_notes_search_limit
2088def get_outgoing_links(
2089 request: Request,
2090 note_id: str,
2091 username: Annotated[str, Depends(require_auth)],
2092):
2093 """Get notes that this note links to."""
2094 try:
2095 service = NoteService(username)
2096 # 404 an unknown id like the other note routes.
2097 if not service.note_exists(note_id):
2098 return JSONResponse(
2099 {"success": False, "error": "Note not found"}, status_code=404
2100 )
2101 links = service.get_outgoing_links(note_id)
2103 return {
2104 "success": True,
2105 "links": links,
2106 "total": len(links),
2107 }
2109 except Exception as e:
2110 return handle_api_error("getting outgoing links", e)
2113@router.get("/api/notes/{note_id}/suggested-links")
2114@_notes_search_limit
2115def get_suggested_links(
2116 request: Request,
2117 note_id: str,
2118 username: Annotated[str, Depends(require_auth)],
2119):
2120 """Get AI-suggested notes to link to based on semantic similarity."""
2121 try:
2122 try:
2123 limit = _clamp_limit(request, 5)
2124 except (ValueError, TypeError):
2125 return JSONResponse(
2126 {"success": False, "error": "Invalid limit"}, status_code=400
2127 )
2128 # 404 a missing/unknown note like every sibling AI-link route
2129 # (suggest_links returns [] for a missing note, which is otherwise
2130 # indistinguishable from "a real note with no suggestions").
2131 if not NoteService(username).note_exists(note_id):
2132 return JSONResponse(
2133 {"success": False, "error": "Note not found"}, status_code=404
2134 )
2135 service = NoteAIService(username)
2136 suggestions = service.suggest_links(note_id, limit=limit)
2138 return {"success": True, "suggestions": suggestions}
2140 except Exception as e:
2141 return handle_api_error("getting suggested links", e)
2144@router.post("/api/notes/{note_id}/accept-link")
2145@_notes_write_limit
2146def accept_suggested_link(
2147 request: Request,
2148 note_id: str,
2149 username: Annotated[str, Depends(require_auth)],
2150 body: _NotesBody,
2151):
2152 """Accept an AI-suggested link: insert [[Target Title]] into the note
2153 content and flag the resulting NoteLink as auto_suggested=True.
2155 Body:
2156 target_note_id: The ID of the note to link to.
2157 """
2158 if isinstance(body, JSONResponse): 2158 ↛ 2159line 2158 didn't jump to line 2159 because the condition on line 2158 was never true
2159 return body
2161 try:
2162 data = body
2163 target_note_id = data.get("target_note_id")
2164 if not target_note_id:
2165 return JSONResponse(
2166 {"success": False, "error": "target_note_id required"},
2167 status_code=400,
2168 )
2170 service = NoteService(username)
2171 result = service.accept_suggested_link(note_id, target_note_id)
2172 if not result: 2172 ↛ 2178line 2172 didn't jump to line 2178 because the condition on line 2172 was always true
2173 return JSONResponse(
2174 {"success": False, "error": "Link could not be accepted"},
2175 status_code=404,
2176 )
2178 return {"success": True, "note": result}
2180 except ValueError as e:
2181 # A real write failure surfaced by the service (e.g. the appended link
2182 # pushed the note past the content-size cap) is a client-fixable 400,
2183 # not the generic 500 handle_api_error would otherwise return.
2184 return JSONResponse(
2185 {"success": False, "error": str(e)}, status_code=400
2186 )
2187 except Exception as e:
2188 return handle_api_error("accepting suggested link", e)
2191@router.get("/api/notes/{note_id}/unlinked-mentions")
2192@_notes_search_limit
2193def get_unlinked_mentions(
2194 request: Request,
2195 note_id: str,
2196 username: Annotated[str, Depends(require_auth)],
2197):
2198 """Notes whose text mentions this note's title but don't yet link to it.
2200 Lexical (no embeddings). The client links one via the existing
2201 /accept-link route on the *mentioning* note (target = this note).
2202 """
2203 try:
2204 service = NoteService(username)
2205 # 404 an unknown id like the other note routes.
2206 if not service.note_exists(note_id):
2207 return JSONResponse(
2208 {"success": False, "error": "Note not found"}, status_code=404
2209 )
2210 mentions = service.get_unlinked_mentions(note_id)
2211 return {"success": True, "mentions": mentions}
2212 except Exception as e:
2213 return handle_api_error("getting unlinked mentions", e)
2216# Passage text is a paragraph-ish selection; cap it so a huge selection
2217# can't be sent as the embedding query.
2218MAX_PASSAGE_LEN = 2000
2221@router.post("/api/notes/{note_id}/similar-passages")
2222@_notes_search_limit
2223def similar_passages(
2224 request: Request,
2225 note_id: str,
2226 username: Annotated[str, Depends(require_auth)],
2227 body: _NotesBody,
2228):
2229 """Find note passages (chunks) semantically similar to a given snippet.
2231 Body: ``{"text": "<selected paragraph>"}``. Chunks from this note are
2232 excluded so the result is "related passages elsewhere".
2233 """
2234 if isinstance(body, JSONResponse): 2234 ↛ 2235line 2234 didn't jump to line 2235 because the condition on line 2234 was never true
2235 return body
2237 try:
2238 data = body
2239 text = data.get("text")
2240 if not text or not isinstance(text, str) or not text.strip():
2241 return JSONResponse(
2242 {"success": False, "error": "text required"}, status_code=400
2243 )
2244 text = text.strip()[:MAX_PASSAGE_LEN]
2246 if not NoteService(username).note_exists(note_id):
2247 logger.warning(
2248 "notes_api validation reject: similar-passages note_id={} "
2249 "not found (user={!r})",
2250 _log_value_preview(note_id),
2251 username,
2252 )
2253 return JSONResponse(
2254 {"success": False, "error": "Note not found"}, status_code=404
2255 )
2257 service = NoteAIService(username)
2258 passages = service.find_similar_passages(text, exclude_note_id=note_id)
2259 return {"success": True, "passages": passages}
2260 except Exception as e:
2261 return handle_api_error("finding similar passages", e)
2264# ============================================================================
2265# API Routes - Ask your notes (research pinned to the Notes collection)
2266# (ask_context is registered above, before GET /api/notes/{note_id})
2267# ============================================================================
2270# ============================================================================
2271# API Routes - Verify Note with Research (fact-check)
2272# ============================================================================
2275@router.post("/api/notes/{note_id}/fact-check")
2276@_notes_factcheck_limit
2277def fact_check_note(
2278 request: Request,
2279 note_id: str,
2280 username: Annotated[str, Depends(require_auth)],
2281 body: _NotesBody,
2282):
2283 """Extract checkable factual claims from a note and synthesize a research
2284 query for verifying them.
2286 The client then starts a research run (reusing /api/start_research + the
2287 research-link flow, exactly like "Research This Note") and, once it
2288 completes, POSTs the claims to the /grade endpoint below.
2289 """
2290 if isinstance(body, JSONResponse): 2290 ↛ 2291line 2290 didn't jump to line 2291 because the condition on line 2290 was never true
2291 return body
2293 try:
2294 # Guard the note id like every other note route: without this an
2295 # unknown/typo'd id fell through to the claim-less 422 below, conflating
2296 # "this note has nothing to verify" with "there is no such note".
2297 if not NoteService(username).note_exists(note_id):
2298 return JSONResponse(
2299 {"success": False, "error": "Note not found"}, status_code=404
2300 )
2302 ai = NoteAIService(username)
2303 claims = ai.extract_claims(note_id)
2304 if not claims:
2305 # The note exists but has no checkable factual claims.
2306 return JSONResponse(
2307 {
2308 "success": False,
2309 "error": "No checkable factual claims found in this note.",
2310 },
2311 status_code=422,
2312 )
2313 query = ai.synthesize_factcheck_query(claims)
2314 return {"success": True, "claims": claims, "query": query}
2316 except Exception as e:
2317 return handle_api_error("starting note fact-check", e)
2320def _grade_note_claims(username, note_id, research_id, claims):
2321 """Grade ``claims`` against a COMPLETED research run's report and persist
2322 the verdicts under ``research_meta['fact_check']``.
2324 Returns a ``(payload, status_code)`` tuple. Extracted from the route so it
2325 can be unit-tested without a client. Runs entirely in the request thread,
2326 where the per-user DB password is available, so the LLM grade and report
2327 fetch work without the encrypted-DB background-worker problem.
2328 """
2329 from ...constants import ResearchStatus
2330 from ...database.models import NoteResearch, ResearchHistory
2331 from ...database.session_context import get_user_db_session
2332 from ...storage import get_report_storage
2333 from ..services.report_assembly_service import (
2334 get_research_source_links_batch,
2335 )
2337 # Load the research (per-user DB session scopes it to this user) and
2338 # require it to have completed before grading.
2339 with get_user_db_session(username) as db:
2340 research = db.query(ResearchHistory).filter_by(id=research_id).first()
2341 if not research:
2342 return {"success": False, "error": "Research not found"}, 404
2343 # Require the research to actually be linked to THIS note — otherwise a
2344 # client could grade an arbitrary completed research against an
2345 # arbitrary note and stamp a foreign note_id into its meta.
2346 linked = (
2347 db.query(NoteResearch)
2348 .filter_by(document_id=note_id, research_id=research_id)
2349 .first()
2350 )
2351 if not linked:
2352 return {
2353 "success": False,
2354 "error": "Research is not linked to this note",
2355 }, 404
2356 status = research.status
2357 meta = dict(research.research_meta or {})
2359 if status != ResearchStatus.COMPLETED:
2360 return {
2361 "success": False,
2362 "error": "Research is not complete yet.",
2363 "status": status,
2364 }, 409
2366 # Fetch the report markdown + sources (in-request).
2367 settings_snapshot = meta.get("settings_snapshot")
2368 with get_user_db_session(username) as db:
2369 storage = get_report_storage(
2370 session=db, settings_snapshot=settings_snapshot
2371 )
2372 report = storage.get_report(research_id, username)
2373 # Sources live in the research_resources table, not research_meta.
2374 # The post-refactor save path never writes the legacy
2375 # `all_links_of_system` metadata key (see the identical fix in
2376 # research_routes.get_report), so reading it here returned [] for
2377 # every research: the grader prompt always showed "(no sources)"
2378 # and every verdict's sources array was empty, leaving the
2379 # citation-validation/anti-rubber-stamp checks unreachable. Read
2380 # the structured table instead — the same source of truth the
2381 # results page uses. Fetch only the MAX_GRADE_SOURCES window the
2382 # grader will actually show; anything past it was discarded.
2383 sources = get_research_source_links_batch(
2384 [research_id], db, limit=_MAX_GRADE_SOURCES
2385 ).get(research_id, [])
2387 # get_report returns None for BOTH a missing report and a swallowed read
2388 # error. Grading against an empty report would label every claim
2389 # "unverified" and persist that as a successful fact-check — a false result.
2390 # Refuse to grade instead.
2391 if not report or not str(report).strip():
2392 return {
2393 "success": False,
2394 "error": (
2395 "The research report is unavailable, so its claims cannot be "
2396 "graded. Try re-running the research."
2397 ),
2398 }, 502
2400 ai = NoteAIService(username)
2401 verdicts = ai.grade_all_claims(claims, report, sources)
2403 # Persist verdicts under research_meta['fact_check']. Reassign the whole
2404 # dict so SQLAlchemy marks the plain-JSON column dirty (this codebase
2405 # deliberately avoids MutableDict — see research_sources_service.py).
2406 with get_user_db_session(username) as db:
2407 research = db.query(ResearchHistory).filter_by(id=research_id).first()
2408 if research is None:
2409 # The research row existed when we loaded it for grading (above)
2410 # but has since vanished — e.g. a concurrent delete between the
2411 # grade and this persist. The verdicts were computed but cannot be
2412 # saved. Returning success here would tell the client a fact-check
2413 # was persisted that never was (a silent partial failure); report
2414 # the conflict explicitly instead.
2415 return {
2416 "success": False,
2417 "error": (
2418 "The research run disappeared before its fact-check "
2419 "verdicts could be saved. Please try again."
2420 ),
2421 }, 409
2422 try:
2423 new_meta = dict(research.research_meta or {})
2424 new_meta["fact_check"] = {
2425 "note_id": note_id,
2426 "claims": claims,
2427 "verdicts": verdicts,
2428 }
2429 research.research_meta = new_meta
2430 db.commit()
2431 except Exception:
2432 # Don't leave the shared request session poisoned.
2433 db.rollback()
2434 raise
2436 return {"success": True, "verdicts": verdicts}, 200
2439@router.post("/api/notes/{note_id}/fact-check/{research_id}/grade")
2440@_notes_factcheck_limit
2441def grade_note_fact_check(
2442 request: Request,
2443 note_id: str,
2444 research_id: str,
2445 username: Annotated[str, Depends(require_auth)],
2446 body: _NotesBody,
2447):
2448 """Grade a note's claims against a COMPLETED research run's report.
2450 Body: ``{"claims": ["...", ...]}`` — the claims returned by /fact-check.
2451 """
2452 if isinstance(body, JSONResponse): 2452 ↛ 2453line 2452 didn't jump to line 2453 because the condition on line 2452 was never true
2453 return body
2455 try:
2456 data = body
2457 raw_claims = data.get("claims")
2458 if not isinstance(raw_claims, list):
2459 return JSONResponse(
2460 {"success": False, "error": "claims required"}, status_code=400
2461 )
2462 # Cap both the count AND each claim's length — the rest of this file
2463 # length-bounds free text (MAX_SEARCH_LEN / _clamp_text_query) but the
2464 # grade endpoint took client claims with no per-string bound.
2465 claims = [
2466 c.strip()[:MAX_CLAIM_LEN]
2467 for c in raw_claims
2468 if isinstance(c, str) and c.strip()
2469 ][: NoteAIService.MAX_CLAIMS_PER_NOTE]
2470 if not claims:
2471 return JSONResponse(
2472 {"success": False, "error": "claims required"}, status_code=400
2473 )
2475 payload, status_code = _grade_note_claims(
2476 username, note_id, research_id, claims
2477 )
2478 return JSONResponse(payload, status_code=status_code)
2480 except Exception as e:
2481 return handle_api_error("grading note fact-check", e)
2484@router.post("/api/notes/resolve-link")
2485@_notes_search_limit
2486def resolve_link(
2487 request: Request,
2488 username: Annotated[str, Depends(require_auth)],
2489 body: _NotesBody,
2490):
2491 """
2492 Resolve a [[link]] text to a note.
2494 Body:
2495 link_text: The text inside [[brackets]]
2496 """
2497 if isinstance(body, JSONResponse): 2497 ↛ 2498line 2497 didn't jump to line 2498 because the condition on line 2497 was never true
2498 return body
2500 try:
2501 data = body
2502 if not data or not data.get("link_text"):
2503 return JSONResponse(
2504 {"success": False, "error": "link_text is required"},
2505 status_code=400,
2506 )
2507 try:
2508 link_text = _clamp_text_query(
2509 data["link_text"], "link_text", max_len=MAX_LINK_TEXT_LEN
2510 )
2511 except ValueError as exc:
2512 return JSONResponse(
2513 {"success": False, "error": str(exc)}, status_code=400
2514 )
2516 service = NoteService(username)
2517 result = service.resolve_link(link_text)
2519 if result:
2520 return {"success": True, "note": result}
2521 return JSONResponse(
2522 {"success": False, "error": "Note not found"}, status_code=404
2523 )
2525 except Exception as e:
2526 return handle_api_error("resolving link", e)
2529# ============================================================================
2530# API Routes - Note Synthesis
2531# ============================================================================
2534@router.post("/api/notes/synthesize/preview")
2535@_notes_synthesize_limit
2536def preview_synthesis(
2537 request: Request,
2538 username: Annotated[str, Depends(require_auth)],
2539 body: _NotesBody,
2540):
2541 """
2542 Preview what a synthesis would produce.
2544 Body:
2545 note_ids: List of note IDs (2-5)
2546 synthesis_type: 'merge', 'summarize', or 'compare'
2547 """
2548 if isinstance(body, JSONResponse): 2548 ↛ 2549line 2548 didn't jump to line 2549 because the condition on line 2548 was never true
2549 return body
2551 try:
2552 data = body
2553 if not data or not data.get("note_ids"):
2554 return JSONResponse(
2555 {"success": False, "error": "note_ids is required"},
2556 status_code=400,
2557 )
2559 note_ids = data["note_ids"]
2560 if not isinstance(note_ids, list) or not all(
2561 isinstance(n, str) for n in note_ids
2562 ):
2563 # Element type matters: synthesize_notes does dict.fromkeys(note_ids),
2564 # which raises TypeError (→ 500) on unhashable items like dicts.
2565 return JSONResponse(
2566 {
2567 "success": False,
2568 "error": "note_ids must be a list of strings",
2569 },
2570 status_code=400,
2571 )
2572 synthesis_type = data.get("synthesis_type", "merge")
2573 # synthesize_notes checks `synthesis_type not in {set}` (a hash), which
2574 # raises TypeError (→ opaque 500) on an unhashable non-string like a
2575 # list/dict. Reject to a clean 400 here, like note_ids above.
2576 if not isinstance(synthesis_type, str):
2577 return JSONResponse(
2578 {
2579 "success": False,
2580 "error": "synthesis_type must be a string",
2581 },
2582 status_code=400,
2583 )
2585 # synthesize_notes returns the result without saving — callers (this
2586 # preview route, and the create-note flow downstream) decide whether
2587 # to persist it. Same call site, same payload.
2588 service = NoteAIService(username)
2589 preview = service.synthesize_notes(note_ids, synthesis_type)
2591 if "error" in preview:
2592 return JSONResponse(
2593 {"success": False, "error": preview["error"]},
2594 status_code=400,
2595 )
2597 return {"success": True, "result": preview}
2599 except Exception as e:
2600 return handle_api_error("previewing synthesis", e)
2603@router.post("/api/notes/synthesize")
2604@_notes_synthesize_limit
2605def synthesize_notes(
2606 request: Request,
2607 username: Annotated[str, Depends(require_auth)],
2608 body: _NotesBody,
2609):
2610 """
2611 Synthesize multiple notes into one.
2613 Body:
2614 note_ids: List of note IDs (2-5)
2615 synthesis_type: 'merge', 'summarize', or 'compare'
2616 create_note: Whether to create the note (default true)
2617 """
2618 from ...database.models import NoteSynthesis, NoteSynthesisSource
2620 if isinstance(body, JSONResponse): 2620 ↛ 2621line 2620 didn't jump to line 2621 because the condition on line 2620 was never true
2621 return body
2623 try:
2624 data = body
2625 if not data or not data.get("note_ids"):
2626 return JSONResponse(
2627 {"success": False, "error": "note_ids is required"},
2628 status_code=400,
2629 )
2631 note_ids = data["note_ids"]
2632 if not isinstance(note_ids, list) or not all(
2633 isinstance(n, str) for n in note_ids
2634 ):
2635 # Element type matters: synthesize_notes does dict.fromkeys(note_ids),
2636 # which raises TypeError (→ 500) on unhashable items like dicts.
2637 return JSONResponse(
2638 {
2639 "success": False,
2640 "error": "note_ids must be a list of strings",
2641 },
2642 status_code=400,
2643 )
2644 synthesis_type = data.get("synthesis_type", "merge")
2645 # synthesize_notes checks `synthesis_type not in {set}` (a hash), which
2646 # raises TypeError (→ opaque 500) on an unhashable non-string like a
2647 # list/dict. Reject to a clean 400 here, like note_ids above.
2648 if not isinstance(synthesis_type, str):
2649 return JSONResponse(
2650 {
2651 "success": False,
2652 "error": "synthesis_type must be a string",
2653 },
2654 status_code=400,
2655 )
2656 # Omitted: defaults to True (persist synthesis as a new note).
2657 create_note = data.get("create_note", True)
2658 if not isinstance(create_note, bool):
2659 logger.warning(
2660 "notes_api validation reject: create_note={} (type={}) "
2661 "(user={!r})",
2662 _log_value_preview(create_note),
2663 type(create_note).__name__,
2664 username,
2665 )
2666 return JSONResponse(
2667 {
2668 "success": False,
2669 "error": "create_note must be a boolean",
2670 },
2671 status_code=400,
2672 )
2674 ai_service = NoteAIService(username)
2675 result = ai_service.synthesize_notes(note_ids, synthesis_type)
2677 if "error" in result:
2678 return JSONResponse(
2679 {"success": False, "error": result["error"]},
2680 status_code=400,
2681 )
2683 # Create the note if requested
2684 if create_note:
2685 note_service = NoteService(username)
2686 new_note_id = note_service.create_note(
2687 title=result["suggested_title"],
2688 content=result["content"],
2689 tags=["synthesized", synthesis_type],
2690 )
2692 # Record the synthesis. Use the FILTERED source set the AI
2693 # service actually fed to the LLM (result["source_notes"])
2694 # rather than the raw client input — NoteAIService.synthesize_notes
2695 # silently drops any non-note Document IDs from the prompt, so
2696 # persisting raw note_ids would (a) record IDs the LLM never
2697 # saw as "sources" and (b) FK-violate on any bogus ID after
2698 # create_note has already committed.
2699 from ...database.session_context import get_user_db_session
2701 filtered_source_ids = [
2702 src["id"]
2703 for src in result.get("source_notes", [])
2704 if "id" in src
2705 ]
2707 db_session = None
2708 try:
2709 with get_user_db_session(username) as db_session:
2710 synthesis_record = NoteSynthesis(
2711 result_document_id=new_note_id,
2712 synthesis_type=synthesis_type,
2713 )
2714 for doc_id in filtered_source_ids:
2715 synthesis_record.sources.append(
2716 NoteSynthesisSource(source_document_id=doc_id)
2717 )
2718 db_session.add(synthesis_record)
2719 db_session.commit()
2720 except Exception:
2721 # create_note() already committed the new Document in its own
2722 # session, so a failure here would leave an orphaned
2723 # "synthesized" note with no provenance record. Roll back the
2724 # poisoned shared session and delete the just-created note
2725 # before surfacing the error.
2726 logger.exception(
2727 "Failed to persist synthesis record for note {}; "
2728 "deleting the orphaned note",
2729 new_note_id,
2730 )
2731 NoteService._rollback_quietly(db_session)
2732 try:
2733 note_service.delete_note(new_note_id)
2734 except Exception:
2735 logger.exception(
2736 "Failed to delete orphaned synthesized note {}",
2737 new_note_id,
2738 )
2739 raise
2741 result["note_id"] = new_note_id
2743 status_code = 201 if create_note else 200
2744 return JSONResponse(
2745 {"success": True, "result": result}, status_code=status_code
2746 )
2748 except Exception as e:
2749 return handle_api_error("synthesizing notes", e)
2752# ============================================================================
2753# API Routes - Version History
2754# ============================================================================
2757@router.get("/api/notes/{note_id}/versions")
2758def get_note_versions(
2759 request: Request,
2760 note_id: str,
2761 username: Annotated[str, Depends(require_auth)],
2762):
2763 """
2764 Get version history for a note.
2766 Query params:
2767 limit: Maximum number of versions to return (default 20, max 200)
2768 offset: Number of versions to skip (default 0). Combined with
2769 limit, this makes older versions reachable when total > limit.
2770 Pre-fix the route had no offset support, so versions 1
2771 through (total - limit) were unreachable through the UI.
2772 """
2773 from ...database.models import Document, NoteVersion
2774 from ...database.session_context import get_user_db_session
2776 note_service = NoteService(username)
2778 try:
2779 try:
2780 limit = _clamp_limit(request, 20)
2781 except (ValueError, TypeError):
2782 return JSONResponse(
2783 {"success": False, "error": "Invalid limit"}, status_code=400
2784 )
2786 try:
2787 offset = max(0, int(request.query_params.get("offset", 0)))
2788 except (ValueError, TypeError):
2789 return JSONResponse(
2790 {"success": False, "error": "Invalid offset"}, status_code=400
2791 )
2793 with get_user_db_session(username) as db_session:
2794 # Defense-in-depth: confirm the document is a note before
2795 # serving anything from its version history. Mirrors the
2796 # guard on the sibling version routes (get_note_version,
2797 # get_semantic_diff, restore_note_version). load_only keeps
2798 # the guard from hydrating text_content (up to 50 MB) just
2799 # to check the source type.
2800 doc = (
2801 db_session.query(Document)
2802 .options(load_only(Document.id, Document.source_type_id))
2803 .filter_by(id=note_id)
2804 .first()
2805 )
2806 if not doc or not note_service._is_note(db_session, doc):
2807 return JSONResponse(
2808 {"success": False, "error": "Note not found"},
2809 status_code=404,
2810 )
2812 total = (
2813 db_session.query(NoteVersion)
2814 .filter_by(document_id=note_id)
2815 .count()
2816 )
2817 # Cap offset at total: an unbounded OFFSET (e.g.
2818 # ?offset=1_000_000_000) forces SQLite to row-walk the
2819 # whole table in a backward index scan even though the
2820 # result is empty. Clamping is cheap and harmless — a real
2821 # caller with offset > total wants the empty page anyway.
2822 if offset > total: 2822 ↛ 2823line 2822 didn't jump to line 2823 because the condition on line 2822 was never true
2823 offset = total
2824 # Column projection: each NoteVersion row carries a full
2825 # content snapshot (up to the 50 MB cap); loading whole
2826 # entities transferred `limit` note bodies per call to render
2827 # a metadata-only list.
2828 versions = (
2829 db_session.query(
2830 NoteVersion.id,
2831 NoteVersion.title,
2832 NoteVersion.change_type,
2833 NoteVersion.change_summary,
2834 NoteVersion.created_at,
2835 )
2836 .filter_by(document_id=note_id)
2837 .order_by(
2838 NoteVersion.created_at.desc(),
2839 NoteVersion.id.desc(), # id tiebreaker for stable paging
2840 )
2841 .offset(offset)
2842 .limit(limit)
2843 .all()
2844 )
2845 return {
2846 "success": True,
2847 "total": total,
2848 "limit": limit,
2849 "offset": offset,
2850 "versions": [
2851 {
2852 "id": v.id,
2853 # Global version index — newest is `total`,
2854 # oldest is 1. Stable across pages.
2855 "version_number": total - offset - i,
2856 "title": v.title,
2857 "change_type": v.change_type,
2858 "change_summary": v.change_summary,
2859 "created_at": v.created_at.isoformat()
2860 if v.created_at
2861 else None,
2862 }
2863 for i, v in enumerate(versions)
2864 ],
2865 }
2867 except Exception as e:
2868 return handle_api_error("getting note versions", e)
2871@router.get("/api/notes/{note_id}/versions/semantic-diff")
2872@_notes_ai_limit
2873def get_semantic_diff(
2874 request: Request,
2875 note_id: str,
2876 username: Annotated[str, Depends(require_auth)],
2877):
2878 """
2879 Get AI-powered semantic diff between two versions.
2881 Query params:
2882 version1: First version UUID
2883 version2: Second version UUID, or the literal "current" to diff
2884 against the note's current content.
2885 """
2886 from ...database.models import Document, NoteVersion
2887 from ...database.session_context import get_user_db_session
2889 note_service = NoteService(username)
2891 try:
2892 version1 = request.query_params.get("version1")
2893 version2 = request.query_params.get("version2")
2895 if not version1 or not version2:
2896 return JSONResponse(
2897 {
2898 "success": False,
2899 "error": "version1 and version2 are required",
2900 },
2901 status_code=400,
2902 )
2904 with get_user_db_session(username) as db_session:
2905 # Defense-in-depth: confirm the document is a note before
2906 # serving anything from its version history. load_only defers
2907 # text_content; the "current" branch below lazy-loads it only
2908 # when actually diffing against the live note.
2909 doc = (
2910 db_session.query(Document)
2911 .options(load_only(Document.id, Document.source_type_id))
2912 .filter_by(id=note_id)
2913 .first()
2914 )
2915 if not doc or not note_service._is_note(db_session, doc):
2916 return JSONResponse(
2917 {"success": False, "error": "Note not found"},
2918 status_code=404,
2919 )
2921 v1 = (
2922 db_session.query(NoteVersion)
2923 .filter_by(document_id=note_id, id=version1)
2924 .first()
2925 )
2926 if not v1:
2927 return JSONResponse(
2928 {"success": False, "error": "Version not found"},
2929 status_code=404,
2930 )
2932 if version2 == "current":
2933 v2_content = doc.text_content or ""
2934 else:
2935 v2 = (
2936 db_session.query(NoteVersion)
2937 .filter_by(document_id=note_id, id=version2)
2938 .first()
2939 )
2940 if not v2: 2940 ↛ 2945line 2940 didn't jump to line 2945 because the condition on line 2940 was always true
2941 return JSONResponse(
2942 {"success": False, "error": "Version not found"},
2943 status_code=404,
2944 )
2945 v2_content = v2.content
2947 ai_service = NoteAIService(username)
2948 diff = ai_service.semantic_diff(v1.content, v2_content)
2950 return {"success": True, "diff": diff}
2952 except Exception as e:
2953 return handle_api_error("getting semantic diff", e)
2956@router.get("/api/notes/{note_id}/versions/{version_id}")
2957def get_note_version(
2958 request: Request,
2959 note_id: str,
2960 version_id: str,
2961 username: Annotated[str, Depends(require_auth)],
2962):
2963 """Get a specific version of a note."""
2964 from ...database.models import Document, NoteVersion
2965 from ...database.session_context import get_user_db_session
2967 note_service = NoteService(username)
2969 try:
2970 with get_user_db_session(username) as db_session:
2971 # Defense-in-depth: confirm the parent document is a note.
2972 # load_only keeps the guard from hydrating text_content.
2973 doc = (
2974 db_session.query(Document)
2975 .options(load_only(Document.id, Document.source_type_id))
2976 .filter_by(id=note_id)
2977 .first()
2978 )
2979 if not doc or not note_service._is_note(db_session, doc):
2980 return JSONResponse(
2981 {"success": False, "error": "Note not found"},
2982 status_code=404,
2983 )
2985 version = (
2986 db_session.query(NoteVersion)
2987 .filter_by(document_id=note_id, id=version_id)
2988 .first()
2989 )
2991 if not version:
2992 return JSONResponse(
2993 {"success": False, "error": "Version not found"},
2994 status_code=404,
2995 )
2997 return {
2998 "success": True,
2999 "version": {
3000 "id": version.id,
3001 "title": version.title,
3002 "content": version.content,
3003 "tags": version.tags,
3004 "change_type": version.change_type,
3005 "change_summary": version.change_summary,
3006 "created_at": version.created_at.isoformat()
3007 if version.created_at
3008 else None,
3009 },
3010 }
3012 except Exception as e:
3013 return handle_api_error("getting note version", e)
3016@router.post("/api/notes/{note_id}/versions/{version_id}/restore")
3017@_notes_write_limit
3018def restore_note_version(
3019 request: Request,
3020 note_id: str,
3021 version_id: str,
3022 username: Annotated[str, Depends(require_auth)],
3023 body: _NotesBody,
3024):
3025 """Restore a note to a previous version.
3027 Delegates to ``NoteService.restore_with_bookends`` which wraps
3028 PRE_RESTORE + content update + RESTORE in a single transaction.
3029 Earlier code orchestrated this from three separate sessions — process
3030 death between writes could leave a restored note with no audit-trail
3031 row. Keep the orchestration in the service; this handler just routes.
3032 """
3033 if isinstance(body, JSONResponse): 3033 ↛ 3034line 3033 didn't jump to line 3034 because the condition on line 3033 was never true
3034 return body
3036 note_service = NoteService(username)
3038 try:
3039 success, error = note_service.restore_with_bookends(note_id, version_id)
3040 if not success:
3041 if error == "note_not_found": 3041 ↛ 3046line 3041 didn't jump to line 3046 because the condition on line 3041 was always true
3042 return JSONResponse(
3043 {"success": False, "error": "Note not found"},
3044 status_code=404,
3045 )
3046 if error == "version_not_found":
3047 return JSONResponse(
3048 {"success": False, "error": "Version not found"},
3049 status_code=404,
3050 )
3051 return JSONResponse(
3052 {"success": False, "error": "Failed to restore version"},
3053 status_code=500,
3054 )
3056 _trigger_note_auto_index(
3057 note_id, note_service, username, request.session.get("session_id")
3058 )
3060 return {
3061 "success": True,
3062 "message": f"Restored to version {version_id[:8]}",
3063 }
3065 except Exception as e:
3066 return handle_api_error("restoring note version", e)