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:
2026-03-02 20:52:21 -05:00
parent 3d7be5888e
commit 012eb1d46b
52 changed files with 4319 additions and 62 deletions
+276
View File
@@ -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