Files
FabledScribe/src/scribe/routes/knowledge.py
T
bvandeusen ef1dbdfc86
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped
feat(acl): unify the retrieval scopes; mark shared rows in Knowledge browse
Closes #2092 and the Knowledge-browse provenance gap.

The two halves of a hybrid search disagreed: the keyword half honoured shares
while the semantic half was pinned to NoteEmbedding.user_id, so a shared record
was findable by wording and invisible by meaning — the case a semantic search
exists to serve. semantic_search_notes now scopes on Note via a `scope`
parameter, and each of its five callers declares which kind of act it is:

  mcp/tools/search.py     read    the agent asked
  routes/search.py        read    the user typed it
  knowledge.py (semantic) read    matches the keyword half beside it
  plugin_context.py       browse  nobody asked; never a one-to-one share
  dedup.py                own     a verdict that blocks a write must not hinge
                                  on another person's notes

That last one is the reason this isn't a single global widening: the dedup gate
returns "update the existing one instead", so matching a stranger's record would
refuse a legitimate create and point at something the caller can't edit. Scope
defaults to "own" so a caller that forgets is wrong in the safe direction, and an
unknown scope raises rather than falling back — a typo there would be a
data-exposure bug.

Auto-inject keeps the browse scope, which still admits a collaborator's note via
a shared project. Its menu line is the only provenance an agent sees, so a
foreign hit now reads: #12 "Title" (0.71) - shared by alex, treat as a
suggestion. MCP and REST search results carry shared/owner too.

Knowledge browse: the feed hydrates cards from /api/knowledge/batch rather than
the list route, so both paths label rows now, and KnowledgeView shows "by
<owner>" on records the viewer doesn't own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 23:06:52 -04:00

150 lines
5.5 KiB
Python

"""Unified Knowledge endpoint — notes, tasks, plans, and processes in one queryable feed."""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import parse_pagination
from scribe.services.access import label_shared_items
logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "task", "plan", "process"}
_VALID_SORTS = {"modified", "created", "alpha", "type"}
@knowledge_bp.route("", methods=["GET"])
@login_required
async def list_knowledge():
"""Return paginated knowledge objects with optional filtering.
Query params:
type — one of note|task|plan|process (omit for all)
tags — comma-separated tag filter (AND logic)
sort — modified|created|alpha|type (default: modified)
q — search query (semantic when provided, keyword fallback)
page — 1-based page number (default 1)
per_page — items per page (default 24, max 100)
"""
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 note_type and note_type not in _VALID_TYPES:
return jsonify({"error": f"Invalid type. Must be one of: {', '.join(sorted(_VALID_TYPES))}"}), 400
if sort not in _VALID_SORTS:
sort = "modified"
limit, offset = parse_pagination(default_limit=24, max_limit=100)
page = max(1, int(request.args.get("page", 1)))
from scribe.services.knowledge import query_knowledge
items, total = await query_knowledge(
user_id=uid,
note_type=note_type,
tags=tags,
sort=sort,
q=q,
limit=limit,
offset=offset,
)
return jsonify({
# Mark rows another user owns: this feed can be mixed-ownership, and an
# unmarked card reads as one the viewer wrote.
"items": await label_shared_items(uid, items),
"total": total,
"page": page,
"per_page": limit,
"pages": max(1, (total + limit - 1) // limit),
})
@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 scribe.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 scribe.services.knowledge import get_knowledge_by_ids
items = await get_knowledge_by_ids(uid, ids)
# The scrolling feed hydrates its cards here, not from the list route, so the
# ownership markers have to be applied on this path too.
return jsonify({"items": await label_shared_items(uid, items)})
@knowledge_bp.route("/tags", methods=["GET"])
@login_required
async def list_knowledge_tags():
"""Return all tags used across knowledge objects (excludes tasks)."""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
from scribe.services.knowledge import get_knowledge_tags
tags = await get_knowledge_tags(uid, note_type=note_type)
return jsonify({"tags": tags})
@knowledge_bp.route("/counts", methods=["GET"])
@login_required
async def get_knowledge_counts():
"""Return per-type counts — used by the sidebar to show item counts."""
uid = get_current_user_id()
tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
from scribe.services.knowledge import get_knowledge_counts as _counts
counts = await _counts(uid, tags=tags)
return jsonify(counts)