feat: Knowledge view + entity types (People, Places, Lists)

Data model:
- Migration 0036: adds note_type TEXT (default 'note') and metadata JSONB
  to the notes table; index on note_type
- Note model: entity_type property, note_type/metadata in to_dict()
- create_note() accepts note_type and metadata params

Backend:
- /api/knowledge — unified paginated endpoint: type/tag/sort/q filters,
  semantic search via embeddings, excludes tasks
- /api/knowledge/tags — distinct tags across knowledge objects
- New LLM tools: create_person, create_place, create_list, add_to_list,
  clear_checked_items — all wired into execute_tool()
- People and places auto-injected as compact summary into LLM system prompt

Frontend:
- KnowledgeView replaces HomeView at /; left filter panel (type+tag),
  toolbar (search, sort, graph toggle), card grid with type-aware cards
  (indigo=note, emerald=person, amber=place, sky=list), load-more pagination
- Today bar: upcoming events, overdue task count, Briefing/Chat links
- Floating mini-chat sticky to bottom: creates/continues a conversation
  inline, message history expands upward, close button ends session
- Graph panel: toggles as a 420px right panel at full viewport width
- AppHeader: Knowledge, Chat, Briefing, Calendar, Tasks, Projects
- Router: / → KnowledgeView; /knowledge redirect; HomeView import removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 18:01:03 -04:00
parent 425d307180
commit 80f30b705d
11 changed files with 1593 additions and 17 deletions
+199
View File
@@ -0,0 +1,199 @@
"""Knowledge service — unified query across notes, people, places, and lists."""
import logging
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
_SNIPPET_LEN = 200
def _note_to_item(note: Note) -> dict:
meta = note.metadata or {}
item: dict = {
"id": note.id,
"note_type": note.entity_type,
"title": note.title,
"snippet": (note.body or "")[:_SNIPPET_LEN],
"tags": note.tags or [],
"project_id": note.project_id,
"metadata": meta,
"created_at": note.created_at.isoformat(),
"updated_at": note.updated_at.isoformat(),
}
# Type-specific convenience fields
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
elif note.entity_type == "list":
# Count checked / total items from markdown task list syntax
body = note.body or ""
total = body.count("- [ ]") + body.count("- [x]") + body.count("- [X]")
checked = body.count("- [x]") + body.count("- [X]")
item["item_count"] = total
item["checked_count"] = checked
return item
async def query_knowledge(
user_id: int,
note_type: str | None,
tags: list[str],
sort: str,
q: str | None,
limit: int,
offset: int,
) -> tuple[list[dict], int]:
"""Query knowledge objects (non-task notes) with filters.
Returns (items, total_count).
"""
# Semantic search path — scores take priority over sort
if q:
return await _semantic_knowledge_search(
user_id, q, note_type=note_type, tags=tags, limit=limit, offset=offset
)
async with async_session() as session:
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.status.is_(None)) # exclude tasks
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
# Exclude tasks — already done above; also exclude any legacy nulls
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
for tag in tags:
base = base.where(Note.tags.contains([tag]))
# Count before pagination
count_stmt = select(func.count()).select_from(base.subquery())
total: int = (await session.execute(count_stmt)).scalar_one()
# Apply sort
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: # modified (default)
base = base.order_by(Note.updated_at.desc())
rows = list((await session.execute(base.limit(limit).offset(offset))).scalars().all())
return [_note_to_item(n) for n in rows], total
async def _semantic_knowledge_search(
user_id: int,
q: str,
note_type: str | None,
tags: list[str],
limit: int,
offset: int,
) -> tuple[list[dict], int]:
"""Semantic search over knowledge objects, with SQL filters applied post-rank."""
try:
from fabledassistant.services.embeddings import semantic_search_notes
# Fetch a larger candidate set to allow for filtering
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
threshold=0.3,
is_task=False,
)
except Exception:
logger.warning("Semantic search unavailable, falling back to SQL", exc_info=True)
return await query_knowledge(user_id, note_type, tags, "modified", None, limit, offset)
results = []
for _score, note in candidates:
if note_type and note.entity_type != note_type:
continue
if tags and not all(t in (note.tags or []) for t in tags):
continue
results.append(note)
total = len(results)
page_items = results[offset: offset + limit]
return [_note_to_item(n) for n in page_items], total
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 = (
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)
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)
rows = list((await session.execute(stmt)).scalars().all())
return [r for r in rows if r]
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:
stmt = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.note_type.in_(["person", "place"]))
.where(Note.status.is_(None))
.order_by(Note.title.asc())
.limit(50)
)
rows = list((await session.execute(stmt)).scalars().all())
if not rows:
return ""
people = [n for n in rows if n.entity_type == "person"]
places = [n for n in rows if n.entity_type == "place"]
lines = []
if people:
parts = []
for p in people:
meta = p.metadata or {}
rel = meta.get("relationship", "")
parts.append(f"{p.title}" + (f" ({rel})" if rel else ""))
lines.append("Known people: " + ", ".join(parts))
if places:
parts = []
for p in places:
meta = p.metadata or {}
addr = meta.get("address", "")
parts.append(f"{p.title}" + (f" {addr}" if addr else ""))
lines.append("Known places: " + "; ".join(parts))
return "\n".join(lines)