Coverage for src/local_deep_research/web/routes/notes_routes.py: 70%
1134 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""
2Notes Routes - API endpoints and pages for the notes feature.
3"""
5from flask import Blueprint, jsonify, request, session
6from loguru import logger
7from sqlalchemy.exc import IntegrityError
8from sqlalchemy.orm import load_only
10from ..auth.decorators import login_required
11from ...research_library.notes.services.note_ai_service import NoteAIService
12from ...research_library.notes.services.note_service import (
13 MAX_TAG_LENGTH,
14 MAX_TAGS_PER_NOTE,
15 NOTE_CONTENT_MAX_BYTES,
16 NoteService,
17 _assert_content_size,
18 escape_markdown_link_label,
19)
20from ...research_library.utils import handle_api_error
21from ...security.rate_limiter import limiter, _get_api_user_key
22from ..utils.templates import render_template_with_defaults
24# Cap on user-supplied free-text query params that flow into `ILIKE %...%`
25# over unbounded TEXT columns — shared with unified_search_routes via
26# _search_constants so the two route modules can't diverge.
27from ._search_constants import MAX_SEARCH_LEN
29# Bound at import time (not looked up on the class at call time) because
30# tests monkeypatch the ``NoteAIService`` name in this module.
31_MAX_GRADE_SOURCES = NoteAIService.MAX_GRADE_SOURCES
33MAX_LINK_TEXT_LEN = 500
34# Per-claim length cap for the fact-check grade endpoint (claims are free text
35# from the client; the grader bounds the count but not the per-string size).
36MAX_CLAIM_LEN = 1000
37# Upper bound on any ?limit query param across the list/search endpoints
38# (was an unnamed 200 repeated at six call sites).
39MAX_LIMIT = 200
42def _clamp_limit(default, max_=MAX_LIMIT):
43 """Parse ``?limit`` into ``[1, max_]``, defaulting to ``default``.
45 Raises ``ValueError`` on a non-integer value; the caller wraps this in
46 ``try`` and returns the 400 (matching the six former inline copies).
47 """
48 return max(1, min(int(request.args.get("limit", default)), max_))
51def _clamp_text_query(value, name, max_len=MAX_SEARCH_LEN):
52 """Return value or raise ValueError if it exceeds the cap.
54 ``None``/empty pass through unchanged so optional params behave the
55 same as before. The caller wraps this in ``try`` and lets
56 ``handle_api_error`` produce the 4xx response.
57 """
58 if value is None:
59 return value
60 if not isinstance(value, str): 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true
61 raise ValueError(f"{name} must be a string")
62 if len(value) > max_len:
63 raise ValueError(f"{name} exceeds maximum length ({max_len} chars)")
64 return value
67# Rate limits for AI-heavy endpoints. Split into two buckets so cheap
68# FAISS / local-embedding lookups (semantic search, similar notes,
69# suggested links, related research) don't drain the same budget as
70# expensive LLM calls (summarize, suggest_tags, extract_key_concepts,
71# semantic_diff). A user typing through the autocomplete UI shouldn't
72# block a single LLM call for a full minute.
73#
74# ``key_func=_get_api_user_key`` keys each bucket per authenticated user;
75# without it the global Limiter default (``get_client_ip``) would let
76# shared-IP users (NAT, lab, reverse-proxy-without-trusted-forward-headers)
77# drain each other's LLM/synthesis budgets. Matches the pattern in
78# rate_limiter.py for sibling per-user buckets (journal_data, journals_read,
79# api_rate_limit, upload_rate_limit_user).
80_notes_ai_limit = limiter.shared_limit(
81 "10 per minute", scope="notes_ai", key_func=_get_api_user_key
82)
83_notes_search_limit = limiter.shared_limit(
84 "60 per minute", scope="notes_search", key_func=_get_api_user_key
85)
86_notes_synthesize_limit = limiter.shared_limit(
87 "5 per minute", scope="notes_synthesize", key_func=_get_api_user_key
88)
89# Notes write budget — applies to create_note, update_note, delete_note,
90# and add/remove from collection. 60/min comfortably covers active
91# editing (a save every second for a minute) but blocks scripted abuse:
92# pre-fix an authenticated user could rapid-POST PUT /api/notes/<id>
93# under the global IP cap (5000/hr) and saturate the 4-worker
94# summary_executor with bursty LLM change-summary tasks, queueing
95# hundreds of MB of (old, new) content pairs. Per-user keying so two
96# real users on shared NAT don't compete for the same bucket.
97_notes_write_limit = limiter.shared_limit(
98 "60 per minute", scope="notes_write", key_func=_get_api_user_key
99)
100# Save-as-note copies a whole completed report (up to the 50MB content
101# cap) into a fresh note + version snapshot per call. At the generic
102# notes_write 60/min that's on the order of GBs/min of per-user DB writes
103# from one client — a disk/backup-bloat vector. Tighter, cost-aware bucket.
104_notes_save_as_note_limit = limiter.shared_limit(
105 "10 per minute", scope="notes_save_as_note", key_func=_get_api_user_key
106)
107# Fact-check kicks off a full LDR research run downstream, so it's strictly
108# tighter than the LLM bucket — a few per minute bounds research load.
109_notes_factcheck_limit = limiter.shared_limit(
110 "3 per minute", scope="notes_factcheck", key_func=_get_api_user_key
111)
113# Create a Blueprint for the notes routes
114notes_bp = Blueprint("notes", __name__, url_prefix="/notes")
116# Pre-parse request-body ceiling for this JSON API surface. The app-wide
117# MAX_CONTENT_LENGTH is sized for multi-file uploads (hundreds of GB), and
118# request.get_json() buffers + parses the WHOLE body before any service
119# validation runs — so without this check a multi-GB JSON body reached
120# memory before _assert_content_size could reject it at 50 MB.
121#
122# Budget 2× the content cap. This bounds pre-parse memory; it does NOT
123# precisely enforce the 50 MB content limit (that stays in
124# _assert_content_size after parsing). 2× covers the realistic worst case
125# where every character needs a 2-byte JSON escape (``"`` / ``\`` /
126# newline); content dominated by control chars (each expands to a 6-byte
127# ``\uXXXX``) can still exceed this and get a 413 even though its decoded
128# size is under 50 MB — an acceptable trade for keeping the cap far below
129# a memory-exhausting multi-GB body.
130_MAX_JSON_BODY_BYTES = 2 * NOTE_CONTENT_MAX_BYTES
133def arm_notes_body_cap():
134 """Set the JSON body cap on the request so Werkzeug enforces it while
135 READING the stream (catches ``Transfer-Encoding: chunked`` bodies that
136 carry no Content-Length and would otherwise be buffered whole).
138 Registered as an APP-level ``before_request`` in app_factory, BEFORE
139 ``CSRFProtect(app)``: Flask-WTF's CSRF check reads ``request.form`` on
140 every mutating request, which caches ``request.stream`` with the
141 max_content_length in effect at THAT moment. App-level hooks run in
142 registration order, so a notes-blueprint hook (or a later app-level
143 one) runs too late — the stream is already cached under the app-wide,
144 multi-file-upload MAX_CONTENT_LENGTH (hundreds of GB) and the cap never
145 takes effect for chunked bodies.
146 """
147 if request.blueprint == notes_bp.name:
148 request.max_content_length = _MAX_JSON_BODY_BYTES
151@notes_bp.before_request
152def _reject_oversized_bodies():
153 """413 oversized bodies with a Content-Length header before parsing.
155 The stream-read cap (chunked bodies too) is armed earlier by
156 ``arm_notes_body_cap``; this hook adds the clean JSON 413 for the
157 common Content-Length case.
158 """
159 if (
160 request.content_length is not None
161 and request.content_length > _MAX_JSON_BODY_BYTES
162 ):
163 return jsonify(
164 {
165 "success": False,
166 "error": (
167 "Request body too large "
168 f"(max {_MAX_JSON_BODY_BYTES // (1024 * 1024)} MB)"
169 ),
170 }
171 ), 413
172 return None
175@notes_bp.before_request
176def _reject_non_object_json_body():
177 """400 a JSON body that parses to something other than an object.
179 Every mutating notes route reads the parsed body as a dict
180 (``data.get(...)``). A truthy non-dict JSON value — ``true``, ``123``,
181 ``"x"``, ``[1, 2]`` — slips past the ``... or {}`` / ``if not data``
182 idioms those routes use (which only catch *falsy* non-dicts) and then
183 raises ``AttributeError``/``TypeError`` on the first ``.get``/subscript,
184 surfacing as an uncaught 500. Reject it here, once, with a clean 400.
186 Scope is deliberately narrow: only a body the client DECLARED as JSON
187 (``request.is_json``) that parses to a non-``None`` non-dict is rejected.
188 A missing/empty body (parses to ``None``) is left for each route to
189 default as it sees fit, and a malformed body (``get_json(silent=True)``
190 → ``None``) is likewise passed through unchanged. No notes route accepts
191 a top-level JSON array or scalar, so this is safe blueprint-wide. Runs
192 after ``_reject_oversized_bodies`` so an oversized body still 413s first.
193 """
194 if request.method in ("POST", "PUT", "PATCH"): 194 ↛ 204line 194 didn't jump to line 204 because the condition on line 194 was always true
195 if request.is_json:
196 data = request.get_json(silent=True)
197 if data is not None and not isinstance(data, dict):
198 return jsonify(
199 {
200 "success": False,
201 "error": "Request body must be a JSON object",
202 }
203 ), 400
204 return None
207def _trigger_note_auto_index(note_id, service, username):
208 """Trigger async auto-indexing for a note (non-blocking)."""
209 try:
210 from ...research_library.routes.rag_routes import trigger_auto_index
211 from ...database.session_passwords import session_password_store
213 session_id = session.get("session_id")
214 db_password = session_password_store.get_session_password(
215 username, session_id
216 )
217 if not db_password: 217 ↛ 223line 217 didn't jump to line 223 because the condition on line 217 was always true
218 logger.debug(
219 "Skipping note auto-index for {}: no session password available",
220 note_id,
221 )
222 return
223 collection_id = service.get_notes_collection_id()
224 if not collection_id:
225 logger.debug(
226 "Skipping note auto-index for {}: no notes collection",
227 note_id,
228 )
229 return
230 trigger_auto_index([note_id], collection_id, username, db_password)
231 except Exception:
232 logger.exception("Failed to trigger auto-indexing for note")
235# ============================================================================
236# Page Routes
237# ============================================================================
240@notes_bp.route("/")
241@login_required
242def notes_page():
243 """Render the notes list page."""
244 return render_template_with_defaults(
245 "pages/notes.html", active_page="notes"
246 )
249@notes_bp.route("/<string:note_id>")
250@login_required
251def note_detail_page(note_id):
252 """Render the note detail page."""
253 return render_template_with_defaults(
254 "pages/note_detail.html",
255 active_page="notes",
256 note_id=note_id,
257 )
260# ============================================================================
261# API Routes - Notes CRUD
262# ============================================================================
265@notes_bp.route("/api/notes", methods=["GET"])
266@login_required
267@_notes_search_limit
268def list_notes():
269 """
270 List notes with optional filtering.
272 Query params:
273 collection_id: Filter by collection
274 search: Search in title and content (capped at MAX_SEARCH_LEN)
275 pinned_only: Only return pinned notes
276 limit: Maximum number (default 100)
277 offset: Offset for pagination (default 0)
278 """
279 username = session.get("username")
280 if not username: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 return jsonify({"success": False, "error": "Not authenticated"}), 401
283 try:
284 service = NoteService(username)
286 collection_id = request.args.get("collection_id")
287 try:
288 search = _clamp_text_query(request.args.get("search"), "search")
289 except ValueError as exc:
290 return jsonify({"success": False, "error": str(exc)}), 400
291 pinned_only = request.args.get("pinned_only", "").lower() == "true"
292 try:
293 limit = _clamp_limit(100)
294 offset = max(0, int(request.args.get("offset", 0)))
295 except (ValueError, TypeError):
296 return jsonify(
297 {"success": False, "error": "Invalid limit or offset"}
298 ), 400
300 # Total under the same filter — lets the frontend render a real
301 # page-N-of-M control rather than guessing from len(notes) ==
302 # limit. count_notes repeats the filtered scan list_notes runs
303 # (for a keyword search that is a second full ILIKE pass over
304 # text_content), so skip it when a short first page already
305 # reveals the total.
306 if offset == 0:
307 notes = service.list_notes(
308 collection_id=collection_id,
309 search=search,
310 pinned_only=pinned_only,
311 limit=limit,
312 offset=0,
313 )
314 total = (
315 len(notes)
316 if len(notes) < limit
317 else service.count_notes(
318 collection_id=collection_id,
319 search=search,
320 pinned_only=pinned_only,
321 )
322 )
323 else:
324 total = service.count_notes(
325 collection_id=collection_id,
326 search=search,
327 pinned_only=pinned_only,
328 )
329 # Clamp an offset past the end of the result set: it returns
330 # an empty page anyway, so don't make the DB scan-and-discard
331 # `offset` rows for a guaranteed-empty result (mirrors
332 # get_note_versions).
333 if offset > total:
334 offset = total
336 notes = service.list_notes(
337 collection_id=collection_id,
338 search=search,
339 pinned_only=pinned_only,
340 limit=limit,
341 offset=offset,
342 )
344 return jsonify(
345 {
346 "success": True,
347 "notes": notes,
348 "total": total,
349 "limit": limit,
350 "offset": offset,
351 }
352 )
354 except Exception as e:
355 return handle_api_error("listing notes", e)
358@notes_bp.route("/api/notes", methods=["POST"])
359@login_required
360@_notes_write_limit
361def create_note():
362 """
363 Create a new note.
365 Body:
366 title: Note title (required)
367 content: Note content (required)
368 tags: List of tags (optional)
369 collection_ids: List of collection IDs (optional)
370 """
371 username = session.get("username")
372 if not username:
373 return jsonify({"success": False, "error": "Not authenticated"}), 401
375 try:
376 data = request.get_json(silent=True)
377 if not isinstance(data, dict) or not data:
378 # get_json returns the parsed body verbatim, so a top-level JSON
379 # array/string/number would pass a bare truthiness check and then
380 # crash data.get() with AttributeError → opaque 500. Require an
381 # object.
382 return jsonify({"success": False, "error": "No data provided"}), 400
384 title = data.get("title")
385 content = data.get("content")
387 if not title:
388 return jsonify(
389 {"success": False, "error": "Title is required"}
390 ), 400
391 if not content:
392 return jsonify(
393 {"success": False, "error": "Content is required"}
394 ), 400
396 service = NoteService(username)
397 note_id = service.create_note(
398 title=title,
399 content=content,
400 tags=data.get("tags"),
401 collection_ids=data.get("collection_ids"),
402 )
404 # Trigger async auto-indexing (non-blocking)
405 _trigger_note_auto_index(note_id, service, username)
407 return jsonify({"success": True, "id": note_id}), 201
409 except ValueError as e:
410 # Validation failures (content size, tag shape) — surface the
411 # actual message so the client can fix and retry.
412 return jsonify({"success": False, "error": str(e)}), 400
413 except Exception as e:
414 return handle_api_error("creating note", e)
417@notes_bp.route("/api/notes/<string:note_id>", methods=["GET"])
418@login_required
419def get_note(note_id):
420 """Get a specific note."""
421 username = session.get("username")
422 if not username:
423 return jsonify({"success": False, "error": "Not authenticated"}), 401
425 try:
426 service = NoteService(username)
427 note = service.get_note(note_id)
429 if not note:
430 return jsonify({"success": False, "error": "Note not found"}), 404
432 return jsonify({"success": True, "note": note})
434 except Exception as e:
435 return handle_api_error("getting note", e)
438@notes_bp.route("/api/notes/<string:note_id>", methods=["PUT"])
439@login_required
440@_notes_write_limit
441def update_note(note_id):
442 """
443 Update a note.
445 Body:
446 title: New title (optional)
447 content: New content (optional)
448 tags: New tags (optional)
449 pinned: New pinned status (optional) — wire-level alias for the
450 ``Document.favorite`` column; the UI vocabulary is "pin".
451 """
452 username = session.get("username")
453 if not username: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 return jsonify({"success": False, "error": "Not authenticated"}), 401
456 try:
457 data = request.get_json(silent=True)
458 if not data: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true
459 return jsonify({"success": False, "error": "No data provided"}), 400
461 # Every sibling field gets a clean 400 via the service validators
462 # (_validate_title/_assert_content_size/_validate_tags); pinned is
463 # assigned straight into the Boolean column, where a non-bool
464 # raises StatementError at flush → an opaque 500.
465 pinned = data.get("pinned")
466 if pinned is not None and not isinstance(pinned, bool):
467 return jsonify(
468 {"success": False, "error": "pinned must be a boolean"}
469 ), 400
471 service = NoteService(username)
472 content_provided = data.get("content") is not None
473 try:
474 success = service.update_note(
475 note_id=note_id,
476 title=data.get("title"),
477 content=data.get("content"),
478 tags=data.get("tags"),
479 favorite=pinned,
480 )
481 except ValueError as e:
482 # Validation failures (content size, tag shape) — surface the
483 # actual message so the client can fix and retry.
484 return jsonify({"success": False, "error": str(e)}), 400
486 if not success: 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true
487 return jsonify({"success": False, "error": "Note not found"}), 404
489 # Trigger async auto-indexing if content OR title was updated
490 # (non-blocking). The service marks DocumentCollection.indexed=False
491 # for either change and relies on this post-commit trigger; the
492 # title is baked into every chunk's metadata at index time, so a
493 # rename leaves stale titles in search results until reindexed. A
494 # title-only PUT used to flip indexed=False without ever scheduling
495 # the reindex. Unchanged values are harmless: the worker runs with
496 # force_reindex=False and skips rows still marked indexed=True.
497 if content_provided or data.get("title") is not None:
498 _trigger_note_auto_index(note_id, service, username)
500 return jsonify({"success": True})
502 except Exception as e:
503 return handle_api_error("updating note", e)
506@notes_bp.route("/api/notes/<string:note_id>", methods=["DELETE"])
507@login_required
508@_notes_write_limit
509def delete_note(note_id):
510 """Delete a note."""
511 username = session.get("username")
512 if not username:
513 return jsonify({"success": False, "error": "Not authenticated"}), 401
515 try:
516 service = NoteService(username)
517 success = service.delete_note(note_id)
519 if not success:
520 return jsonify({"success": False, "error": "Note not found"}), 404
522 return jsonify({"success": True})
524 except Exception as e:
525 return handle_api_error("deleting note", e)
528# ============================================================================
529# API Routes - Collections
530# ============================================================================
533@notes_bp.route("/api/notes/<string:note_id>/collections", methods=["GET"])
534@login_required
535def get_note_collections(note_id):
536 """Get collections a note belongs to."""
537 username = session.get("username")
538 if not username: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 return jsonify({"success": False, "error": "Not authenticated"}), 401
541 try:
542 service = NoteService(username)
543 # 404 on unknown note id rather than returning an empty list.
544 # Pre-fix, a typo in the note UUID returned 200 with [] —
545 # indistinguishable from "this note has no collections" — making
546 # client-side bugs invisible to QA. The sibling get_note route
547 # already 404s for unknown ids; align with that contract.
548 if not service.note_exists(note_id): 548 ↛ 550line 548 didn't jump to line 550 because the condition on line 548 was always true
549 return jsonify({"success": False, "error": "Note not found"}), 404
550 collections = service.get_note_collections(note_id)
552 return jsonify({"success": True, "collections": collections})
554 except Exception as e:
555 return handle_api_error("getting note collections", e)
558@notes_bp.route("/api/notes/<string:note_id>/collections", methods=["POST"])
559@login_required
560@_notes_write_limit
561def add_note_to_collection(note_id):
562 """
563 Add a note to a collection.
565 Body:
566 collection_id: The collection ID
567 """
568 username = session.get("username")
569 if not username: 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true
570 return jsonify({"success": False, "error": "Not authenticated"}), 401
572 try:
573 data = request.get_json(silent=True)
574 if not data or not data.get("collection_id"): 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true
575 return jsonify(
576 {"success": False, "error": "collection_id is required"}
577 ), 400
579 service = NoteService(username)
580 # add_to_collection returns the same False for "note id missing /
581 # not a note" and "already a member"; without this guard a stale
582 # id (note deleted in another tab) produced a factually wrong 409
583 # "Already in collection". 404 first, like get_note_collections.
584 if not service.note_exists(note_id):
585 return jsonify({"success": False, "error": "Note not found"}), 404
586 success = service.add_to_collection(note_id, data["collection_id"])
588 if not success: 588 ↛ 593line 588 didn't jump to line 593 because the condition on line 588 was always true
589 return jsonify(
590 {"success": False, "error": "Already in collection"}
591 ), 409
593 return jsonify({"success": True})
595 except LookupError:
596 return jsonify({"success": False, "error": "Collection not found"}), 404
597 except Exception as e:
598 return handle_api_error("adding note to collection", e)
601@notes_bp.route(
602 "/api/notes/<string:note_id>/collections/<string:collection_id>",
603 methods=["DELETE"],
604)
605@login_required
606@_notes_write_limit
607def remove_note_from_collection(note_id, collection_id):
608 """Remove a note from a collection."""
609 username = session.get("username")
610 if not username: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true
611 return jsonify({"success": False, "error": "Not authenticated"}), 401
613 try:
614 service = NoteService(username)
615 # Same conflation as add_note_to_collection: a missing/non-note id
616 # used to 404 with the misleading "Not in collection" message.
617 if not service.note_exists(note_id): 617 ↛ 619line 617 didn't jump to line 619 because the condition on line 617 was always true
618 return jsonify({"success": False, "error": "Note not found"}), 404
619 success = service.remove_from_collection(note_id, collection_id)
621 if not success:
622 return jsonify(
623 {"success": False, "error": "Not in collection"}
624 ), 404
626 return jsonify({"success": True})
628 except ValueError as e:
629 # Guard failures (e.g. unlinking from the system Notes collection,
630 # which is every note's persistent home) — surface the message.
631 return jsonify({"success": False, "error": str(e)}), 400
632 except Exception as e:
633 return handle_api_error("removing note from collection", e)
636# ============================================================================
637# API Routes - Research
638# ============================================================================
641@notes_bp.route("/api/notes/<string:note_id>/research", methods=["GET"])
642@login_required
643def get_note_research(note_id):
644 """Get research runs triggered from a note."""
645 username = session.get("username")
646 if not username: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true
647 return jsonify({"success": False, "error": "Not authenticated"}), 401
649 try:
650 service = NoteService(username)
651 # 404 on unknown note id — see get_note_collections for the
652 # rationale. The two routes had the same contract gap.
653 if not service.note_exists(note_id): 653 ↛ 655line 653 didn't jump to line 655 because the condition on line 653 was always true
654 return jsonify({"success": False, "error": "Note not found"}), 404
655 research = service.get_note_research(note_id)
657 return jsonify({"success": True, "research": research})
659 except Exception as e:
660 return handle_api_error("getting note research", e)
663@notes_bp.route("/api/notes/<string:note_id>/research", methods=["POST"])
664@login_required
665@_notes_write_limit
666def link_research_to_note(note_id):
667 """
668 Link a research run to a note.
670 Body:
671 research_id: The research history ID
672 """
673 username = session.get("username")
674 if not username:
675 return jsonify({"success": False, "error": "Not authenticated"}), 401
677 try:
678 data = request.get_json(silent=True)
679 if not data or not data.get("research_id"):
680 return jsonify(
681 {"success": False, "error": "research_id is required"}
682 ), 400
684 service = NoteService(username)
685 link_id = service.link_research_to_note(
686 note_id=note_id,
687 research_id=data["research_id"],
688 )
690 return jsonify({"success": True, "id": link_id}), 201
692 except ValueError as e:
693 # A missing/non-note id surfaces as ValueError("... not found") — a
694 # 404 like every sibling note route; the research-per-note cap is a
695 # client-fixable 400.
696 if "not found" in str(e).lower():
697 return jsonify({"success": False, "error": str(e)}), 404
698 return jsonify({"success": False, "error": str(e)}), 400
699 except IntegrityError as exc:
700 # Only the (document_id, research_id) uniqueness collision means the
701 # research is already linked → 409. A display_order retry-exhaustion
702 # or an FK race (stale/deleted research_id) also surfaces as
703 # IntegrityError but is NOT a duplicate — don't mislabel those as
704 # "already linked"; let them fall through to a generic error.
705 msg = str(getattr(exc, "orig", exc)).lower()
706 if "foreign key" in msg:
707 # research_id references a research run that doesn't exist (or
708 # was deleted) — a client-fixable 404, not an opaque 500.
709 return jsonify(
710 {"success": False, "error": "Research run not found"}
711 ), 404
712 if "display_order" in msg:
713 return handle_api_error("linking research to note", exc)
714 return jsonify(
715 {"success": False, "error": "Research already linked to this note"}
716 ), 409
717 except Exception as e:
718 return handle_api_error("linking research to note", e)
721@notes_bp.route(
722 "/api/notes/<string:note_id>/research/<string:research_id>",
723 methods=["PATCH"],
724)
725@login_required
726@_notes_write_limit
727def patch_note_research(note_id, research_id):
728 """Update a NoteResearch row (currently supports is_collapsed toggle).
730 Body:
731 is_collapsed: bool (optional)
732 """
733 username = session.get("username")
734 if not username: 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true
735 return jsonify({"success": False, "error": "Not authenticated"}), 401
737 try:
738 data = request.get_json(silent=True) or {}
739 service = NoteService(username)
740 updated = service.update_note_research(
741 note_id=note_id,
742 research_id=research_id,
743 is_collapsed=data.get("is_collapsed"),
744 )
745 if not updated:
746 return jsonify(
747 {"success": False, "error": "Research link not found"}
748 ), 404
749 return jsonify({"success": True})
751 except Exception as e:
752 return handle_api_error("updating note research", e)
755@notes_bp.route(
756 "/api/notes/<string:note_id>/research/reorder", methods=["POST"]
757)
758@login_required
759@_notes_write_limit
760def reorder_note_research(note_id):
761 """Atomically reorder research items for a note.
763 Body:
764 research_ids: list of research_id strings in desired display order.
765 """
766 username = session.get("username")
767 if not username:
768 return jsonify({"success": False, "error": "Not authenticated"}), 401
770 try:
771 data = request.get_json(silent=True) or {}
772 research_ids = data.get("research_ids")
773 if not isinstance(research_ids, list) or not research_ids:
774 return jsonify(
775 {
776 "success": False,
777 "error": "research_ids must be a non-empty list",
778 }
779 ), 400
780 if not all(isinstance(r, str) for r in research_ids):
781 # Non-string elements (nested arrays/objects) are unhashable and
782 # crash the dedup/reorder logic with TypeError → opaque 500.
783 return jsonify(
784 {
785 "success": False,
786 "error": "research_ids must be a list of strings",
787 }
788 ), 400
790 service = NoteService(username)
791 # 404 a stale/deleted note id BEFORE the ids-match check, mirroring
792 # every sibling note-scoped route (get_note_research,
793 # add_note_to_collection, ...). Otherwise reorder_note_research finds
794 # no NoteResearch rows for the missing note, returns False on the
795 # ids-mismatch path, and the route misreports a 404 condition (note
796 # deleted in another tab) as a 400 validation error.
797 if not service.note_exists(note_id):
798 return jsonify({"success": False, "error": "Note not found"}), 404
799 ok = service.reorder_note_research(note_id, research_ids)
800 if not ok: 800 ↛ 807line 800 didn't jump to line 807 because the condition on line 800 was always true
801 return jsonify(
802 {
803 "success": False,
804 "error": "research_ids do not match the note's linked research",
805 }
806 ), 400
807 return jsonify({"success": True})
809 except ValueError as e:
810 # Validation failures (research-per-note cap) — surface the
811 # actual message so the client can fix and retry.
812 return jsonify({"success": False, "error": str(e)}), 400
813 except Exception as e:
814 return handle_api_error("reordering note research", e)
817# ============================================================================
818# API Routes - Research → Notes (results-page Notes panel)
819# ============================================================================
822@notes_bp.route("/api/research/<string:research_id>/notes", methods=["GET"])
823@login_required
824@_notes_search_limit
825def get_research_notes(research_id):
826 """List the notes linked to a research run.
828 The reverse of GET /api/notes/<id>/research — powers the Notes panel
829 on the research results page, so commentary attached to a run is
830 visible from the run itself, not only from the note.
831 """
832 username = session.get("username")
833 if not username: 833 ↛ 834line 833 didn't jump to line 834 because the condition on line 833 was never true
834 return jsonify({"success": False, "error": "Not authenticated"}), 401
836 try:
837 service = NoteService(username)
838 notes = service.get_notes_for_research(research_id)
839 if notes is None:
840 return jsonify(
841 {"success": False, "error": "Research not found"}
842 ), 404
843 return jsonify({"success": True, "notes": notes})
845 except Exception as e:
846 return handle_api_error("listing notes for research", e)
849@notes_bp.route("/api/research/<string:research_id>/notes", methods=["POST"])
850@login_required
851@_notes_write_limit
852def create_research_note(research_id):
853 """Create a note pre-linked to a research run (all-or-nothing).
855 Body (all optional): ``title``, ``content``, ``tags``. Defaults
856 produce a starter note titled after the research query. The results
857 page's "Add note" (no body) and "Clip to note" (content = the quoted
858 selection) both come through here.
859 """
860 username = session.get("username")
861 if not username: 861 ↛ 862line 861 didn't jump to line 862 because the condition on line 861 was never true
862 return jsonify({"success": False, "error": "Not authenticated"}), 401
864 try:
865 data = request.get_json(silent=True) or {}
866 service = NoteService(username)
867 try:
868 note_id = service.create_note_for_research(
869 research_id,
870 title=data.get("title"),
871 content=data.get("content"),
872 tags=data.get("tags"),
873 )
874 except LookupError:
875 return jsonify(
876 {"success": False, "error": "Research not found"}
877 ), 404
878 # Reach semantic search / "Ask your notes" like a normally-created
879 # note (the plain POST /api/notes route does the same at its tail).
880 _trigger_note_auto_index(note_id, service, username)
881 return jsonify({"success": True, "note_id": note_id}), 201
883 except ValueError as e:
884 # Validation failures (title/content caps, tag shape) — surface
885 # the actual message so the client can fix and retry.
886 return jsonify({"success": False, "error": str(e)}), 400
887 except Exception as e:
888 return handle_api_error("creating note for research", e)
891@notes_bp.route(
892 "/api/research/<string:research_id>/save-as-note", methods=["POST"]
893)
894@login_required
895@_notes_save_as_note_limit
896def save_research_as_note(research_id):
897 """Copy a COMPLETED research run's report into a new, linked note.
899 Deliberately a derivation, not a conversion: the report stays the
900 immutable record (fact-check grades claims against it and its
901 provenance must survive), while the note is the user's editable
902 copy. Provenance is kept twice — the NoteResearch link and a header
903 line inside the note content — so the copy stays traceable even if
904 it is later unlinked.
905 """
906 username = session.get("username")
907 if not username: 907 ↛ 908line 907 didn't jump to line 908 because the condition on line 907 was never true
908 return jsonify({"success": False, "error": "Not authenticated"}), 401
910 try:
911 from ...constants import ResearchStatus
912 from ...database.models import ResearchHistory
913 from ...database.session_context import get_user_db_session
914 from ...storage import get_report_storage
916 with get_user_db_session(username) as db:
917 research = (
918 db.query(ResearchHistory).filter_by(id=research_id).first()
919 )
920 if research is None:
921 return jsonify(
922 {"success": False, "error": "Research not found"}
923 ), 404
924 if research.status != ResearchStatus.COMPLETED:
925 return jsonify(
926 {
927 "success": False,
928 "error": "Research is not complete yet.",
929 "status": research.status,
930 }
931 ), 409
932 query_text = (research.query or "").strip()
933 meta = dict(research.research_meta or {})
935 # Fetch the report markdown in-request (same pattern as the
936 # fact-check grade flow: the per-user DB password is available
937 # here, unlike in a background worker).
938 settings_snapshot = meta.get("settings_snapshot")
939 with get_user_db_session(username) as db:
940 storage = get_report_storage(
941 session=db, settings_snapshot=settings_snapshot
942 )
943 report = storage.get_report(research_id, username)
945 if not report or not str(report).strip():
946 # get_report returns None for BOTH a missing report and a
947 # swallowed read error; saving an empty note would look like
948 # success while losing the content. Refuse instead.
949 return jsonify(
950 {
951 "success": False,
952 "error": (
953 "The research report is unavailable, so it cannot "
954 "be saved as a note. Try re-running the research."
955 ),
956 }
957 ), 502
959 from ...research_library.notes.services.note_service import (
960 MAX_TITLE_LENGTH,
961 )
963 title = (
964 f"Research: {query_text}"[:MAX_TITLE_LENGTH]
965 if query_text
966 else f"Research {research_id[:8]}"
967 )
968 provenance = (
969 f"> Saved from the research run "
970 f"[{escape_markdown_link_label(query_text or research_id)}]"
971 f"(/results/{research_id}).\n\n"
972 )
974 service = NoteService(username)
975 try:
976 note_id = service.create_note_for_research(
977 research_id, title=title, content=provenance + str(report)
978 )
979 except LookupError:
980 # Deleted between the checks above and the create — treat like
981 # any other missing research.
982 return jsonify(
983 {"success": False, "error": "Research not found"}
984 ), 404
985 _trigger_note_auto_index(note_id, service, username)
986 return jsonify({"success": True, "note_id": note_id}), 201
988 except ValueError as e:
989 # Validation failures — most likely the report exceeding the note
990 # content cap. Client-fixable only in the sense that the user
991 # should know why it refused; surface the message.
992 return jsonify({"success": False, "error": str(e)}), 400
993 except Exception as e:
994 return handle_api_error("saving research as note", e)
997# ============================================================================
998# API Routes - Inline annotations (Word-review-style comments) and
999# document-linked notes. Targets: research runs and library documents —
1000# both have IMMUTABLE rendered text, so quote anchors can never drift.
1001# The comment itself is a full NOTE; the anchor lives in note_references.
1002# ============================================================================
1004MAX_ANNOTATION_COMMENT_LEN = 5000
1005MAX_ANNOTATION_QUOTE_LEN = 1000
1006MAX_ANNOTATION_CONTEXT_LEN = 100
1009def _validated_annotation_fields(data):
1010 """Validate/trim an annotation payload; raises ValueError.
1012 Type-checks every field before touching it: a non-string comment
1013 (e.g. ``{"comment": 123}`` from a direct API caller) would otherwise
1014 crash on ``.strip()`` as an opaque 500 instead of the clean 400 the
1015 length checks produce.
1016 """
1017 for field in ("comment", "quote", "prefix", "suffix"):
1018 value = data.get(field)
1019 if value is not None and not isinstance(value, str):
1020 raise ValueError(f"{field} must be a string")
1021 comment = (data.get("comment") or "").strip()
1022 quote = (data.get("quote") or "").strip()
1023 if not comment:
1024 raise ValueError("comment is required")
1025 if not quote: 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true
1026 raise ValueError("quote is required")
1027 if len(comment) > MAX_ANNOTATION_COMMENT_LEN: 1027 ↛ 1028line 1027 didn't jump to line 1028 because the condition on line 1027 was never true
1028 raise ValueError(
1029 f"comment exceeds maximum length "
1030 f"({MAX_ANNOTATION_COMMENT_LEN} chars)"
1031 )
1032 if len(quote) > MAX_ANNOTATION_QUOTE_LEN: 1032 ↛ 1033line 1032 didn't jump to line 1033 because the condition on line 1032 was never true
1033 raise ValueError(
1034 f"quote exceeds maximum length ({MAX_ANNOTATION_QUOTE_LEN} chars)"
1035 )
1036 prefix = (data.get("prefix") or "")[-MAX_ANNOTATION_CONTEXT_LEN:]
1037 suffix = (data.get("suffix") or "")[:MAX_ANNOTATION_CONTEXT_LEN]
1038 return comment, quote, prefix, suffix
1041def _comment_note_title(comment):
1042 """Derive the comment-note's title from its first words."""
1043 one_line = " ".join(comment.split())
1044 if len(one_line) > 60: 1044 ↛ 1045line 1044 didn't jump to line 1045 because the condition on line 1044 was never true
1045 one_line = one_line[:60].rstrip() + "…"
1046 return f"Comment: {one_line}"
1049# Length of the comment preview echoed back in an annotation response (was
1050# a bare 300 duplicated across both create-annotation handlers).
1051_ANNOTATION_COMMENT_PREVIEW_CHARS = 300
1054def _annotation_response(note_id, comment, quote, prefix, suffix):
1055 """The annotation-created response payload shared by the research and
1056 document create-annotation handlers (previously byte-identical dicts)."""
1057 return {
1058 "note_id": note_id,
1059 "quote": quote,
1060 "prefix": prefix,
1061 "suffix": suffix,
1062 "note_title": _comment_note_title(comment),
1063 "comment_preview": comment[:_ANNOTATION_COMMENT_PREVIEW_CHARS],
1064 }
1067def _comment_note_content(comment, quote, target_label, target_url):
1068 """Comment-first note body: remark, quoted passage, provenance.
1070 ``target_label`` (a research query or a library document title, both
1071 potentially untrusted) is escaped so it can't break out of the
1072 provenance link — see ``escape_markdown_link_label``.
1073 """
1074 quote_block = "\n".join(f"> {line}" for line in quote.split("\n"))
1075 label = escape_markdown_link_label(target_label)
1076 return (
1077 f"{comment}\n\n{quote_block}\n\n— comment on [{label}]({target_url})\n"
1078 )
1081def _research_exists(username, research_id):
1082 """(exists, query_text) for a research id in the user's DB."""
1083 from ...database.models import ResearchHistory
1084 from ...database.session_context import get_user_db_session
1086 with get_user_db_session(username) as db:
1087 row = (
1088 db.query(ResearchHistory.id, ResearchHistory.query)
1089 .filter_by(id=research_id)
1090 .first()
1091 )
1092 if row is None:
1093 return False, None
1094 return True, (row.query or "").strip()
1097@notes_bp.route(
1098 "/api/research/<string:research_id>/annotations", methods=["GET"]
1099)
1100@login_required
1101@_notes_search_limit
1102def get_research_annotations(research_id):
1103 """List a research run's inline annotations (anchored comment notes)."""
1104 username = session.get("username")
1105 if not username: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true
1106 return jsonify({"success": False, "error": "Not authenticated"}), 401
1108 try:
1109 exists, _query = _research_exists(username, research_id)
1110 if not exists:
1111 return jsonify(
1112 {"success": False, "error": "Research not found"}
1113 ), 404
1114 service = NoteService(username)
1115 annotations = service.get_annotations_for_target(
1116 research_id=research_id
1117 )
1118 return jsonify({"success": True, "annotations": annotations})
1120 except Exception as e:
1121 return handle_api_error("listing research annotations", e)
1124@notes_bp.route(
1125 "/api/research/<string:research_id>/annotations", methods=["POST"]
1126)
1127@login_required
1128@_notes_write_limit
1129def create_research_annotation(research_id):
1130 """Attach a comment to a passage of the research report.
1132 Body: ``quote`` (the selected rendered text), optional ``prefix`` /
1133 ``suffix`` (Hypothesis-style disambiguation context), ``comment``.
1134 The comment becomes a NOTE linked to the run; the anchor is a
1135 note_references row.
1136 """
1137 username = session.get("username")
1138 if not username: 1138 ↛ 1139line 1138 didn't jump to line 1139 because the condition on line 1138 was never true
1139 return jsonify({"success": False, "error": "Not authenticated"}), 401
1141 try:
1142 data = request.get_json(silent=True) or {}
1143 comment, quote, prefix, suffix = _validated_annotation_fields(data)
1145 exists, query_text = _research_exists(username, research_id)
1146 if not exists: 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true
1147 return jsonify(
1148 {"success": False, "error": "Research not found"}
1149 ), 404
1151 service = NoteService(username)
1152 note_id = service.create_note_for_research(
1153 research_id,
1154 title=_comment_note_title(comment),
1155 content=_comment_note_content(
1156 comment,
1157 quote,
1158 query_text or research_id,
1159 f"/results/{research_id}",
1160 ),
1161 quote=quote,
1162 prefix=prefix,
1163 suffix=suffix,
1164 )
1165 _trigger_note_auto_index(note_id, service, username)
1166 return jsonify(
1167 {
1168 "success": True,
1169 "annotation": _annotation_response(
1170 note_id, comment, quote, prefix, suffix
1171 ),
1172 }
1173 ), 201
1175 except LookupError:
1176 return jsonify({"success": False, "error": "Research not found"}), 404
1177 except ValueError as e:
1178 # Validation failures (missing/oversized fields) — surface the
1179 # actual message so the client can fix and retry.
1180 return jsonify({"success": False, "error": str(e)}), 400
1181 except Exception as e:
1182 return handle_api_error("creating research annotation", e)
1185@notes_bp.route(
1186 "/api/research/<string:research_id>/annotations/<string:note_id>",
1187 methods=["DELETE"],
1188)
1189@login_required
1190@_notes_write_limit
1191def delete_research_annotation(research_id, note_id):
1192 """Remove an annotation: the comment note is deleted (standard delete
1193 path — version cascade, vector purge) and its anchor row cascades."""
1194 username = session.get("username")
1195 if not username: 1195 ↛ 1196line 1195 didn't jump to line 1196 because the condition on line 1195 was never true
1196 return jsonify({"success": False, "error": "Not authenticated"}), 401
1198 try:
1199 service = NoteService(username)
1200 if not service.has_annotation(note_id, research_id=research_id):
1201 return jsonify(
1202 {"success": False, "error": "Annotation not found"}
1203 ), 404
1204 service.delete_note(note_id)
1205 return jsonify({"success": True})
1207 except Exception as e:
1208 return handle_api_error("deleting research annotation", e)
1211# ---- Library documents: same surface, document targets --------------------
1214@notes_bp.route("/api/documents/<string:document_id>/notes", methods=["GET"])
1215@login_required
1216@_notes_search_limit
1217def get_document_notes(document_id):
1218 """List the notes referencing a library document (its Notes panel)."""
1219 username = session.get("username")
1220 if not username: 1220 ↛ 1221line 1220 didn't jump to line 1221 because the condition on line 1220 was never true
1221 return jsonify({"success": False, "error": "Not authenticated"}), 401
1223 try:
1224 service = NoteService(username)
1225 notes = service.get_notes_for_document(document_id)
1226 if notes is None: 1226 ↛ 1230line 1226 didn't jump to line 1230 because the condition on line 1226 was always true
1227 return jsonify(
1228 {"success": False, "error": "Document not found"}
1229 ), 404
1230 return jsonify({"success": True, "notes": notes})
1232 except Exception as e:
1233 return handle_api_error("listing notes for document", e)
1236@notes_bp.route("/api/documents/<string:document_id>/notes", methods=["POST"])
1237@login_required
1238@_notes_write_limit
1239def create_document_note(document_id):
1240 """Create a note referencing a library document (all-or-nothing).
1242 Body (all optional): ``title``, ``content``, ``tags``. Defaults
1243 produce a starter note titled after the document.
1244 """
1245 username = session.get("username")
1246 if not username: 1246 ↛ 1247line 1246 didn't jump to line 1247 because the condition on line 1246 was never true
1247 return jsonify({"success": False, "error": "Not authenticated"}), 401
1249 try:
1250 data = request.get_json(silent=True) or {}
1251 service = NoteService(username)
1252 try:
1253 note_id = service.create_note_for_document(
1254 document_id,
1255 title=data.get("title"),
1256 content=data.get("content"),
1257 tags=data.get("tags"),
1258 )
1259 except LookupError:
1260 return jsonify(
1261 {"success": False, "error": "Document not found"}
1262 ), 404
1263 _trigger_note_auto_index(note_id, service, username)
1264 return jsonify({"success": True, "note_id": note_id}), 201
1266 except ValueError as e:
1267 # Validation failures (caps, or trying to annotate a note —
1268 # mutable content, anchors would drift) — surface the message.
1269 return jsonify({"success": False, "error": str(e)}), 400
1270 except Exception as e:
1271 return handle_api_error("creating note for document", e)
1274@notes_bp.route(
1275 "/api/documents/<string:document_id>/annotations", methods=["GET"]
1276)
1277@login_required
1278@_notes_search_limit
1279def get_document_annotations(document_id):
1280 """List a library document's inline annotations."""
1281 username = session.get("username")
1282 if not username:
1283 return jsonify({"success": False, "error": "Not authenticated"}), 401
1285 try:
1286 service = NoteService(username)
1287 # Existence gate doubles as the 404 (get_notes_for_document
1288 # checks the Document row).
1289 if service.get_notes_for_document(document_id) is None:
1290 return jsonify(
1291 {"success": False, "error": "Document not found"}
1292 ), 404
1293 annotations = service.get_annotations_for_target(
1294 document_id=document_id
1295 )
1296 return jsonify({"success": True, "annotations": annotations})
1298 except Exception as e:
1299 return handle_api_error("listing document annotations", e)
1302@notes_bp.route(
1303 "/api/documents/<string:document_id>/annotations", methods=["POST"]
1304)
1305@login_required
1306@_notes_write_limit
1307def create_document_annotation(document_id):
1308 """Attach a comment to a passage of a library document's text."""
1309 username = session.get("username")
1310 if not username: 1310 ↛ 1311line 1310 didn't jump to line 1311 because the condition on line 1310 was never true
1311 return jsonify({"success": False, "error": "Not authenticated"}), 401
1313 try:
1314 data = request.get_json(silent=True) or {}
1315 comment, quote, prefix, suffix = _validated_annotation_fields(data)
1317 from ...database.models import Document
1318 from ...database.session_context import get_user_db_session
1320 with get_user_db_session(username) as db:
1321 row = (
1322 db.query(Document.id, Document.title)
1323 .filter_by(id=document_id)
1324 .first()
1325 )
1326 if row is None: 1326 ↛ 1327line 1326 didn't jump to line 1327 because the condition on line 1326 was never true
1327 return jsonify(
1328 {"success": False, "error": "Document not found"}
1329 ), 404
1330 doc_title = (row.title or "").strip() or document_id
1332 service = NoteService(username)
1333 note_id = service.create_note_for_document(
1334 document_id,
1335 title=_comment_note_title(comment),
1336 content=_comment_note_content(
1337 comment,
1338 quote,
1339 doc_title,
1340 f"/library/document/{document_id}",
1341 ),
1342 quote=quote,
1343 prefix=prefix,
1344 suffix=suffix,
1345 )
1346 _trigger_note_auto_index(note_id, service, username)
1347 return jsonify(
1348 {
1349 "success": True,
1350 "annotation": _annotation_response(
1351 note_id, comment, quote, prefix, suffix
1352 ),
1353 }
1354 ), 201
1356 except LookupError:
1357 return jsonify({"success": False, "error": "Document not found"}), 404
1358 except ValueError as e:
1359 return jsonify({"success": False, "error": str(e)}), 400
1360 except Exception as e:
1361 return handle_api_error("creating document annotation", e)
1364@notes_bp.route(
1365 "/api/documents/<string:document_id>/annotations/<string:note_id>",
1366 methods=["DELETE"],
1367)
1368@login_required
1369@_notes_write_limit
1370def delete_document_annotation(document_id, note_id):
1371 """Remove a document annotation (deletes the comment note; the
1372 anchor row cascades)."""
1373 username = session.get("username")
1374 if not username: 1374 ↛ 1375line 1374 didn't jump to line 1375 because the condition on line 1374 was never true
1375 return jsonify({"success": False, "error": "Not authenticated"}), 401
1377 try:
1378 service = NoteService(username)
1379 if not service.has_annotation(note_id, document_id=document_id): 1379 ↛ 1380line 1379 didn't jump to line 1380 because the condition on line 1379 was never true
1380 return jsonify(
1381 {"success": False, "error": "Annotation not found"}
1382 ), 404
1383 service.delete_note(note_id)
1384 return jsonify({"success": True})
1386 except Exception as e:
1387 return handle_api_error("deleting document annotation", e)
1390# ============================================================================
1391# API Routes - RAG Indexing
1392# ============================================================================
1395@notes_bp.route("/api/notes/<string:note_id>/index", methods=["POST"])
1396@login_required
1397@_notes_write_limit
1398def index_note_to_collection(note_id):
1399 """
1400 Index a note into a collection for RAG search.
1402 Body:
1403 collection_id: The collection to index the note into (required)
1404 force_reindex: Whether to force reindexing (optional, default false)
1405 """
1406 from ...research_library.routes.rag_routes import get_rag_service
1408 username = session.get("username")
1409 if not username: 1409 ↛ 1410line 1409 didn't jump to line 1410 because the condition on line 1409 was never true
1410 return jsonify({"success": False, "error": "Not authenticated"}), 401
1412 try:
1413 data = request.get_json(silent=True)
1414 if not data or not data.get("collection_id"):
1415 return jsonify(
1416 {"success": False, "error": "collection_id is required"}
1417 ), 400
1419 collection_id = data["collection_id"]
1420 force_reindex = data.get("force_reindex", False)
1422 # Verify the id is actually a note before indexing — every sibling
1423 # note route guards this, but this one passed the id straight to the
1424 # RAG service, which would index an arbitrary user document.
1425 if not NoteService(username).note_exists(note_id):
1426 return jsonify({"success": False, "error": "Note not found"}), 404
1428 # Get RAG service configured for the collection
1429 rag_service = get_rag_service(collection_id)
1431 # Index the note (notes are now documents)
1432 result = rag_service.index_document(
1433 document_id=note_id,
1434 collection_id=collection_id,
1435 force_reindex=force_reindex,
1436 )
1438 if result["status"] == "error":
1439 return jsonify(
1440 {"success": False, "error": result.get("error")}
1441 ), 400
1443 return jsonify({"success": True, "result": result})
1445 except Exception as e:
1446 return handle_api_error("indexing note", e)
1449# ============================================================================
1450# API Routes - AI Features
1451# ============================================================================
1454@notes_bp.route("/api/notes/semantic-search", methods=["GET"])
1455@login_required
1456@_notes_search_limit
1457def semantic_search_notes():
1458 """
1459 Search notes using semantic similarity.
1461 Finds notes by meaning rather than keyword matching.
1463 Query params:
1464 q: Search query (required)
1465 limit: Maximum results (default 10)
1466 min_similarity: Minimum similarity threshold 0.0-1.0 (default 0.3)
1467 collection_id: Optional — restrict results to this collection's notes
1468 (matches the text-search filter).
1469 """
1470 username = session.get("username")
1471 if not username: 1471 ↛ 1472line 1471 didn't jump to line 1472 because the condition on line 1471 was never true
1472 return jsonify({"success": False, "error": "Not authenticated"}), 401
1474 try:
1475 try:
1476 query = _clamp_text_query(request.args.get("q", ""), "q")
1477 except ValueError as exc:
1478 return jsonify({"success": False, "error": str(exc)}), 400
1479 query = (query or "").strip()
1480 if not query: 1480 ↛ 1481line 1480 didn't jump to line 1481 because the condition on line 1480 was never true
1481 return jsonify(
1482 {"success": False, "error": "Query is required"}
1483 ), 400
1485 try:
1486 limit = _clamp_limit(10)
1487 min_similarity = max(
1488 0.0, min(float(request.args.get("min_similarity", 0.3)), 1.0)
1489 )
1490 except (ValueError, TypeError):
1491 return jsonify(
1492 {"success": False, "error": "Invalid limit or min_similarity"}
1493 ), 400
1495 collection_id = request.args.get("collection_id") or None
1497 service = NoteAIService(username)
1498 results = service.semantic_search(
1499 query,
1500 limit=limit,
1501 min_similarity=min_similarity,
1502 collection_id=collection_id,
1503 )
1505 return jsonify(
1506 {
1507 "success": True,
1508 "query": query,
1509 "results": results,
1510 "count": len(results),
1511 }
1512 )
1514 except Exception as e:
1515 return handle_api_error("semantic search", e)
1518@notes_bp.route("/api/notes/<string:note_id>/similar", methods=["GET"])
1519@login_required
1520@_notes_search_limit
1521def get_similar_notes(note_id):
1522 """
1523 Find notes similar to the given note using embeddings.
1525 Query params:
1526 limit: Maximum number of results (default 5)
1527 """
1528 username = session.get("username")
1529 if not username: 1529 ↛ 1530line 1529 didn't jump to line 1530 because the condition on line 1529 was never true
1530 return jsonify({"success": False, "error": "Not authenticated"}), 401
1532 try:
1533 try:
1534 limit = _clamp_limit(5)
1535 except (ValueError, TypeError):
1536 return jsonify({"success": False, "error": "Invalid limit"}), 400
1537 # 404 on unknown note id — the AI service returns [] for both a
1538 # missing note and a note with no similar matches, which made a
1539 # stale id indistinguishable from a real-empty result. Same guard
1540 # as get_note_collections / get_note_research.
1541 if not NoteService(username).note_exists(note_id):
1542 return jsonify({"success": False, "error": "Note not found"}), 404
1543 service = NoteAIService(username)
1544 similar = service.find_similar_notes(note_id, limit=limit)
1546 return jsonify({"success": True, "similar_notes": similar})
1548 except Exception as e:
1549 return handle_api_error("finding similar notes", e)
1552@notes_bp.route("/api/notes/<string:note_id>/summarize", methods=["POST"])
1553@login_required
1554@_notes_ai_limit
1555def summarize_note(note_id):
1556 """Generate an AI summary of the note."""
1557 username = session.get("username")
1558 if not username:
1559 return jsonify({"success": False, "error": "Not authenticated"}), 401
1561 try:
1562 service = NoteAIService(username)
1563 summary = service.summarize_note(note_id)
1565 # summarize_note returns None only for a missing/empty note (real LLM
1566 # failures raise → handled below as 500). That's a client/data
1567 # condition, so 404 — consistent with the sibling AI endpoints.
1568 if summary is None:
1569 return jsonify(
1570 {"success": False, "error": "Note not found or empty"}
1571 ), 404
1573 return jsonify({"success": True, "summary": summary})
1575 except Exception as e:
1576 return handle_api_error("summarizing note", e)
1579@notes_bp.route(
1580 "/api/notes/<string:note_id>/research-questions", methods=["POST"]
1581)
1582@login_required
1583@_notes_ai_limit
1584def extract_research_questions(note_id):
1585 """Extract potential research questions from the note."""
1586 username = session.get("username")
1587 if not username: 1587 ↛ 1588line 1587 didn't jump to line 1588 because the condition on line 1587 was never true
1588 return jsonify({"success": False, "error": "Not authenticated"}), 401
1590 try:
1591 # 404 on unknown note id — see get_similar_notes for the rationale.
1592 if not NoteService(username).note_exists(note_id): 1592 ↛ 1594line 1592 didn't jump to line 1594 because the condition on line 1592 was always true
1593 return jsonify({"success": False, "error": "Note not found"}), 404
1594 service = NoteAIService(username)
1595 questions = service.extract_research_questions(note_id)
1597 return jsonify({"success": True, "questions": questions})
1599 except Exception as e:
1600 return handle_api_error("extracting research questions", e)
1603@notes_bp.route("/api/notes/suggest-tags", methods=["POST"])
1604@login_required
1605@_notes_ai_limit
1606def suggest_tags():
1607 """
1608 Suggest tags for note content.
1610 Body:
1611 content: The note content to analyze
1612 existing_tags: Current tags to avoid duplicates (optional)
1613 """
1614 username = session.get("username")
1615 if not username: 1615 ↛ 1616line 1615 didn't jump to line 1616 because the condition on line 1615 was never true
1616 return jsonify({"success": False, "error": "Not authenticated"}), 401
1618 try:
1619 data = request.get_json(silent=True)
1620 if not data or not data.get("content"): 1620 ↛ 1621line 1620 didn't jump to line 1621 because the condition on line 1620 was never true
1621 return jsonify(
1622 {"success": False, "error": "content is required"}
1623 ), 400
1625 # Cap body size — same MAX_CONTENT_SIZE used by create_note /
1626 # update_note. Without this, the route accepts unbounded text and
1627 # holds it all in memory while the LLM call runs.
1628 _assert_content_size(data["content"])
1630 # existing_tags is interpolated/joined downstream; a non-list (or a
1631 # list with non-string items) would raise deep in the service and
1632 # surface as an opaque 500. Validate to a clean 400 here.
1633 existing_tags = data.get("existing_tags")
1634 if existing_tags is not None and (
1635 not isinstance(existing_tags, list)
1636 or not all(isinstance(t, str) for t in existing_tags)
1637 ):
1638 return jsonify(
1639 {
1640 "success": False,
1641 "error": "existing_tags must be a list of strings",
1642 }
1643 ), 400
1644 # Cap count + per-item length like _validate_tags does on the
1645 # create/update paths. suggest_tags joins the WHOLE list before
1646 # truncating the joined result, so without a cap a client could send a
1647 # multi-MB existing_tags array (within the body ceiling, at 10/min) and
1648 # force a large in-memory join on every call.
1649 if existing_tags is not None and (
1650 len(existing_tags) > MAX_TAGS_PER_NOTE
1651 or any(len(t) > MAX_TAG_LENGTH for t in existing_tags)
1652 ):
1653 return jsonify(
1654 {
1655 "success": False,
1656 "error": (
1657 f"existing_tags exceeds limits (max "
1658 f"{MAX_TAGS_PER_NOTE} tags, {MAX_TAG_LENGTH} chars each)"
1659 ),
1660 }
1661 ), 400
1663 service = NoteAIService(username)
1664 tags = service.suggest_tags(
1665 content=data["content"],
1666 existing_tags=existing_tags,
1667 )
1669 return jsonify({"success": True, "tags": tags})
1671 except ValueError as e:
1672 return jsonify({"success": False, "error": str(e)}), 400
1673 except Exception as e:
1674 return handle_api_error("suggesting tags", e)
1677@notes_bp.route("/api/notes/<string:note_id>/key-concepts", methods=["POST"])
1678@login_required
1679@_notes_ai_limit
1680def extract_key_concepts(note_id):
1681 """Extract key concepts, entities, and themes from the note."""
1682 username = session.get("username")
1683 if not username: 1683 ↛ 1684line 1683 didn't jump to line 1684 because the condition on line 1683 was never true
1684 return jsonify({"success": False, "error": "Not authenticated"}), 401
1686 try:
1687 # 404 on unknown note id — see get_similar_notes for the rationale.
1688 if not NoteService(username).note_exists(note_id): 1688 ↛ 1690line 1688 didn't jump to line 1690 because the condition on line 1688 was always true
1689 return jsonify({"success": False, "error": "Note not found"}), 404
1690 service = NoteAIService(username)
1691 concepts = service.extract_key_concepts(note_id)
1693 return jsonify({"success": True, "concepts": concepts})
1695 except Exception as e:
1696 return handle_api_error("extracting key concepts", e)
1699# ============================================================================
1700# API Routes - Smart Note Linking
1701# ============================================================================
1704@notes_bp.route("/api/notes/<string:note_id>/backlinks", methods=["GET"])
1705@login_required
1706@_notes_search_limit
1707def get_backlinks(note_id):
1708 """Get notes that link to this note (backlinks)."""
1709 username = session.get("username")
1710 if not username: 1710 ↛ 1711line 1710 didn't jump to line 1711 because the condition on line 1710 was never true
1711 return jsonify({"success": False, "error": "Not authenticated"}), 401
1713 try:
1714 service = NoteService(username)
1715 # 404 an unknown id like the other note routes, so an empty list
1716 # for a real note is distinguishable from "no such note".
1717 if not service.note_exists(note_id):
1718 return jsonify({"success": False, "error": "Note not found"}), 404
1719 backlinks = service.get_backlinks(note_id)
1721 return jsonify(
1722 {
1723 "success": True,
1724 "backlinks": backlinks,
1725 "total": len(backlinks),
1726 }
1727 )
1729 except Exception as e:
1730 return handle_api_error("getting backlinks", e)
1733@notes_bp.route("/api/notes/<string:note_id>/outgoing-links", methods=["GET"])
1734@login_required
1735@_notes_search_limit
1736def get_outgoing_links(note_id):
1737 """Get notes that this note links to."""
1738 username = session.get("username")
1739 if not username: 1739 ↛ 1740line 1739 didn't jump to line 1740 because the condition on line 1739 was never true
1740 return jsonify({"success": False, "error": "Not authenticated"}), 401
1742 try:
1743 service = NoteService(username)
1744 # 404 an unknown id like the other note routes.
1745 if not service.note_exists(note_id): 1745 ↛ 1747line 1745 didn't jump to line 1747 because the condition on line 1745 was always true
1746 return jsonify({"success": False, "error": "Note not found"}), 404
1747 links = service.get_outgoing_links(note_id)
1749 return jsonify(
1750 {
1751 "success": True,
1752 "links": links,
1753 "total": len(links),
1754 }
1755 )
1757 except Exception as e:
1758 return handle_api_error("getting outgoing links", e)
1761@notes_bp.route("/api/notes/<string:note_id>/suggested-links", methods=["GET"])
1762@login_required
1763@_notes_search_limit
1764def get_suggested_links(note_id):
1765 """Get AI-suggested notes to link to based on semantic similarity."""
1766 username = session.get("username")
1767 if not username: 1767 ↛ 1768line 1767 didn't jump to line 1768 because the condition on line 1767 was never true
1768 return jsonify({"success": False, "error": "Not authenticated"}), 401
1770 try:
1771 try:
1772 limit = _clamp_limit(5)
1773 except (ValueError, TypeError):
1774 return jsonify({"success": False, "error": "Invalid limit"}), 400
1775 # 404 a missing/unknown note like every sibling AI-link route
1776 # (suggest_links returns [] for a missing note, which is otherwise
1777 # indistinguishable from "a real note with no suggestions").
1778 if not NoteService(username).note_exists(note_id): 1778 ↛ 1779line 1778 didn't jump to line 1779 because the condition on line 1778 was never true
1779 return jsonify({"success": False, "error": "Note not found"}), 404
1780 service = NoteAIService(username)
1781 suggestions = service.suggest_links(note_id, limit=limit)
1783 return jsonify({"success": True, "suggestions": suggestions})
1785 except Exception as e:
1786 return handle_api_error("getting suggested links", e)
1789@notes_bp.route("/api/notes/<string:note_id>/accept-link", methods=["POST"])
1790@login_required
1791@_notes_write_limit
1792def accept_suggested_link(note_id):
1793 """Accept an AI-suggested link: insert [[Target Title]] into the note
1794 content and flag the resulting NoteLink as auto_suggested=True.
1796 Body:
1797 target_note_id: The ID of the note to link to.
1798 """
1799 username = session.get("username")
1800 if not username: 1800 ↛ 1801line 1800 didn't jump to line 1801 because the condition on line 1800 was never true
1801 return jsonify({"success": False, "error": "Not authenticated"}), 401
1803 try:
1804 data = request.get_json(silent=True) or {}
1805 target_note_id = data.get("target_note_id")
1806 if not target_note_id: 1806 ↛ 1807line 1806 didn't jump to line 1807 because the condition on line 1806 was never true
1807 return jsonify(
1808 {"success": False, "error": "target_note_id required"}
1809 ), 400
1811 service = NoteService(username)
1812 result = service.accept_suggested_link(note_id, target_note_id)
1813 if not result: 1813 ↛ 1818line 1813 didn't jump to line 1818 because the condition on line 1813 was always true
1814 return jsonify(
1815 {"success": False, "error": "Link could not be accepted"}
1816 ), 404
1818 return jsonify({"success": True, "note": result})
1820 except ValueError as e:
1821 # A real write failure surfaced by the service (e.g. the appended link
1822 # pushed the note past the content-size cap) is a client-fixable 400,
1823 # not the generic 500 handle_api_error would otherwise return.
1824 return jsonify({"success": False, "error": str(e)}), 400
1825 except Exception as e:
1826 return handle_api_error("accepting suggested link", e)
1829@notes_bp.route(
1830 "/api/notes/<string:note_id>/unlinked-mentions", methods=["GET"]
1831)
1832@login_required
1833@_notes_search_limit
1834def get_unlinked_mentions(note_id):
1835 """Notes whose text mentions this note's title but don't yet link to it.
1837 Lexical (no embeddings). The client links one via the existing
1838 /accept-link route on the *mentioning* note (target = this note).
1839 """
1840 username = session.get("username")
1841 if not username: 1841 ↛ 1842line 1841 didn't jump to line 1842 because the condition on line 1841 was never true
1842 return jsonify({"success": False, "error": "Not authenticated"}), 401
1844 try:
1845 service = NoteService(username)
1846 # 404 an unknown id like the other note routes.
1847 if not service.note_exists(note_id): 1847 ↛ 1849line 1847 didn't jump to line 1849 because the condition on line 1847 was always true
1848 return jsonify({"success": False, "error": "Note not found"}), 404
1849 mentions = service.get_unlinked_mentions(note_id)
1850 return jsonify({"success": True, "mentions": mentions})
1851 except Exception as e:
1852 return handle_api_error("getting unlinked mentions", e)
1855# Passage text is a paragraph-ish selection; cap it so a huge selection
1856# can't be sent as the embedding query.
1857MAX_PASSAGE_LEN = 2000
1860@notes_bp.route(
1861 "/api/notes/<string:note_id>/similar-passages", methods=["POST"]
1862)
1863@login_required
1864@_notes_search_limit
1865def similar_passages(note_id):
1866 """Find note passages (chunks) semantically similar to a given snippet.
1868 Body: ``{"text": "<selected paragraph>"}``. Chunks from this note are
1869 excluded so the result is "related passages elsewhere".
1870 """
1871 username = session.get("username")
1872 if not username: 1872 ↛ 1873line 1872 didn't jump to line 1873 because the condition on line 1872 was never true
1873 return jsonify({"success": False, "error": "Not authenticated"}), 401
1875 try:
1876 data = request.get_json(silent=True) or {}
1877 text = data.get("text")
1878 if not text or not isinstance(text, str) or not text.strip(): 1878 ↛ 1879line 1878 didn't jump to line 1879 because the condition on line 1878 was never true
1879 return jsonify({"success": False, "error": "text required"}), 400
1880 text = text.strip()[:MAX_PASSAGE_LEN]
1882 service = NoteAIService(username)
1883 passages = service.find_similar_passages(text, exclude_note_id=note_id)
1884 return jsonify({"success": True, "passages": passages})
1885 except Exception as e:
1886 return handle_api_error("finding similar passages", e)
1889# ============================================================================
1890# API Routes - Ask your notes (research pinned to the Notes collection)
1891# ============================================================================
1894@notes_bp.route("/api/notes/ask-context", methods=["GET"])
1895@login_required
1896@_notes_search_limit
1897def ask_context():
1898 """Pre-flight context for the 'Ask your notes' box: the Notes collection
1899 id + note/indexed counts, so the UI can warn when there's nothing to
1900 search before starting a research run pinned to that collection.
1901 """
1902 username = session.get("username")
1903 if not username:
1904 return jsonify({"success": False, "error": "Not authenticated"}), 401
1906 try:
1907 service = NoteService(username)
1908 status = service.get_notes_index_status()
1909 return jsonify({"success": True, **status})
1910 except Exception as e:
1911 return handle_api_error("getting ask-notes context", e)
1914# ============================================================================
1915# API Routes - Verify Note with Research (fact-check)
1916# ============================================================================
1919@notes_bp.route("/api/notes/<string:note_id>/fact-check", methods=["POST"])
1920@login_required
1921@_notes_factcheck_limit
1922def fact_check_note(note_id):
1923 """Extract checkable factual claims from a note and synthesize a research
1924 query for verifying them.
1926 The client then starts a research run (reusing /api/start_research + the
1927 research-link flow, exactly like "Research This Note") and, once it
1928 completes, POSTs the claims to the /grade endpoint below.
1929 """
1930 username = session.get("username")
1931 if not username: 1931 ↛ 1932line 1931 didn't jump to line 1932 because the condition on line 1931 was never true
1932 return jsonify({"success": False, "error": "Not authenticated"}), 401
1934 try:
1935 # Guard the note id like every other note route: without this an
1936 # unknown/typo'd id fell through to the claim-less 422 below, conflating
1937 # "this note has nothing to verify" with "there is no such note".
1938 if not NoteService(username).note_exists(note_id):
1939 return jsonify({"success": False, "error": "Note not found"}), 404
1941 ai = NoteAIService(username)
1942 claims = ai.extract_claims(note_id)
1943 if not claims:
1944 # The note exists but has no checkable factual claims.
1945 return jsonify(
1946 {
1947 "success": False,
1948 "error": "No checkable factual claims found in this note.",
1949 }
1950 ), 422
1951 query = ai.synthesize_factcheck_query(claims)
1952 return jsonify({"success": True, "claims": claims, "query": query})
1954 except Exception as e:
1955 return handle_api_error("starting note fact-check", e)
1958def _grade_note_claims(username, note_id, research_id, claims):
1959 """Grade ``claims`` against a COMPLETED research run's report and persist
1960 the verdicts under ``research_meta['fact_check']``.
1962 Returns a ``(payload, status_code)`` tuple. Extracted from the route so it
1963 can be unit-tested without a Flask client. Runs entirely in the request
1964 thread, where the per-user DB password is available, so the LLM grade and
1965 report fetch work without the encrypted-DB background-worker problem.
1966 """
1967 from ...constants import ResearchStatus
1968 from ...database.models import NoteResearch, ResearchHistory
1969 from ...database.session_context import get_user_db_session
1970 from ...storage import get_report_storage
1971 from ..services.report_assembly_service import (
1972 get_research_source_links_batch,
1973 )
1975 # Load the research (per-user DB session scopes it to this user) and
1976 # require it to have completed before grading.
1977 with get_user_db_session(username) as db:
1978 research = db.query(ResearchHistory).filter_by(id=research_id).first()
1979 if not research:
1980 return {"success": False, "error": "Research not found"}, 404
1981 # Require the research to actually be linked to THIS note — otherwise a
1982 # client could grade an arbitrary completed research against an
1983 # arbitrary note and stamp a foreign note_id into its meta.
1984 linked = (
1985 db.query(NoteResearch)
1986 .filter_by(document_id=note_id, research_id=research_id)
1987 .first()
1988 )
1989 if not linked:
1990 return {
1991 "success": False,
1992 "error": "Research is not linked to this note",
1993 }, 404
1994 status = research.status
1995 meta = dict(research.research_meta or {})
1997 if status != ResearchStatus.COMPLETED:
1998 return {
1999 "success": False,
2000 "error": "Research is not complete yet.",
2001 "status": status,
2002 }, 409
2004 # Fetch the report markdown + sources (in-request).
2005 settings_snapshot = meta.get("settings_snapshot")
2006 with get_user_db_session(username) as db:
2007 storage = get_report_storage(
2008 session=db, settings_snapshot=settings_snapshot
2009 )
2010 report = storage.get_report(research_id, username)
2011 # Sources live in the research_resources table, not research_meta.
2012 # The post-refactor save path never writes the legacy
2013 # `all_links_of_system` metadata key (see the identical fix in
2014 # research_routes.get_report), so reading it here returned [] for
2015 # every research: the grader prompt always showed "(no sources)"
2016 # and every verdict's sources array was empty, leaving the
2017 # citation-validation/anti-rubber-stamp checks unreachable. Read
2018 # the structured table instead — the same source of truth the
2019 # results page uses. Fetch only the MAX_GRADE_SOURCES window the
2020 # grader will actually show; anything past it was discarded.
2021 sources = get_research_source_links_batch(
2022 [research_id], db, limit=_MAX_GRADE_SOURCES
2023 ).get(research_id, [])
2025 # get_report returns None for BOTH a missing report and a swallowed read
2026 # error. Grading against an empty report would label every claim
2027 # "unverified" and persist that as a successful fact-check — a false result.
2028 # Refuse to grade instead.
2029 if not report or not str(report).strip():
2030 return {
2031 "success": False,
2032 "error": (
2033 "The research report is unavailable, so its claims cannot be "
2034 "graded. Try re-running the research."
2035 ),
2036 }, 502
2038 ai = NoteAIService(username)
2039 verdicts = ai.grade_all_claims(claims, report, sources)
2041 # Persist verdicts under research_meta['fact_check']. Reassign the whole
2042 # dict so SQLAlchemy marks the plain-JSON column dirty (this codebase
2043 # deliberately avoids MutableDict — see research_sources_service.py).
2044 with get_user_db_session(username) as db:
2045 research = db.query(ResearchHistory).filter_by(id=research_id).first()
2046 if research is None:
2047 # The research row existed when we loaded it for grading (above)
2048 # but has since vanished — e.g. a concurrent delete between the
2049 # grade and this persist. The verdicts were computed but cannot be
2050 # saved. Returning success here would tell the client a fact-check
2051 # was persisted that never was (a silent partial failure); report
2052 # the conflict explicitly instead.
2053 return {
2054 "success": False,
2055 "error": (
2056 "The research run disappeared before its fact-check "
2057 "verdicts could be saved. Please try again."
2058 ),
2059 }, 409
2060 try:
2061 new_meta = dict(research.research_meta or {})
2062 new_meta["fact_check"] = {
2063 "note_id": note_id,
2064 "claims": claims,
2065 "verdicts": verdicts,
2066 }
2067 research.research_meta = new_meta
2068 db.commit()
2069 except Exception:
2070 # Don't leave the shared request session poisoned.
2071 db.rollback()
2072 raise
2074 return {"success": True, "verdicts": verdicts}, 200
2077@notes_bp.route(
2078 "/api/notes/<string:note_id>/fact-check/<string:research_id>/grade",
2079 methods=["POST"],
2080)
2081@login_required
2082@_notes_factcheck_limit
2083def grade_note_fact_check(note_id, research_id):
2084 """Grade a note's claims against a COMPLETED research run's report.
2086 Body: ``{"claims": ["...", ...]}`` — the claims returned by /fact-check.
2087 """
2088 username = session.get("username")
2089 if not username: 2089 ↛ 2090line 2089 didn't jump to line 2090 because the condition on line 2089 was never true
2090 return jsonify({"success": False, "error": "Not authenticated"}), 401
2092 try:
2093 data = request.get_json(silent=True) or {}
2094 raw_claims = data.get("claims")
2095 if not isinstance(raw_claims, list):
2096 return jsonify({"success": False, "error": "claims required"}), 400
2097 # Cap both the count AND each claim's length — the rest of this file
2098 # length-bounds free text (MAX_SEARCH_LEN / _clamp_text_query) but the
2099 # grade endpoint took client claims with no per-string bound.
2100 claims = [
2101 c.strip()[:MAX_CLAIM_LEN]
2102 for c in raw_claims
2103 if isinstance(c, str) and c.strip()
2104 ][: NoteAIService.MAX_CLAIMS_PER_NOTE]
2105 if not claims:
2106 return jsonify({"success": False, "error": "claims required"}), 400
2108 payload, status_code = _grade_note_claims(
2109 username, note_id, research_id, claims
2110 )
2111 return jsonify(payload), status_code
2113 except Exception as e:
2114 return handle_api_error("grading note fact-check", e)
2117@notes_bp.route("/api/notes/resolve-link", methods=["POST"])
2118@login_required
2119@_notes_search_limit
2120def resolve_link():
2121 """
2122 Resolve a [[link]] text to a note.
2124 Body:
2125 link_text: The text inside [[brackets]]
2126 """
2127 username = session.get("username")
2128 if not username: 2128 ↛ 2129line 2128 didn't jump to line 2129 because the condition on line 2128 was never true
2129 return jsonify({"success": False, "error": "Not authenticated"}), 401
2131 try:
2132 data = request.get_json(silent=True)
2133 if not data or not data.get("link_text"): 2133 ↛ 2134line 2133 didn't jump to line 2134 because the condition on line 2133 was never true
2134 return jsonify(
2135 {"success": False, "error": "link_text is required"}
2136 ), 400
2137 try:
2138 link_text = _clamp_text_query(
2139 data["link_text"], "link_text", max_len=MAX_LINK_TEXT_LEN
2140 )
2141 except ValueError as exc:
2142 return jsonify({"success": False, "error": str(exc)}), 400
2144 service = NoteService(username)
2145 result = service.resolve_link(link_text)
2147 if result:
2148 return jsonify({"success": True, "note": result})
2149 return jsonify({"success": False, "error": "Note not found"}), 404
2151 except Exception as e:
2152 return handle_api_error("resolving link", e)
2155@notes_bp.route("/api/notes/search-for-linking", methods=["GET"])
2156@login_required
2157@_notes_search_limit
2158def search_notes_for_linking():
2159 """
2160 Search notes for the [[link]] autocomplete feature.
2162 Query params:
2163 query: Search query (partial title match)
2164 exclude_note_id: Note ID to exclude from results
2165 limit: Maximum number of results (default 10)
2166 """
2167 username = session.get("username")
2168 if not username: 2168 ↛ 2169line 2168 didn't jump to line 2169 because the condition on line 2168 was never true
2169 return jsonify({"success": False, "error": "Not authenticated"}), 401
2171 try:
2172 try:
2173 query = _clamp_text_query(request.args.get("query", ""), "query")
2174 except ValueError as exc:
2175 return jsonify({"success": False, "error": str(exc)}), 400
2176 if not query:
2177 return jsonify({"success": True, "notes": []})
2179 exclude_note_id = request.args.get("exclude_note_id")
2180 try:
2181 limit = _clamp_limit(10)
2182 except (ValueError, TypeError):
2183 return jsonify({"success": False, "error": "Invalid limit"}), 400
2185 service = NoteService(username)
2186 notes = service.search_notes_for_linking(query, exclude_note_id, limit)
2188 return jsonify({"success": True, "notes": notes})
2190 except Exception as e:
2191 return handle_api_error("searching notes for linking", e)
2194# ============================================================================
2195# API Routes - Note Synthesis
2196# ============================================================================
2199@notes_bp.route("/api/notes/synthesize/preview", methods=["POST"])
2200@login_required
2201@_notes_synthesize_limit
2202def preview_synthesis():
2203 """
2204 Preview what a synthesis would produce.
2206 Body:
2207 note_ids: List of note IDs (2-5)
2208 synthesis_type: 'merge', 'summarize', or 'compare'
2209 """
2210 username = session.get("username")
2211 if not username: 2211 ↛ 2212line 2211 didn't jump to line 2212 because the condition on line 2211 was never true
2212 return jsonify({"success": False, "error": "Not authenticated"}), 401
2214 try:
2215 data = request.get_json(silent=True)
2216 if not data or not data.get("note_ids"): 2216 ↛ 2217line 2216 didn't jump to line 2217 because the condition on line 2216 was never true
2217 return jsonify(
2218 {"success": False, "error": "note_ids is required"}
2219 ), 400
2221 note_ids = data["note_ids"]
2222 if not isinstance(note_ids, list) or not all( 2222 ↛ 2227line 2222 didn't jump to line 2227 because the condition on line 2222 was never true
2223 isinstance(n, str) for n in note_ids
2224 ):
2225 # Element type matters: synthesize_notes does dict.fromkeys(note_ids),
2226 # which raises TypeError (→ 500) on unhashable items like dicts.
2227 return jsonify(
2228 {
2229 "success": False,
2230 "error": "note_ids must be a list of strings",
2231 }
2232 ), 400
2233 synthesis_type = data.get("synthesis_type", "merge")
2234 # synthesize_notes checks `synthesis_type not in {set}` (a hash), which
2235 # raises TypeError (→ opaque 500) on an unhashable non-string like a
2236 # list/dict. Reject to a clean 400 here, like note_ids above.
2237 if not isinstance(synthesis_type, str):
2238 return jsonify(
2239 {
2240 "success": False,
2241 "error": "synthesis_type must be a string",
2242 }
2243 ), 400
2245 # synthesize_notes returns the result without saving — callers (this
2246 # preview route, and the create-note flow downstream) decide whether
2247 # to persist it. Same call site, same payload.
2248 service = NoteAIService(username)
2249 preview = service.synthesize_notes(note_ids, synthesis_type)
2251 if "error" in preview: 2251 ↛ 2252line 2251 didn't jump to line 2252 because the condition on line 2251 was never true
2252 return jsonify({"success": False, "error": preview["error"]}), 400
2254 return jsonify({"success": True, "result": preview})
2256 except Exception as e:
2257 return handle_api_error("previewing synthesis", e)
2260@notes_bp.route("/api/notes/synthesize", methods=["POST"])
2261@login_required
2262@_notes_synthesize_limit
2263def synthesize_notes():
2264 """
2265 Synthesize multiple notes into one.
2267 Body:
2268 note_ids: List of note IDs (2-5)
2269 synthesis_type: 'merge', 'summarize', or 'compare'
2270 create_note: Whether to create the note (default true)
2271 """
2272 from ...database.models import NoteSynthesis, NoteSynthesisSource
2274 username = session.get("username")
2275 if not username: 2275 ↛ 2276line 2275 didn't jump to line 2276 because the condition on line 2275 was never true
2276 return jsonify({"success": False, "error": "Not authenticated"}), 401
2278 try:
2279 data = request.get_json(silent=True)
2280 if not data or not data.get("note_ids"): 2280 ↛ 2281line 2280 didn't jump to line 2281 because the condition on line 2280 was never true
2281 return jsonify(
2282 {"success": False, "error": "note_ids is required"}
2283 ), 400
2285 note_ids = data["note_ids"]
2286 if not isinstance(note_ids, list) or not all(
2287 isinstance(n, str) for n in note_ids
2288 ):
2289 # Element type matters: synthesize_notes does dict.fromkeys(note_ids),
2290 # which raises TypeError (→ 500) on unhashable items like dicts.
2291 return jsonify(
2292 {
2293 "success": False,
2294 "error": "note_ids must be a list of strings",
2295 }
2296 ), 400
2297 synthesis_type = data.get("synthesis_type", "merge")
2298 # synthesize_notes checks `synthesis_type not in {set}` (a hash), which
2299 # raises TypeError (→ opaque 500) on an unhashable non-string like a
2300 # list/dict. Reject to a clean 400 here, like note_ids above.
2301 if not isinstance(synthesis_type, str):
2302 return jsonify(
2303 {
2304 "success": False,
2305 "error": "synthesis_type must be a string",
2306 }
2307 ), 400
2308 create_note = data.get("create_note", True)
2310 ai_service = NoteAIService(username)
2311 result = ai_service.synthesize_notes(note_ids, synthesis_type)
2313 if "error" in result:
2314 return jsonify({"success": False, "error": result["error"]}), 400
2316 # Create the note if requested
2317 if create_note:
2318 note_service = NoteService(username)
2319 new_note_id = note_service.create_note(
2320 title=result["suggested_title"],
2321 content=result["content"],
2322 tags=["synthesized", synthesis_type],
2323 )
2325 # Record the synthesis. Use the FILTERED source set the AI
2326 # service actually fed to the LLM (result["source_notes"])
2327 # rather than the raw client input — NoteAIService.synthesize_notes
2328 # silently drops any non-note Document IDs from the prompt, so
2329 # persisting raw note_ids would (a) record IDs the LLM never
2330 # saw as "sources" and (b) FK-violate on any bogus ID after
2331 # create_note has already committed.
2332 from ...database.session_context import get_user_db_session
2334 filtered_source_ids = [
2335 src["id"]
2336 for src in result.get("source_notes", [])
2337 if "id" in src
2338 ]
2340 db_session = None
2341 try:
2342 with get_user_db_session(username) as db_session:
2343 synthesis_record = NoteSynthesis(
2344 result_document_id=new_note_id,
2345 synthesis_type=synthesis_type,
2346 )
2347 for doc_id in filtered_source_ids:
2348 synthesis_record.sources.append(
2349 NoteSynthesisSource(source_document_id=doc_id)
2350 )
2351 db_session.add(synthesis_record)
2352 db_session.commit()
2353 except Exception:
2354 # create_note() already committed the new Document in its own
2355 # session, so a failure here would leave an orphaned
2356 # "synthesized" note with no provenance record. Roll back the
2357 # poisoned shared session and delete the just-created note
2358 # before surfacing the error.
2359 logger.exception(
2360 "Failed to persist synthesis record for note {}; "
2361 "deleting the orphaned note",
2362 new_note_id,
2363 )
2364 NoteService._rollback_quietly(db_session)
2365 try:
2366 note_service.delete_note(new_note_id)
2367 except Exception:
2368 logger.exception(
2369 "Failed to delete orphaned synthesized note {}",
2370 new_note_id,
2371 )
2372 raise
2374 result["note_id"] = new_note_id
2376 status_code = 201 if create_note else 200
2377 return jsonify({"success": True, "result": result}), status_code
2379 except Exception as e:
2380 return handle_api_error("synthesizing notes", e)
2383# ============================================================================
2384# API Routes - Version History
2385# ============================================================================
2388@notes_bp.route("/api/notes/<string:note_id>/versions", methods=["GET"])
2389@login_required
2390def get_note_versions(note_id):
2391 """
2392 Get version history for a note.
2394 Query params:
2395 limit: Maximum number of versions to return (default 20, max 200)
2396 offset: Number of versions to skip (default 0). Combined with
2397 limit, this makes older versions reachable when total > limit.
2398 Pre-fix the route had no offset support, so versions 1
2399 through (total - limit) were unreachable through the UI.
2400 """
2401 from ...database.models import Document, NoteVersion
2402 from ...database.session_context import get_user_db_session
2404 username = session.get("username")
2405 if not username: 2405 ↛ 2406line 2405 didn't jump to line 2406 because the condition on line 2405 was never true
2406 return jsonify({"success": False, "error": "Not authenticated"}), 401
2408 note_service = NoteService(username)
2410 try:
2411 try:
2412 limit = _clamp_limit(20)
2413 except (ValueError, TypeError):
2414 return jsonify({"success": False, "error": "Invalid limit"}), 400
2416 try:
2417 offset = max(0, int(request.args.get("offset", 0)))
2418 except (ValueError, TypeError):
2419 return jsonify({"success": False, "error": "Invalid offset"}), 400
2421 with get_user_db_session(username) as db_session:
2422 # Defense-in-depth: confirm the document is a note before
2423 # serving anything from its version history. Mirrors the
2424 # guard on the sibling version routes (get_note_version,
2425 # get_semantic_diff, restore_note_version). load_only keeps
2426 # the guard from hydrating text_content (up to 50 MB) just
2427 # to check the source type.
2428 doc = (
2429 db_session.query(Document)
2430 .options(load_only(Document.id, Document.source_type_id))
2431 .filter_by(id=note_id)
2432 .first()
2433 )
2434 if not doc or not note_service._is_note(db_session, doc):
2435 return jsonify(
2436 {"success": False, "error": "Note not found"}
2437 ), 404
2439 total = (
2440 db_session.query(NoteVersion)
2441 .filter_by(document_id=note_id)
2442 .count()
2443 )
2444 # Cap offset at total: an unbounded OFFSET (e.g.
2445 # ?offset=1_000_000_000) forces SQLite to row-walk the
2446 # whole table in a backward index scan even though the
2447 # result is empty. Clamping is cheap and harmless — a real
2448 # caller with offset > total wants the empty page anyway.
2449 if offset > total: 2449 ↛ 2450line 2449 didn't jump to line 2450 because the condition on line 2449 was never true
2450 offset = total
2451 # Column projection: each NoteVersion row carries a full
2452 # content snapshot (up to the 50 MB cap); loading whole
2453 # entities transferred `limit` note bodies per call to render
2454 # a metadata-only list.
2455 versions = (
2456 db_session.query(
2457 NoteVersion.id,
2458 NoteVersion.title,
2459 NoteVersion.change_type,
2460 NoteVersion.change_summary,
2461 NoteVersion.created_at,
2462 )
2463 .filter_by(document_id=note_id)
2464 .order_by(
2465 NoteVersion.created_at.desc(),
2466 NoteVersion.id.desc(), # id tiebreaker for stable paging
2467 )
2468 .offset(offset)
2469 .limit(limit)
2470 .all()
2471 )
2472 return jsonify(
2473 {
2474 "success": True,
2475 "total": total,
2476 "limit": limit,
2477 "offset": offset,
2478 "versions": [
2479 {
2480 "id": v.id,
2481 # Global version index — newest is `total`,
2482 # oldest is 1. Stable across pages.
2483 "version_number": total - offset - i,
2484 "title": v.title,
2485 "change_type": v.change_type,
2486 "change_summary": v.change_summary,
2487 "created_at": v.created_at.isoformat()
2488 if v.created_at
2489 else None,
2490 }
2491 for i, v in enumerate(versions)
2492 ],
2493 }
2494 )
2496 except Exception as e:
2497 return handle_api_error("getting note versions", e)
2500@notes_bp.route(
2501 "/api/notes/<string:note_id>/versions/semantic-diff", methods=["GET"]
2502)
2503@login_required
2504@_notes_ai_limit
2505def get_semantic_diff(note_id):
2506 """
2507 Get AI-powered semantic diff between two versions.
2509 Query params:
2510 version1: First version UUID
2511 version2: Second version UUID, or the literal "current" to diff
2512 against the note's current content.
2513 """
2514 from ...database.models import Document, NoteVersion
2515 from ...database.session_context import get_user_db_session
2517 username = session.get("username")
2518 if not username: 2518 ↛ 2519line 2518 didn't jump to line 2519 because the condition on line 2518 was never true
2519 return jsonify({"success": False, "error": "Not authenticated"}), 401
2521 note_service = NoteService(username)
2523 try:
2524 version1 = request.args.get("version1")
2525 version2 = request.args.get("version2")
2527 if not version1 or not version2: 2527 ↛ 2528line 2527 didn't jump to line 2528 because the condition on line 2527 was never true
2528 return jsonify(
2529 {
2530 "success": False,
2531 "error": "version1 and version2 are required",
2532 }
2533 ), 400
2535 with get_user_db_session(username) as db_session:
2536 # Defense-in-depth: confirm the document is a note before
2537 # serving anything from its version history. load_only defers
2538 # text_content; the "current" branch below lazy-loads it only
2539 # when actually diffing against the live note.
2540 doc = (
2541 db_session.query(Document)
2542 .options(load_only(Document.id, Document.source_type_id))
2543 .filter_by(id=note_id)
2544 .first()
2545 )
2546 if not doc or not note_service._is_note(db_session, doc): 2546 ↛ 2547line 2546 didn't jump to line 2547 because the condition on line 2546 was never true
2547 return jsonify(
2548 {"success": False, "error": "Note not found"}
2549 ), 404
2551 v1 = (
2552 db_session.query(NoteVersion)
2553 .filter_by(document_id=note_id, id=version1)
2554 .first()
2555 )
2556 if not v1:
2557 return jsonify(
2558 {"success": False, "error": "Version not found"}
2559 ), 404
2561 if version2 == "current": 2561 ↛ 2562line 2561 didn't jump to line 2562 because the condition on line 2561 was never true
2562 v2_content = doc.text_content or ""
2563 else:
2564 v2 = (
2565 db_session.query(NoteVersion)
2566 .filter_by(document_id=note_id, id=version2)
2567 .first()
2568 )
2569 if not v2:
2570 return jsonify(
2571 {"success": False, "error": "Version not found"}
2572 ), 404
2573 v2_content = v2.content
2575 ai_service = NoteAIService(username)
2576 diff = ai_service.semantic_diff(v1.content, v2_content)
2578 return jsonify({"success": True, "diff": diff})
2580 except Exception as e:
2581 return handle_api_error("getting semantic diff", e)
2584@notes_bp.route(
2585 "/api/notes/<string:note_id>/versions/<string:version_id>", methods=["GET"]
2586)
2587@login_required
2588def get_note_version(note_id, version_id):
2589 """Get a specific version of a note."""
2590 from ...database.models import Document, NoteVersion
2591 from ...database.session_context import get_user_db_session
2593 username = session.get("username")
2594 if not username: 2594 ↛ 2595line 2594 didn't jump to line 2595 because the condition on line 2594 was never true
2595 return jsonify({"success": False, "error": "Not authenticated"}), 401
2597 note_service = NoteService(username)
2599 try:
2600 with get_user_db_session(username) as db_session:
2601 # Defense-in-depth: confirm the parent document is a note.
2602 # load_only keeps the guard from hydrating text_content.
2603 doc = (
2604 db_session.query(Document)
2605 .options(load_only(Document.id, Document.source_type_id))
2606 .filter_by(id=note_id)
2607 .first()
2608 )
2609 if not doc or not note_service._is_note(db_session, doc): 2609 ↛ 2610line 2609 didn't jump to line 2610 because the condition on line 2609 was never true
2610 return jsonify(
2611 {"success": False, "error": "Note not found"}
2612 ), 404
2614 version = (
2615 db_session.query(NoteVersion)
2616 .filter_by(document_id=note_id, id=version_id)
2617 .first()
2618 )
2620 if not version:
2621 return jsonify(
2622 {"success": False, "error": "Version not found"}
2623 ), 404
2625 return jsonify(
2626 {
2627 "success": True,
2628 "version": {
2629 "id": version.id,
2630 "title": version.title,
2631 "content": version.content,
2632 "tags": version.tags,
2633 "change_type": version.change_type,
2634 "change_summary": version.change_summary,
2635 "created_at": version.created_at.isoformat()
2636 if version.created_at
2637 else None,
2638 },
2639 }
2640 )
2642 except Exception as e:
2643 return handle_api_error("getting note version", e)
2646@notes_bp.route(
2647 "/api/notes/<string:note_id>/versions/<string:version_id>/restore",
2648 methods=["POST"],
2649)
2650@login_required
2651@_notes_write_limit
2652def restore_note_version(note_id, version_id):
2653 """Restore a note to a previous version.
2655 Delegates to ``NoteService.restore_with_bookends`` which wraps
2656 PRE_RESTORE + content update + RESTORE in a single transaction.
2657 Earlier code orchestrated this from three separate sessions — process
2658 death between writes could leave a restored note with no audit-trail
2659 row. Keep the orchestration in the service; this handler just routes.
2660 """
2661 username = session.get("username")
2662 if not username: 2662 ↛ 2663line 2662 didn't jump to line 2663 because the condition on line 2662 was never true
2663 return jsonify({"success": False, "error": "Not authenticated"}), 401
2665 note_service = NoteService(username)
2667 try:
2668 success, error = note_service.restore_with_bookends(note_id, version_id)
2669 if not success: 2669 ↛ 2670line 2669 didn't jump to line 2670 because the condition on line 2669 was never true
2670 if error == "note_not_found":
2671 return jsonify(
2672 {"success": False, "error": "Note not found"}
2673 ), 404
2674 if error == "version_not_found":
2675 return jsonify(
2676 {"success": False, "error": "Version not found"}
2677 ), 404
2678 return jsonify(
2679 {"success": False, "error": "Failed to restore version"}
2680 ), 500
2682 _trigger_note_auto_index(note_id, note_service, username)
2684 return jsonify(
2685 {
2686 "success": True,
2687 "message": f"Restored to version {version_id[:8]}",
2688 }
2689 )
2691 except Exception as e:
2692 return handle_api_error("restoring note version", e)