feat(knowledge): two-tier pagination — ID pre-fetch + content batch loading

Backend:
- GET /api/knowledge/ids: returns up to 100 note IDs cheaply (no body
  parsing), supports same filters as /api/knowledge, includes has_more
- GET /api/knowledge/batch?ids=...: fetches full items for given IDs in
  order; used by frontend to load content in controlled batches

Frontend (KnowledgeView):
- Fetch 100 IDs upfront, load first 50 as content on mount
- IntersectionObserver sentinel (root: null) triggers 24-item content
  batches as user scrolls
- Proactive ID refill when queue drops below 48 unloaded IDs
- fetchGen counter invalidates stale in-flight responses on filter reset
- IDs claimed before async fetch to prevent double-loading
- sentinelVisible ref drives post-load re-check when content doesn't
  push sentinel off screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 09:44:50 -04:00
parent 5495fd1500
commit 90afbec4c2
3 changed files with 244 additions and 58 deletions
+57
View File
@@ -62,6 +62,63 @@ async def list_knowledge():
})
@knowledge_bp.route("/ids", methods=["GET"])
@login_required
async def list_knowledge_ids():
"""Return note IDs only (cheap) for the two-tier pagination feed.
Same filter params as GET /api/knowledge.
Additional params: limit (default 100, max 200), offset (default 0).
Returns {ids, total, has_more}.
"""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []
sort = request.args.get("sort", "modified").strip().lower()
q = request.args.get("q", "").strip() or None
if sort not in _VALID_SORTS:
sort = "modified"
try:
limit = min(int(request.args.get("limit", 100)), 200)
offset = max(0, int(request.args.get("offset", 0)))
except ValueError:
return jsonify({"error": "Invalid limit or offset"}), 400
if note_type and note_type not in _VALID_TYPES:
return jsonify({"error": "Invalid type"}), 400
from fabledassistant.services.knowledge import query_knowledge_ids
ids, total = await query_knowledge_ids(
user_id=uid, note_type=note_type, tags=tags,
sort=sort, q=q, limit=limit, offset=offset,
)
return jsonify({"ids": ids, "total": total, "has_more": (offset + len(ids)) < total})
@knowledge_bp.route("/batch", methods=["GET"])
@login_required
async def get_knowledge_batch():
"""Fetch full items for a comma-separated list of IDs (max 100).
Returns {items: [...]} in the order of the requested IDs.
"""
uid = get_current_user_id()
ids_raw = request.args.get("ids", "").strip()
if not ids_raw:
return jsonify({"items": []})
try:
ids = [int(x) for x in ids_raw.split(",") if x.strip()]
except ValueError:
return jsonify({"error": "Invalid IDs"}), 400
if len(ids) > 100:
return jsonify({"error": "Too many IDs (max 100)"}), 400
from fabledassistant.services.knowledge import get_knowledge_by_ids
items = await get_knowledge_by_ids(uid, ids)
return jsonify({"items": items})
@knowledge_bp.route("/tags", methods=["GET"])
@login_required
async def list_knowledge_tags():
+63
View File
@@ -167,6 +167,69 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
return [r for r in rows if r]
async def query_knowledge_ids(
user_id: int,
note_type: str | None,
tags: list[str],
sort: str,
q: str | None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[int], int]:
"""Return note IDs only — cheap query for the two-tier pagination feed."""
if q:
# Re-use semantic search, extract IDs in rank order
items, total = await _semantic_knowledge_search(
user_id, q, note_type=note_type, tags=tags,
limit=limit, offset=offset,
)
return [item["id"] for item in items], total
async with async_session() as session:
base = (
select(Note.id)
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
for tag in tags:
base = base.where(Note.tags.contains([tag]))
count_stmt = select(func.count()).select_from(base.subquery())
total: int = (await session.execute(count_stmt)).scalar_one()
if sort == "created":
base = base.order_by(Note.created_at.desc())
elif sort == "alpha":
base = base.order_by(Note.title.asc())
elif sort == "type":
base = base.order_by(Note.note_type.asc(), Note.updated_at.desc())
else:
base = base.order_by(Note.updated_at.desc())
ids = list((await session.execute(base.limit(limit).offset(offset))).scalars().all())
return ids, total
async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]:
"""Fetch full items for the given IDs, preserving the requested order."""
if not ids:
return []
async with async_session() as session:
stmt = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.id.in_(ids))
)
rows = list((await session.execute(stmt)).scalars().all())
by_id = {n.id: n for n in rows}
return [_note_to_item(by_id[i]) for i in ids if i in by_id]
async def get_people_and_places_context(user_id: int) -> str:
"""Return a compact summary of known people and places for LLM system prompt injection."""
async with async_session() as session: