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:
@@ -0,0 +1,28 @@
|
||||
"""Add note_type and metadata columns to notes table for entity types."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0036"
|
||||
down_revision = "0035"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("note_type", sa.Text(), nullable=False, server_default="note"),
|
||||
)
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("metadata", JSONB(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_notes_note_type", "notes", ["note_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notes_note_type", table_name="notes")
|
||||
op.drop_column("notes", "metadata")
|
||||
op.drop_column("notes", "note_type")
|
||||
@@ -15,7 +15,8 @@ const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"));
|
||||
const isChatActive = computed(() => route.path.startsWith("/chat"))
|
||||
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
@@ -72,15 +73,12 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Center: primary navigation (desktop) -->
|
||||
<div class="nav-center">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + utilities + gear + user -->
|
||||
@@ -123,13 +121,12 @@ router.afterEach(() => {
|
||||
|
||||
<!-- Mobile dropdown -->
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
@@ -7,8 +6,12 @@ const router = createRouter({
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
name: "knowledge",
|
||||
component: () => import("@/views/KnowledgeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/knowledge",
|
||||
redirect: "/",
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ from fabledassistant.routes.events import events_bp
|
||||
from fabledassistant.routes.search import search_bp
|
||||
from fabledassistant.routes.voice import voice_bp
|
||||
from fabledassistant.routes.profile import profile_bp
|
||||
from fabledassistant.routes.knowledge import knowledge_bp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -96,6 +97,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(search_bp)
|
||||
app.register_blueprint(voice_bp)
|
||||
app.register_blueprint(profile_bp)
|
||||
app.register_blueprint(knowledge_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
|
||||
@@ -51,6 +51,10 @@ class Note(Base, TimestampMixin):
|
||||
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Entity type — 'note' (default), 'person', 'place', 'list'
|
||||
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
||||
# Structured metadata for entity types (person/place/list)
|
||||
metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||
@@ -59,12 +63,18 @@ class Note(Base, TimestampMixin):
|
||||
Index("ix_notes_user_id", "user_id"),
|
||||
Index("ix_notes_project_id", "project_id"),
|
||||
Index("ix_notes_milestone_id", "milestone_id"),
|
||||
Index("ix_notes_note_type", "note_type"),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_task(self) -> bool:
|
||||
return self.status is not None
|
||||
|
||||
@property
|
||||
def entity_type(self) -> str:
|
||||
"""Normalised type: 'note', 'person', 'place', or 'list'."""
|
||||
return self.note_type or "note"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -86,6 +96,8 @@ class Note(Base, TimestampMixin):
|
||||
else None
|
||||
),
|
||||
"is_task": self.is_task,
|
||||
"note_type": self.entity_type,
|
||||
"metadata": self.metadata or {},
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Unified Knowledge endpoint — notes, people, places, lists in one queryable feed."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.routes.utils import parse_pagination
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
||||
|
||||
_VALID_TYPES = {"note", "person", "place", "list"}
|
||||
_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 — one of note|person|place|list (omit for all, excludes tasks)
|
||||
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 fabledassistant.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,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
"pages": max(1, (total + limit - 1) // limit),
|
||||
})
|
||||
|
||||
|
||||
@knowledge_bp.route("/tags", methods=["GET"])
|
||||
@login_required
|
||||
async def list_knowledge_tags():
|
||||
"""Return all tags used across knowledge objects (excludes tasks)."""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
|
||||
from fabledassistant.services.knowledge import get_knowledge_tags
|
||||
tags = await get_knowledge_tags(uid, note_type=note_type)
|
||||
return jsonify({"tags": tags})
|
||||
@@ -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)
|
||||
@@ -515,14 +515,17 @@ async def build_context(
|
||||
tz_line = f" The user's timezone is {user_timezone}." if user_timezone else ""
|
||||
|
||||
from fabledassistant.services.user_profile import build_profile_context
|
||||
from fabledassistant.services.knowledge import get_people_and_places_context
|
||||
profile_context = await build_profile_context(user_id)
|
||||
profile_section = f"\n\n{profile_context}" if profile_context else ""
|
||||
entities_context = await get_people_and_places_context(user_id)
|
||||
entities_section = f"\n\n{entities_context}" if entities_context else ""
|
||||
|
||||
system_parts = [
|
||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
||||
"Help users with their notes, tasks, and general questions. "
|
||||
"When note context is provided, use it to give relevant answers. "
|
||||
f"Today's date is {today}.{tz_line}{profile_section}\n\n"
|
||||
f"Today's date is {today}.{tz_line}{profile_section}{entities_section}\n\n"
|
||||
f"{tool_guidance}"
|
||||
]
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ async def create_note(
|
||||
priority: str | None = None,
|
||||
due_date: date | None = None,
|
||||
recurrence_rule: dict | None = None,
|
||||
note_type: str = "note",
|
||||
metadata: dict | None = None,
|
||||
) -> Note:
|
||||
# Auto-populate project_id from milestone when not explicitly provided
|
||||
if milestone_id is not None and project_id is None:
|
||||
@@ -82,6 +84,8 @@ async def create_note(
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
recurrence_rule=recurrence_rule,
|
||||
note_type=note_type,
|
||||
metadata=metadata,
|
||||
)
|
||||
session.add(note)
|
||||
await session.commit()
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user