Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation
## Projects & Milestones (Phases A + G) - New models: Project, Milestone (Project → Milestone → Task hierarchy) - notes table: project_id + milestone_id FKs; parent_id FK constraint activated - Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones) - Services: projects.py, milestones.py (CRUD + progress tracking) - Routes: /api/projects + /api/projects/<id>/milestones - LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools - Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated ## RAG Auto-injection (Phase B) - Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each) - excluded_note_ids param; ChatView "Auto-included" sidebar section ## Summarisation improvements (Phase C) - Threshold 20→30, keep-recent 6→8, max_tokens 200→400 - Two-pass summarisation for histories >50 messages ## Browser push notifications (Phase E) - PushSubscription model + migration; pywebpush dependency - /api/push routes; VAPID config; fire-and-forget on generation complete - Frontend: sw.js, push store, Settings toggle ## PWA manifest (Phase F) - manifest.json, Apple meta tags, service worker registration in main.ts ## Tag normalisation - All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize) - Note/Task types gain project_id + milestone_id fields; store signatures updated ## CalDAV - Radicale embedded server reverted; back to user-configured external CalDAV Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,18 @@ _CORE_TOOLS = [
|
||||
"enum": ["none", "low", "medium", "high"],
|
||||
"description": "Task priority level",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project name to assign this task to",
|
||||
},
|
||||
"parent_task": {
|
||||
"type": "string",
|
||||
"description": "Optional title of a parent task to make this a sub-task of",
|
||||
},
|
||||
"milestone": {
|
||||
"type": "string",
|
||||
"description": "Optional milestone title within the project to assign this task to",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
@@ -81,6 +93,10 @@ _CORE_TOOLS = [
|
||||
"items": {"type": "string"},
|
||||
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project name to assign this note to",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
@@ -143,6 +159,10 @@ _CORE_TOOLS = [
|
||||
"enum": ["replace", "add", "remove"],
|
||||
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
@@ -294,6 +314,14 @@ _CORE_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Return tasks due on or after this date (YYYY-MM-DD)",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Filter tasks by project name",
|
||||
},
|
||||
"milestone": {
|
||||
"type": "string",
|
||||
"description": "Filter tasks by milestone name within a project",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of tasks to return (default 10)",
|
||||
@@ -302,6 +330,100 @@ _CORE_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_project",
|
||||
"description": "Create a new project. Use when the user asks to create a project, initiative, or campaign to organize related tasks and notes.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Project title"},
|
||||
"description": {"type": "string", "description": "Brief description"},
|
||||
"goal": {"type": "string", "description": "The goal or desired outcome"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_projects",
|
||||
"description": "List the user's projects. Use when asked about projects, initiatives, or campaigns.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "completed", "archived"],
|
||||
"description": "Filter by status (omit for all)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_project",
|
||||
"description": "Get details and summary of a specific project.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Project name or keyword to find it"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_project",
|
||||
"description": "Update a project's status, description, or goal.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Project name to find"},
|
||||
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
|
||||
"description": {"type": "string"},
|
||||
"goal": {"type": "string"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_milestone",
|
||||
"description": "Create a new milestone inside a project for grouping and tracking related tasks.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string", "description": "Project title (will be created if not found)"},
|
||||
"title": {"type": "string", "description": "Milestone title"},
|
||||
"description": {"type": "string", "description": "Optional description"},
|
||||
},
|
||||
"required": ["project", "title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_milestones",
|
||||
"description": "List milestones for a project with their progress percentages.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string", "description": "Project name to look up"},
|
||||
},
|
||||
"required": ["project"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# CalDAV tools — only included when user has CalDAV configured
|
||||
@@ -604,6 +726,27 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
)
|
||||
suggested = await suggest_tags(user_id, task_title, task_body)
|
||||
_schedule_embedding(note.id, user_id, task_title, task_body)
|
||||
# Handle optional project and parent_task assignment
|
||||
project_name = arguments.get("project")
|
||||
parent_task_name = arguments.get("parent_task")
|
||||
update_fields: dict = {}
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project
|
||||
proj = await get_or_create_project(user_id, project_name)
|
||||
update_fields["project_id"] = proj.id
|
||||
if parent_task_name:
|
||||
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
|
||||
if parent_notes:
|
||||
update_fields["parent_id"] = parent_notes[0].id
|
||||
milestone_name = arguments.get("milestone")
|
||||
if milestone_name and project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project as _gocp
|
||||
from fabledassistant.services.milestones import get_or_create_milestone
|
||||
proj = await _gocp(user_id, project_name)
|
||||
ms = await get_or_create_milestone(user_id, proj.id, milestone_name)
|
||||
update_fields["milestone_id"] = ms.id
|
||||
if update_fields:
|
||||
note = await update_note(user_id, note.id, **update_fields)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "task",
|
||||
@@ -613,6 +756,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"status": note.status,
|
||||
"priority": note.priority,
|
||||
"due_date": str(note.due_date) if note.due_date else None,
|
||||
"project_id": note.project_id,
|
||||
},
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
@@ -635,12 +779,19 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
)
|
||||
suggested = await suggest_tags(user_id, note_title, note_body)
|
||||
_schedule_embedding(note.id, user_id, note_title, note_body)
|
||||
# Handle optional project assignment
|
||||
project_name = arguments.get("project")
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project
|
||||
proj = await get_or_create_project(user_id, project_name)
|
||||
note = await update_note(user_id, note.id, project_id=proj.id)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "note",
|
||||
"data": {
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
"project_id": note.project_id,
|
||||
},
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
@@ -693,6 +844,15 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
else:
|
||||
update_fields["tags"] = new_tags
|
||||
|
||||
if "project" in arguments:
|
||||
project_name = arguments["project"]
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_or_create_project
|
||||
proj = await get_or_create_project(user_id, project_name)
|
||||
update_fields["project_id"] = proj.id
|
||||
else:
|
||||
update_fields["project_id"] = None
|
||||
|
||||
updated = await update_note(user_id, note.id, **update_fields)
|
||||
if updated is None:
|
||||
return {"success": False, "error": "Failed to update note."}
|
||||
@@ -710,6 +870,25 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
}
|
||||
|
||||
elif tool_name == "list_tasks":
|
||||
project_id = None
|
||||
milestone_id = None
|
||||
project_name = arguments.get("project")
|
||||
if project_name:
|
||||
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
|
||||
proj = await get_project_by_title(user_id, project_name)
|
||||
if proj is None:
|
||||
all_p = await _lp(user_id)
|
||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
||||
if matches:
|
||||
proj = matches[0]
|
||||
if proj:
|
||||
project_id = proj.id
|
||||
milestone_name = arguments.get("milestone")
|
||||
if milestone_name:
|
||||
from fabledassistant.services.milestones import get_milestone_by_title
|
||||
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
|
||||
if ms:
|
||||
milestone_id = ms.id
|
||||
notes, total = await list_notes(
|
||||
user_id=user_id,
|
||||
is_task=True,
|
||||
@@ -717,6 +896,8 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
priority=arguments.get("priority"),
|
||||
due_before=_parse_due_date(arguments.get("due_before")),
|
||||
due_after=_parse_due_date(arguments.get("due_after")),
|
||||
project_id=project_id,
|
||||
milestone_id=milestone_id,
|
||||
limit=int(arguments.get("limit", 10)),
|
||||
sort="due_date",
|
||||
order="asc",
|
||||
@@ -729,6 +910,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": str(n.due_date) if n.due_date else None,
|
||||
"project_id": n.project_id,
|
||||
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
|
||||
})
|
||||
return {
|
||||
@@ -950,6 +1132,39 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"data": {"id": note.id, "title": note.title},
|
||||
}
|
||||
|
||||
elif tool_name == "create_milestone":
|
||||
from fabledassistant.services.projects import get_or_create_project as _gocp
|
||||
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
||||
project_name = arguments.get("project", "")
|
||||
if not project_name:
|
||||
return {"success": False, "error": "project is required"}
|
||||
proj = await _gocp(user_id, project_name)
|
||||
ms = await _cm(
|
||||
user_id,
|
||||
proj.id,
|
||||
title=arguments.get("title", "Untitled Milestone"),
|
||||
description=arguments.get("description"),
|
||||
)
|
||||
return {"success": True, "type": "milestone", "data": ms.to_dict()}
|
||||
|
||||
elif tool_name == "list_milestones":
|
||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
||||
from fabledassistant.services.milestones import get_project_milestone_summary
|
||||
project_name = arguments.get("project", "")
|
||||
proj = await _gpbt(user_id, project_name)
|
||||
if proj is None:
|
||||
all_p = await _lp(user_id)
|
||||
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
|
||||
proj = matches[0] if matches else None
|
||||
if proj is None:
|
||||
return {"success": False, "error": f"No project found matching '{project_name}'"}
|
||||
summary = await get_project_milestone_summary(user_id, proj.id)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "milestones_list",
|
||||
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
|
||||
}
|
||||
|
||||
elif tool_name == "search_web":
|
||||
from fabledassistant.services.research import _search_searxng
|
||||
query = arguments.get("query", "")
|
||||
@@ -976,6 +1191,67 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"data": {"topic": topic},
|
||||
}
|
||||
|
||||
elif tool_name == "create_project":
|
||||
from fabledassistant.services.projects import create_project as _create_project
|
||||
project = await _create_project(
|
||||
user_id,
|
||||
title=arguments.get("title", "New Project"),
|
||||
description=arguments.get("description", ""),
|
||||
goal=arguments.get("goal", ""),
|
||||
)
|
||||
return {"success": True, "type": "project", "data": project.to_dict()}
|
||||
|
||||
elif tool_name == "list_projects":
|
||||
from fabledassistant.services.projects import list_projects as _list_projects
|
||||
projects = await _list_projects(user_id, status=arguments.get("status"))
|
||||
return {
|
||||
"success": True,
|
||||
"type": "projects_list",
|
||||
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
|
||||
}
|
||||
|
||||
elif tool_name == "get_project":
|
||||
from fabledassistant.services.projects import (
|
||||
get_project_by_title as _gpbt,
|
||||
get_project_summary as _gps,
|
||||
list_projects as _lp,
|
||||
)
|
||||
query = arguments.get("query", "")
|
||||
project = await _gpbt(user_id, query)
|
||||
if project is None:
|
||||
all_projects = await _lp(user_id)
|
||||
matches = [p for p in all_projects if query.lower() in p.title.lower()]
|
||||
if matches:
|
||||
project = matches[0]
|
||||
if project is None:
|
||||
return {"success": False, "error": f"No project found matching '{query}'"}
|
||||
summary = await _gps(user_id, project.id)
|
||||
data = project.to_dict()
|
||||
data["summary"] = summary
|
||||
return {"success": True, "type": "project_detail", "data": data}
|
||||
|
||||
elif tool_name == "update_project":
|
||||
from fabledassistant.services.projects import (
|
||||
get_project_by_title as _gpbt,
|
||||
update_project as _up,
|
||||
list_projects as _lp,
|
||||
)
|
||||
query = arguments.get("query", "")
|
||||
project = await _gpbt(user_id, query)
|
||||
if project is None:
|
||||
all_projects = await _lp(user_id)
|
||||
matches = [p for p in all_projects if query.lower() in p.title.lower()]
|
||||
if matches:
|
||||
project = matches[0]
|
||||
if project is None:
|
||||
return {"success": False, "error": f"No project found matching '{query}'"}
|
||||
fields = {}
|
||||
for k in ("status", "description", "goal"):
|
||||
if k in arguments:
|
||||
fields[k] = arguments[k]
|
||||
updated = await _up(user_id, project.id, **fields)
|
||||
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
|
||||
|
||||
elif tool_name == "search_images":
|
||||
from fabledassistant.services.images import fetch_and_store_image
|
||||
from fabledassistant.services.research import _search_searxng_images
|
||||
|
||||
Reference in New Issue
Block a user