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
+149
View File
@@ -0,0 +1,149 @@
"""Browser push notification service using VAPID/pywebpush."""
import asyncio
import json
import logging
from datetime import datetime, timezone
from functools import lru_cache
from sqlalchemy import delete, select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.push_subscription import PushSubscription
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def _get_webpush():
"""Lazy import to avoid startup errors if pywebpush is not installed."""
try:
from pywebpush import WebPusher
return WebPusher
except ImportError:
return None
def vapid_enabled() -> bool:
return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY)
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
"""Upsert a push subscription by endpoint."""
endpoint = subscription_json.get("endpoint", "")
keys = subscription_json.get("keys", {})
p256dh = keys.get("p256dh", "")
auth = keys.get("auth", "")
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
existing = result.scalars().first()
if existing:
existing.p256dh = p256dh
existing.auth = auth
existing.user_agent = user_agent
existing.last_used = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
sub = PushSubscription(
user_id=user_id,
endpoint=endpoint,
p256dh=p256dh,
auth=auth,
user_agent=user_agent,
)
session.add(sub)
await session.commit()
await session.refresh(sub)
return sub
async def delete_subscription(user_id: int, endpoint: str) -> None:
async with async_session() as session:
await session.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
await session.commit()
async def _remove_expired_subscription(user_id: int, endpoint: str) -> None:
"""Remove a subscription that returned 410 Gone."""
try:
await delete_subscription(user_id, endpoint)
logger.info("Removed expired push subscription for user %d", user_id)
except Exception:
logger.warning("Failed to remove expired subscription", exc_info=True)
async def send_push_notification(
user_id: int,
title: str,
body: str,
url: str = "/",
) -> None:
"""Send a push notification to all subscriptions for a user.
Fire-and-forget — wrap in asyncio.create_task() at call site.
"""
if not vapid_enabled():
logger.debug("VAPID not configured, skipping push notification")
return
WebPusher = _get_webpush()
if WebPusher is None:
logger.warning("pywebpush not installed, cannot send push notifications")
return
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
subscriptions = list(result.scalars().all())
if not subscriptions:
return
payload = json.dumps({"title": title, "body": body, "url": url})
vapid_claims = {
"sub": Config.VAPID_CLAIMS_SUB,
}
def _send_sync(sub: PushSubscription) -> tuple[int, str]:
"""Synchronous send — runs in executor."""
try:
subscription_info = {
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
response = WebPusher(subscription_info).send(
data=payload,
vapid_private_key=Config.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims,
)
return response.status_code, sub.endpoint
except Exception as e:
logger.warning("Push send failed for sub %d: %s", sub.id, e)
return 0, sub.endpoint
loop = asyncio.get_event_loop()
tasks = [loop.run_in_executor(None, _send_sync, sub) for sub in subscriptions]
results = await asyncio.gather(*tasks, return_exceptions=True)
for sub, result in zip(subscriptions, results):
if isinstance(result, Exception):
continue
status_code, endpoint = result
if status_code == 410:
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
elif status_code and status_code not in (200, 201):
logger.warning("Push returned status %d for user %d", status_code, user_id)