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:
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user