CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m2s
CI & Build / Build & push image (push) Skipped
`usage_for_notes` is named for notes and works on every note row, yet the chip reached snippets and rules only. Notes had it nowhere. Lessons had it collected and shown nowhere a person could reach, because #4196 taught `/api/lessons` to attach it and `KnowledgeView` — the only lesson list in the UI — browses through `/api/knowledge`, so `listLessons` still has no consumer. The cause was not a missing line. SEVEN call sites carried their own copy of the same few lines: two REST lists, two REST details, two MCP lists, one MCP detail. Each read perfectly well alone, so "which doors attach usage?" had no answer anywhere in the code — the same asymmetry test_system_tagging_door_parity.py records for System tagging (#4249), where whichever door nobody exercised for a kind is the one that never grew the feature. `attach_usage(rows, key="id")` is now that answer, and all seven go through it. A detail payload is a one-row list, so the single-record doors share the seam rather than keeping a second shape beside it. Deliberately NO try/except: the fail-open already lives in `usage_for_notes`, which reports through `_report_failure("readout")` and returns the zero-filled map. Wrapping it again would swallow the REPORT as well as the error, and a silently-swallowed readout failure is exactly #2663 — every counter reading zero in production for weeks while the writes landed fine. `/api/knowledge` now attaches usage, which closes both holes at once: it is how notes, lessons and processes are all browsed. `KnowledgeView` renders the badge on the card footer, looking the advice up per row because the feed is mixed. The advice moves to utils/deadWeight.ts. Canon #3460 says each caller owns its own const, and that held while each caller showed ONE kind; a mixed feed would need five of its own and the next surface another five. The canon's actual invariant — advice is kind-specific and never baked into the badge — is kept: it is still a prop. The three existing callers now read the same table, so the sentence has one home rather than four. Recorded against #3460 so the next reader is not left re-litigating it. `_row_id` rejects bools explicitly: `int(True)` is 1, so a row carrying a flag under the key would be credited with note #1's counts, and a wrong chip is worse than no chip because it reads as a measurement. A row with no usable id is skipped rather than failing the page. Tests pin the PROPERTY, not one route: no door calls the aggregate directly (AST, so a comment naming it is not a false positive), and every door that shows usage reaches the seam. Plus the N+1 guard — one aggregate per page, asserted on await_count, because the per-row version reads more naturally and is invisible in review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
172 lines
6.7 KiB
Python
172 lines
6.7 KiB
Python
"""Unified Knowledge endpoint — every record kind 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
|
|
from scribe.services.knowledge import FACET_TYPES
|
|
from scribe.services.note_usage import attach_usage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
|
|
|
# Derived from the service's facet table, never re-listed here. This set was a
|
|
# hand-kept copy and had drifted three kinds behind it: it admitted `plan`
|
|
# (retired in 0066) and rejected `issue` (shipped in 0065, 435 rows) and
|
|
# `snippet` — so the browse surface could not filter to the kinds it was
|
|
# already rendering badges for (#3128).
|
|
_VALID_TYPES = FACET_TYPES
|
|
_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 — a facet from services.knowledge._FACETS: a record type
|
|
(note|process|snippet) or a task kind (task for any,
|
|
else work|issue|spike|plan). 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,
|
|
)
|
|
|
|
items = await label_shared_items(uid, items)
|
|
# The surfaced-vs-opened counts, on the list a person actually browses
|
|
# (#4230). `usage_for_notes` always worked on every note row, but only the
|
|
# snippet and rule lists ever attached it — and this is the ONLY lesson and
|
|
# note list in the UI, so those two kinds had the counter collected and
|
|
# shown nowhere. Attaching here rather than teaching `/api/lessons` a
|
|
# second time is what closes both holes at once: `/knowledge` is how notes,
|
|
# lessons and processes are all browsed.
|
|
#
|
|
# Mixed kinds is not a problem for this: usage keys on the note row, which
|
|
# every facet of this feed is.
|
|
await attach_usage(items)
|
|
|
|
return jsonify({
|
|
# Mark rows another user owns: this feed can be mixed-ownership, and an
|
|
# unmarked card reads as one the viewer wrote.
|
|
"items": 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, narrowed to one facet."""
|
|
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)
|