feat(knowledge): note types, counts, new-note button, audit fixes

- Add note_type (note/person/place/list) selector + entity metadata fields
  (relationship, email, phone / address, hours) to NoteEditorView
- Pre-select type via ?type= query param from KnowledgeView new-note dropdown
- KnowledgeView: add split "New note / ▾" button with type dropdown
- KnowledgeView: show per-type counts on sidebar filter buttons (when > 1)
- Fix: filter-btn now flex layout so count badge aligns to right edge
- Fix: list_notes count_query was missing parent_id filter (inflated totals)
- Fix: PATCH /api/notes/:id now fires upsert_note_embedding (workspace autosave)
- Fix: get_knowledge_counts endpoint for per-type counts
- Fix: get_knowledge_tags was silently discarding note_type filter (double-stmt bug)
- Fix: NoteEditorView onMounted stray brace from edit session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 12:53:09 -04:00
parent 738245af5c
commit ed715dcc23
8 changed files with 328 additions and 58 deletions
+13
View File
@@ -129,3 +129,16 @@ async def list_knowledge_tags():
from fabledassistant.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 fabledassistant.services.knowledge import get_knowledge_counts as _counts
counts = await _counts(uid, tags=tags)
return jsonify(counts)
+44 -20
View File
@@ -104,18 +104,26 @@ async def create_note_route():
if proj:
project_id = proj.id
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=project_id,
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
due_date=due_date,
)
note_type = data.get("note_type", "note")
entity_meta = data.get("metadata") or None
try:
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=project_id,
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
due_date=due_date,
note_type=note_type,
entity_meta=entity_meta,
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
@@ -207,9 +215,11 @@ async def update_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "metadata" in data:
fields["entity_meta"] = data["metadata"] or None
if "due_date" in data:
if data["due_date"]:
@@ -222,7 +232,10 @@ async def update_note_route(note_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
try:
note = await update_note(uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
@@ -237,19 +250,30 @@ async def patch_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "metadata" in data:
fields["entity_meta"] = data["metadata"] or None
if "due_date" in data:
result = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
if data["due_date"]:
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
try:
note = await update_note(uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
return jsonify(note.to_dict())
+26 -15
View File
@@ -141,32 +141,43 @@ async def _semantic_knowledge_search(
async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]:
"""Return all distinct tags used across knowledge objects for this user."""
from sqlalchemy.dialects.postgresql import array
async with async_session() as session:
stmt = (
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
stmt = stmt.where(Note.note_type == note_type)
base = base.where(Note.note_type == note_type)
else:
stmt = stmt.where(Note.note_type.in_(["note", "person", "place", "list"]))
stmt = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.distinct()
.order_by("tag")
)
if note_type:
stmt = stmt.where(Note.note_type == note_type)
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
stmt = base.distinct().order_by("tag")
rows = list((await session.execute(stmt)).scalars().all())
return [r for r in rows if r]
async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]:
"""Return per-type count of knowledge objects for the sidebar display."""
async with async_session() as session:
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Ensure all types present even if zero
for t in ("note", "person", "place", "list"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
return counts
async def query_knowledge_ids(
user_id: int,
note_type: str | None,
+9 -2
View File
@@ -188,6 +188,7 @@ async def list_notes(
if parent_id is not None:
query = query.where(Note.parent_id == parent_id)
count_query = count_query.where(Note.parent_id == parent_id)
if no_project:
query = query.where(Note.project_id.is_(None))
@@ -242,9 +243,15 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
if not hasattr(note, key):
continue
if key == "status" and isinstance(value, str):
value = TaskStatus(value).value
try:
value = TaskStatus(value).value
except ValueError:
raise ValueError(f"Invalid status: {value!r}. Must be one of: {[s.value for s in TaskStatus]}")
elif key == "priority" and isinstance(value, str):
value = TaskPriority(value).value
try:
value = TaskPriority(value).value
except ValueError:
raise ValueError(f"Invalid priority: {value!r}. Must be one of: {[p.value for p in TaskPriority]}")
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)