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
+241
View File
@@ -927,11 +927,123 @@ _RAG_TOOLS = [
},
]
_ENTITY_TOOLS = [
{
"type": "function",
"function": {
"name": "create_person",
"description": (
"Save a person to the user's knowledge base. Use this when the user introduces "
"someone by name or asks to remember details about a person (family member, friend, "
"contact, colleague). Stored people are used to resolve names in future prompts."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Full name of the person"},
"relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"},
"phone": {"type": "string", "description": "Phone number"},
"email": {"type": "string", "description": "Email address"},
"birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"},
"address": {"type": "string", "description": "Home or mailing address"},
"notes": {"type": "string", "description": "Any additional free-form notes about this person"},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "create_place",
"description": (
"Save a named location to the user's knowledge base. Use this when the user wants "
"to remember a place (business, venue, address). Stored places are used to auto-fill "
"locations in calendar events and other prompts."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"},
"address": {"type": "string", "description": "Street address"},
"phone": {"type": "string", "description": "Phone number"},
"hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"},
"url": {"type": "string", "description": "Website URL"},
"notes": {"type": "string", "description": "Any additional free-form notes about this place"},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "create_list",
"description": (
"Create a named list (shopping list, to-do list, packing list, etc.). "
"Items are stored as a markdown task list. Use add_to_list to add items to an existing list."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"},
"items": {
"type": "array",
"items": {"type": "string"},
"description": "Initial list items (unchecked)",
},
"store": {"type": "string", "description": "Optional associated store or place name"},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Tags for the list",
},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "add_to_list",
"description": "Add one or more items to an existing list. Items are appended as unchecked entries.",
"parameters": {
"type": "object",
"properties": {
"list_name": {"type": "string", "description": "Name of the list to add items to"},
"items": {
"type": "array",
"items": {"type": "string"},
"description": "Items to add",
},
},
"required": ["list_name", "items"],
},
},
},
{
"type": "function",
"function": {
"name": "clear_checked_items",
"description": "Remove all checked/completed items from a list, keeping only unchecked items.",
"parameters": {
"type": "object",
"properties": {
"list_name": {"type": "string", "description": "Name of the list to clear"},
},
"required": ["list_name"],
},
},
},
]
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool list for a user based on their configured integrations."""
tools = list(_CORE_TOOLS)
tools.extend(_RAG_TOOLS)
tools.extend(_ENTITY_TOOLS)
if await is_caldav_configured(user_id):
tools.extend(_CALDAV_TOOLS)
if Config.searxng_enabled():
@@ -1902,6 +2014,135 @@ async def execute_tool(
await update_conversation(user_id, conv_id, rag_project_id=project_id)
return {"success": True, "type": "rag_scope_set", "scope_label": scope_label}
elif tool_name == "create_person":
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
meta: dict = {}
for field in ("relationship", "phone", "email", "birthday", "address"):
val = arguments.get(field)
if val:
meta[field] = str(val)
extra_notes = arguments.get("notes", "")
body_lines = []
if meta.get("relationship"):
body_lines.append(f"**Relationship:** {meta['relationship']}")
if meta.get("phone"):
body_lines.append(f"**Phone:** {meta['phone']}")
if meta.get("email"):
body_lines.append(f"**Email:** {meta['email']}")
if meta.get("birthday"):
body_lines.append(f"**Birthday:** {meta['birthday']}")
if meta.get("address"):
body_lines.append(f"**Address:** {meta['address']}")
if extra_notes:
body_lines.append(f"\n{extra_notes}")
note = await create_note(
user_id=user_id,
title=name,
body="\n".join(body_lines),
tags=["person"],
note_type="person",
metadata=meta,
)
_schedule_embedding(note.id, user_id, name, note.body)
return {"success": True, "type": "person", "data": {"id": note.id, "name": name}}
elif tool_name == "create_place":
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
meta = {}
for field in ("address", "phone", "hours", "url"):
val = arguments.get(field)
if val:
meta[field] = str(val)
extra_notes = arguments.get("notes", "")
body_lines = []
if meta.get("address"):
body_lines.append(f"**Address:** {meta['address']}")
if meta.get("phone"):
body_lines.append(f"**Phone:** {meta['phone']}")
if meta.get("hours"):
body_lines.append(f"**Hours:** {meta['hours']}")
if meta.get("url"):
body_lines.append(f"**Website:** {meta['url']}")
if extra_notes:
body_lines.append(f"\n{extra_notes}")
note = await create_note(
user_id=user_id,
title=name,
body="\n".join(body_lines),
tags=["place"],
note_type="place",
metadata=meta,
)
_schedule_embedding(note.id, user_id, name, note.body)
return {"success": True, "type": "place", "data": {"id": note.id, "name": name}}
elif tool_name == "create_list":
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
items = arguments.get("items") or []
if not isinstance(items, list):
items = []
body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip())
meta = {}
store = arguments.get("store")
if store:
meta["store"] = str(store)
tags = arguments.get("tags") or []
if not isinstance(tags, list):
tags = []
note = await create_note(
user_id=user_id,
title=name,
body=body,
tags=tags,
note_type="list",
metadata=meta,
)
_schedule_embedding(note.id, user_id, name, body)
return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}}
elif tool_name == "add_to_list":
list_name = str(arguments.get("list_name", "")).strip()
items = arguments.get("items") or []
if not list_name:
return {"success": False, "error": "list_name is required"}
if not isinstance(items, list) or not items:
return {"success": False, "error": "items must be a non-empty array"}
existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None)
if target is None:
target = next((n for n in existing if n.note_type == "list"), None)
if target is None:
return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."}
new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip())
existing_body = (target.body or "").rstrip()
new_body = f"{existing_body}\n{new_lines}".lstrip()
await update_note(user_id=user_id, note_id=target.id, body=new_body)
_schedule_embedding(target.id, user_id, target.title, new_body)
return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}}
elif tool_name == "clear_checked_items":
list_name = str(arguments.get("list_name", "")).strip()
if not list_name:
return {"success": False, "error": "list_name is required"}
existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None)
if target is None:
target = next((n for n in existing if n.note_type == "list"), None)
if target is None:
return {"success": False, "error": f"No list named '{list_name}' found."}
import re as _re
body = target.body or ""
cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip()
await update_note(user_id=user_id, note_id=target.id, body=cleaned)
_schedule_embedding(target.id, user_id, target.title, cleaned)
return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}}
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}