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
+8 -1
View File
@@ -14,6 +14,9 @@ from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp
from fabledassistant.routes.milestones import milestones_bp
from fabledassistant.routes.projects import projects_bp
from fabledassistant.routes.push import push_bp
from fabledassistant.routes.quick_capture import quick_capture_bp
from fabledassistant.routes.settings import settings_bp
from fabledassistant.routes.tasks import tasks_bp
@@ -46,7 +49,10 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(push_bp)
app.register_blueprint(quick_capture_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(tasks_bp)
@@ -91,7 +97,8 @@ def create_app() -> Quart:
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
"connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self';"
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
"base-uri 'self'; worker-src 'self';"
)
return response
+5
View File
@@ -60,6 +60,11 @@ class Config:
# Maximum size of a single image to cache (default 5 MB)
IMAGE_MAX_BYTES: int = int(os.environ.get("IMAGE_MAX_BYTES", str(5 * 1024 * 1024)))
# VAPID keys for browser push notifications
VAPID_PRIVATE_KEY: str = os.environ.get("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.environ.get("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_SUB: str = os.environ.get("VAPID_CLAIMS_SUB", "mailto:admin@fabledassistant.local")
@classmethod
def oidc_enabled(cls) -> bool:
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
+4
View File
@@ -25,3 +25,7 @@ from fabledassistant.models.password_reset import PasswordResetToken # noqa: E4
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
from fabledassistant.models.image_cache import ImageCache # noqa: E402, F401
from fabledassistant.models.project import Project # noqa: E402, F401
from fabledassistant.models.push_subscription import PushSubscription # noqa: E402, F401
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE")
)
project_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
)
# iCal UID for Radicale linkage (unique per user)
uid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(Text, default="")
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"uid": self.uid,
"project_id": self.project_id,
"title": self.title,
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
"end_dt": self.end_dt.isoformat() if self.end_dt else None,
"all_day": self.all_day,
"description": self.description,
"location": self.location,
"recurrence": self.recurrence,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class Milestone(Base):
__tablename__ = "milestones"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"project_id": self.project_id,
"title": self.title,
"description": self.description,
"status": self.status,
"order_index": self.order_index,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+11 -1
View File
@@ -32,7 +32,13 @@ class Note(Base):
body: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
parent_id: Mapped[int | None] = mapped_column(
nullable=True
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
project_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
)
milestone_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -51,6 +57,8 @@ class Note(Base):
Index("ix_notes_status", "status"),
Index("ix_notes_title", "title"),
Index("ix_notes_user_id", "user_id"),
Index("ix_notes_project_id", "project_id"),
Index("ix_notes_milestone_id", "milestone_id"),
)
@property
@@ -64,6 +72,8 @@ class Note(Base):
"body": self.body,
"tags": self.tags or [],
"parent_id": self.parent_id,
"project_id": self.project_id,
"milestone_id": self.milestone_id,
"status": self.status,
"priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None,
+36
View File
@@ -0,0 +1,36 @@
import enum
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class ProjectStatus(str, enum.Enum):
active = "active"
completed = "completed"
archived = "archived"
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
title: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str] = mapped_column(Text, default="")
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"goal": self.goal,
"status": self.status,
"color": self.color,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -0,0 +1,20 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
endpoint: Mapped[str] = mapped_column(Text)
p256dh: Mapped[str] = mapped_column(Text)
auth: Mapped[str] = mapped_column(Text)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "endpoint", name="uq_push_subscription_user_endpoint"),
)
+2
View File
@@ -114,6 +114,7 @@ async def send_message_route(conv_id: int):
return jsonify({"error": "content is required"}), 400
context_note_id = data.get("context_note_id")
include_note_ids = data.get("include_note_ids") or []
excluded_note_ids = data.get("excluded_note_ids") or []
think = bool(data.get("think", False))
# Reject if generation already running for this conversation
@@ -143,6 +144,7 @@ async def send_message_route(conv_id: int):
uid, conv_id, conv.title, content,
context_note_id=context_note_id,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
think=think,
))
+124
View File
@@ -0,0 +1,124 @@
"""Milestone routes nested under /api/projects/<project_id>/milestones."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
get_milestone,
get_milestone_progress,
list_milestones,
update_milestone,
)
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import get_project
logger = logging.getLogger(__name__)
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
@login_required
async def list_milestones_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
status = request.args.get("status")
milestones = await list_milestones(uid, project_id, status=status)
result = []
for m in milestones:
entry = m.to_dict()
progress = await get_milestone_progress(m.id)
entry.update(progress)
result.append(entry)
return jsonify({"milestones": result})
@milestones_bp.route("/<int:project_id>/milestones", methods=["POST"])
@login_required
async def create_milestone_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
milestone = await create_milestone(
uid,
project_id,
title=data["title"],
description=data.get("description"),
order_index=data.get("order_index", 0),
)
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry), 201
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
@login_required
async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry)
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
@login_required
async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
updated = await update_milestone(uid, milestone_id, **fields)
if updated is None:
return jsonify({"error": "Milestone not found"}), 404
entry = updated.to_dict()
entry.update(await get_milestone_progress(updated.id))
return jsonify(entry)
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
@login_required
async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
deleted = await delete_milestone(uid, milestone_id)
if not deleted:
return jsonify({"error": "Milestone not found"}), 404
return "", 204
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>/tasks", methods=["GET"])
@login_required
async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
notes, total = await list_notes(
uid,
is_task=True,
status=status_filter,
milestone_id=milestone_id,
limit=limit,
offset=offset,
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+3 -1
View File
@@ -84,6 +84,8 @@ async def create_note_route():
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=data.get("project_id"),
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
due_date=due_date,
@@ -176,7 +178,7 @@ async def update_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "status", "priority"):
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
if key in data:
fields[key] = data[key]
+114
View File
@@ -0,0 +1,114 @@
"""Project management routes."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
create_project,
delete_project,
get_project,
get_project_summary,
list_projects,
update_project,
)
logger = logging.getLogger(__name__)
projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
@projects_bp.route("", methods=["GET"])
@login_required
async def list_projects_route():
uid = get_current_user_id()
status = request.args.get("status")
projects = await list_projects(uid, status=status)
return jsonify({"projects": [p.to_dict() for p in projects]})
@projects_bp.route("", methods=["POST"])
@login_required
async def create_project_route():
uid = get_current_user_id()
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
project = await create_project(
uid,
title=data["title"],
description=data.get("description", ""),
goal=data.get("goal", ""),
color=data.get("color"),
)
return jsonify(project.to_dict()), 201
@projects_bp.route("/<int:project_id>", methods=["GET"])
@login_required
async def get_project_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
summary = await get_project_summary(uid, project_id)
data = project.to_dict()
data["summary"] = summary
return jsonify(data)
@projects_bp.route("/<int:project_id>", methods=["PATCH"])
@login_required
async def update_project_route(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed}
project = await update_project(uid, project_id, **fields)
if project is None:
return jsonify({"error": "Project not found"}), 404
return jsonify(project.to_dict())
@projects_bp.route("/<int:project_id>", methods=["DELETE"])
@login_required
async def delete_project_route(project_id: int):
uid = get_current_user_id()
deleted = await delete_project(uid, project_id)
if not deleted:
return jsonify({"error": "Project not found"}), 404
return "", 204
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
is_task: bool | None = None
if type_filter == "task":
is_task = True
elif type_filter == "note":
is_task = False
notes, total = await list_notes(
uid,
is_task=is_task,
status=status_filter,
project_id=project_id,
limit=limit,
offset=offset,
sort="updated_at",
order="desc",
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+44
View File
@@ -0,0 +1,44 @@
"""Push notification subscription routes."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
logger = logging.getLogger(__name__)
push_bp = Blueprint("push", __name__, url_prefix="/api/push")
@push_bp.route("/vapid-public-key", methods=["GET"])
@login_required
async def get_vapid_key():
if not vapid_enabled():
return jsonify({"error": "Push notifications not configured"}), 503
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY})
@push_bp.route("/subscribe", methods=["POST"])
@login_required
async def subscribe():
uid = get_current_user_id()
data = await request.get_json()
if not data or not data.get("endpoint"):
return jsonify({"error": "Invalid subscription data"}), 400
user_agent = request.headers.get("User-Agent")
sub = await save_subscription(uid, data, user_agent=user_agent)
return jsonify({"id": sub.id}), 201
@push_bp.route("/subscribe", methods=["DELETE"])
@login_required
async def unsubscribe():
uid = get_current_user_id()
data = await request.get_json()
endpoint = (data or {}).get("endpoint", "")
if not endpoint:
return jsonify({"error": "endpoint is required"}), 400
await delete_subscription(uid, endpoint)
return "", 204
+8 -8
View File
@@ -16,13 +16,13 @@ CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "cald
async def get_caldav_config(user_id: int) -> dict[str, str]:
"""Read CalDAV settings for the given user."""
"""Return the user's CalDAV config from their settings."""
all_settings = await get_all_settings(user_id)
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
async def is_caldav_configured(user_id: int) -> bool:
"""Check if CalDAV URL, username, and password are all set."""
"""Check if the user has configured an external CalDAV server."""
config = await get_caldav_config(user_id)
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
@@ -55,8 +55,8 @@ def _make_client(config: dict[str, str]) -> caldav.DAVClient:
"""Create a CalDAV client from config dict."""
return caldav.DAVClient(
url=config["caldav_url"],
username=config["caldav_username"],
password=config["caldav_password"],
username=config.get("caldav_username") or None,
password=config.get("caldav_password") or None,
)
@@ -158,8 +158,8 @@ def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
def _check_config(config: dict[str, str]) -> None:
"""Raise if CalDAV is not configured."""
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
raise ValueError("CalDAV is not configured. Go to Settings -> Calendar to set it up.")
if not config.get("caldav_url"):
raise ValueError("CalDAV is not configured. Go to Settings Calendar to enter your server URL.")
async def create_event(
@@ -707,8 +707,8 @@ async def delete_todo(
async def test_connection(user_id: int) -> dict:
"""Test the CalDAV connection and return status."""
config = await get_caldav_config(user_id)
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
return {"success": False, "error": "CalDAV is not configured. Please fill in URL, username, and password."}
if not config.get("caldav_url"):
return {"success": False, "error": "CalDAV is not configured."}
def _test():
client = _make_client(config)
@@ -0,0 +1,304 @@
"""Calendar sync service: bridges the DB Event table with local Radicale.
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
The app connects without auth (Radicale runs with auth.type = none inside Docker).
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
"""
import asyncio
import logging
import uuid
from datetime import datetime, timezone
import httpx
import icalendar
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
logger = logging.getLogger(__name__)
# Internal Radicale URL (Docker service name)
_RADICALE_INTERNAL = "http://radicale:5232"
def _user_calendar_path(user_id: int) -> str:
return f"/user_{user_id}/calendar/"
def _user_calendar_url(user_id: int) -> str:
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
return f"{base}/user_{user_id}/calendar/"
async def setup_user_calendar(user_id: int) -> bool:
"""Create Radicale collection for user if it doesn't exist.
Returns True on success or if already exists, False on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
user_url = f"{base}/user_{user_id}/"
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Create user home collection
await client.request(
"MKCOL",
user_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set><prop><resourcetype><collection/></resourcetype></prop></set>
</mkcol>""",
)
# Create calendar collection
resp = await client.request(
"MKCOL",
cal_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set>
<prop>
<resourcetype><collection/><C:calendar/></resourcetype>
<displayname>Fabled Assistant</displayname>
</prop>
</set>
</mkcol>""",
)
if resp.status_code in (201, 405): # 405 = already exists
logger.info("Calendar collection ready for user %d", user_id)
return True
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
return False
except Exception:
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
return False
def _make_vcalendar(event: Event) -> str:
"""Build iCalendar string from DB Event."""
cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN")
cal.add("version", "2.0")
vevent = icalendar.Event()
vevent.add("uid", event.uid)
vevent.add("summary", event.title)
if event.all_day:
vevent.add("dtstart", event.start_dt.date())
if event.end_dt:
vevent.add("dtend", event.end_dt.date())
else:
vevent.add("dtstart", event.start_dt)
if event.end_dt:
vevent.add("dtend", event.end_dt)
if event.description:
vevent.add("description", event.description)
if event.location:
vevent.add("location", event.location)
if event.recurrence:
rrule_parts: dict = {}
for part in event.recurrence.split(";"):
if "=" in part:
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
if rrule_parts:
vevent.add("rrule", rrule_parts)
vevent.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(vevent)
return cal.to_ical().decode("utf-8")
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
"""Write a DB Event to Radicale. Creates or updates the resource.
Returns True on success.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
ical_data = _make_vcalendar(event)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(
resource_url,
content=ical_data.encode("utf-8"),
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if resp.status_code in (200, 201, 204):
return True
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
return False
except Exception:
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
return False
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
"""Delete an event from Radicale by UID."""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(resource_url)
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
except Exception:
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
return False
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
"""Fetch an event from Radicale and upsert into the DB Event table.
Returns the upserted Event or None on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(resource_url)
if resp.status_code == 404:
return None
resp.raise_for_status()
ical_data = resp.text
except Exception:
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
return None
try:
cal = icalendar.Calendar.from_ical(ical_data)
for component in cal.walk():
if component.name != "VEVENT":
continue
title = str(component.get("SUMMARY", ""))
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
description = str(component.get("DESCRIPTION", ""))
location = str(component.get("LOCATION", ""))
rrule = component.get("RRULE")
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
all_day = False
if dtstart and isinstance(dtstart.dt, datetime):
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
elif dtstart:
from datetime import date
d = dtstart.dt
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
all_day = True
else:
continue
end_dt = None
if dtend and isinstance(dtend.dt, datetime):
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
elif dtend:
from datetime import date
d = dtend.dt
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
)
existing = result.scalars().first()
if existing:
existing.title = title
existing.start_dt = start_dt
existing.end_dt = end_dt
existing.all_day = all_day
existing.description = description
existing.location = location
existing.recurrence = recurrence
existing.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
event_obj = Event(
user_id=user_id,
uid=ical_uid,
title=title,
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=description,
location=location,
recurrence=recurrence,
)
session.add(event_obj)
await session.commit()
await session.refresh(event_obj)
return event_obj
except Exception:
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
return None
async def sync_all_events_from_radicale(user_id: int) -> int:
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
Returns number of events synced.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
# PROPFIND to get all .ics resources
resp = await client.request(
"PROPFIND",
cal_url,
headers={"Depth": "1", "Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
)
if resp.status_code == 404:
logger.info("No calendar found for user %d — setting up", user_id)
await setup_user_calendar(user_id)
return 0
if resp.status_code != 207:
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
return 0
# Extract UIDs from href paths
import re
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
except Exception:
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
return 0
synced = 0
for uid in uids:
if uid:
event = await sync_event_to_db(user_id, uid)
if event:
synced += 1
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
return synced
def get_external_caldav_url(user_id: int) -> str:
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
"""
external = Config.RADICALE_EXTERNAL_URL
if external:
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
# Fall back to BASE_URL host with port 5232
from urllib.parse import urlparse
parsed = urlparse(Config.BASE_URL)
host = parsed.hostname or "localhost"
return f"http://{host}:5232/user_{user_id}/calendar/"
+3 -2
View File
@@ -78,11 +78,12 @@ async def semantic_search_notes(
query: str,
exclude_ids: set[int] | None = None,
limit: int = 8,
threshold: float = _SIMILARITY_THRESHOLD,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
Scores are cosine similarities in [-1, 1]; only notes at or above
_SIMILARITY_THRESHOLD are returned, sorted highest-first.
*threshold* are returned, sorted highest-first.
Returns an empty list if the embedding model is unavailable or on any error.
"""
try:
@@ -114,7 +115,7 @@ async def semantic_search_notes(
sim = _cosine_similarity(query_vec, ne.embedding)
except Exception:
continue
if sim >= _SIMILARITY_THRESHOLD:
if sim >= threshold:
scored.append((sim, note))
scored.sort(key=lambda x: x[0], reverse=True)
@@ -147,6 +147,7 @@ async def run_generation(
user_content: str,
context_note_id: int | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
think: bool = False,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
@@ -166,7 +167,7 @@ async def run_generation(
# Phase 2: Summarize long conversation history if needed.
history_to_use = history
history_summary: str | None = None
if len(history) > 20: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
if len(history) > 30: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
buf.append_event("status", {"status": "Summarizing conversation history..."})
history_to_use, history_summary = await summarize_history_for_context(history, model)
@@ -177,6 +178,7 @@ async def run_generation(
user_id, history_to_use, context_note_id, user_content,
history_summary=history_summary,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
))
messages, context_meta = await context_task
@@ -348,6 +350,22 @@ async def run_generation(
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing})
# Fire push notification when complete (non-critical, fire-and-forget)
try:
from fabledassistant.services.push import send_push_notification, vapid_enabled
if vapid_enabled():
preview = buf.content_so_far[:120].rstrip()
if len(buf.content_so_far) > 120:
preview += ""
asyncio.create_task(send_push_notification(
user_id,
title="Response ready",
body=preview,
url=f"/chat/{conv_id}",
))
except Exception:
logger.debug("Failed to schedule push notification", exc_info=True)
# Title generation is non-critical — fire-and-forget so done fires immediately
non_system = [m for m in messages if m["role"] != "system"]
msg_count = len(non_system)
+1
View File
@@ -132,6 +132,7 @@ Rules:
- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday")
- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note")
- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X")
- create_project: user wants to create a project, initiative, or campaign ("new project X", "start a project called Y", "create a project for Z")
- create_note: everything else — ideas, observations, links, excerpts, longer text
- For create_task / create_event: extract a concise title; put any extra detail in "body"
- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body"
+110 -19
View File
@@ -27,6 +27,10 @@ STOP_WORDS = frozenset({
"but", "if", "so", "than", "too", "very", "just", "about", "up",
})
RAG_AUTO_THRESHOLD = 0.60
RAG_AUTO_LIMIT = 3
RAG_AUTO_SNIPPET = 800
async def get_installed_models() -> set[str]:
"""Return set of installed Ollama model names (with and without :latest)."""
@@ -302,8 +306,8 @@ def _find_urls(text: str) -> list[str]:
# History summarization thresholds
_HISTORY_SUMMARY_THRESHOLD = 20 # total messages before summarizing
_HISTORY_KEEP_RECENT = 6 # verbatim tail to preserve (3 exchanges)
_HISTORY_SUMMARY_THRESHOLD = 30 # total messages before summarizing
_HISTORY_KEEP_RECENT = 8 # verbatim tail to preserve (4 exchanges)
async def summarize_history_for_context(
@@ -324,13 +328,55 @@ async def summarize_history_for_context(
to_summarize = history[:-_HISTORY_KEEP_RECENT]
recent = history[-_HISTORY_KEEP_RECENT:]
lines: list[str] = []
for m in to_summarize:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {content[:400]}")
# Two-pass for very long histories: summarize first half, combine with second half
if len(to_summarize) > 50:
mid = len(to_summarize) // 2
first_half = to_summarize[:mid]
second_half = to_summarize[mid:]
# Summarize first half
first_lines = []
for m in first_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
first_lines.append(f"{label}: {content[:400]}")
if first_lines:
try:
first_summary_messages = [
{"role": "system", "content": "Summarize this conversation in 3-4 sentences covering topics, notes/tasks created, and key decisions."},
{"role": "user", "content": "\n".join(first_lines)},
]
summary_a = await generate_completion(first_summary_messages, model, max_tokens=300)
summary_a = summary_a.strip()
except Exception:
summary_a = ""
else:
summary_a = ""
# Build lines for final pass from second half
second_lines = []
for m in second_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
second_lines.append(f"{label}: {content[:400]}")
if summary_a:
lines = [f"[Earlier summary: {summary_a}]"] + second_lines
else:
lines = second_lines
else:
lines: list[str] = []
for m in to_summarize:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {content[:400]}")
if not lines:
return history, None
@@ -339,17 +385,19 @@ async def summarize_history_for_context(
{
"role": "system",
"content": (
"Summarize this conversation history in 3-5 concise sentences. "
"Capture: topics discussed, any notes/tasks/events created or modified, "
"decisions made, and context needed to continue the conversation naturally. "
"Be specific and factual. Output only the summary, nothing else."
"Summarize this conversation history. Capture: "
"(1) All notes, tasks, and projects created or modified — include their exact names. "
"(2) Key decisions made and conclusions reached. "
"(3) Open questions and next steps mentioned. "
"(4) The overall topic arc so the conversation can continue naturally. "
"Be specific and factual. Output 4-8 concise sentences. Nothing else."
),
},
{"role": "user", "content": "\n".join(lines)},
]
try:
summary = await generate_completion(prompt_messages, model, max_tokens=200)
summary = await generate_completion(prompt_messages, model, max_tokens=400)
summary = summary.strip()
if summary:
logger.info(
@@ -371,6 +419,7 @@ async def build_context(
exclude_note_ids: list[int] | None = None,
history_summary: str | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -426,6 +475,7 @@ async def build_context(
"context_note_id": None,
"context_note_title": None,
"auto_notes": [],
"auto_injected_notes": [],
}
# Include current note context if provided — full body, no truncation
@@ -441,10 +491,9 @@ async def build_context(
f"--- End Note ---"
)
# Search for related notes to populate sidebar candidates.
# Results are NOT injected into the system prompt automatically — this keeps
# the system prompt prefix stable so Ollama's KV cache can reuse prefill state.
# Users can explicitly include notes via the sidebar (include_note_ids).
# Search for related notes. High-confidence results (>=0.60) are auto-injected
# into the system prompt; lower-confidence results populate the sidebar only.
# Users can also explicitly include notes via the sidebar (include_note_ids).
search_exclude = set(exclude_set)
if current_note_id:
search_exclude.add(current_note_id)
@@ -473,12 +522,54 @@ async def build_context(
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
# Populate sidebar candidates (never auto-injected into system prompt).
# Separate high-confidence results for auto-injection vs sidebar display
excluded_inject_set = set(excluded_note_ids or [])
auto_inject: list[tuple[float, object]] = []
sidebar_only: list[tuple[float | None, object]] = []
for score, n in found_scored:
if (
score is not None
and score >= RAG_AUTO_THRESHOLD
and len(auto_inject) < RAG_AUTO_LIMIT
and n.id not in excluded_inject_set
):
auto_inject.append((score, n))
else:
sidebar_only.append((score, n))
# Inject high-scoring notes into system prompt
if auto_inject:
snippets = []
for score, n in auto_inject:
body_snippet = (n.body or "")[:RAG_AUTO_SNIPPET]
snippets.append(f"**{n.title}** (relevance: {round(score * 100)}%)\n{body_snippet}")
context_meta["auto_injected_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2),
})
system_parts.append(
"\n\n--- Relevant Notes ---\n"
+ "\n\n".join(snippets)
+ "\n--- End Relevant Notes ---"
)
# Populate sidebar candidates (auto-injected notes also appear here for reference,
# but sidebar_only are the ones not yet in the prompt)
for score, n in auto_inject:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": True,
})
for score, n in sidebar_only:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": False,
})
context_meta["auto_note_ids"] = [n.id for _, n in found_scored]
+145
View File
@@ -0,0 +1,145 @@
"""Milestone management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
async def create_milestone(
user_id: int,
project_id: int,
title: str,
description: str | None = None,
order_index: int = 0,
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
user_id=user_id,
project_id=project_id,
title=title,
description=description,
status="active",
order_index=order_index,
)
session.add(milestone)
await session.commit()
await session.refresh(milestone)
return milestone
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
return result.scalars().first()
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
return milestone
return await create_milestone(user_id, project_id, title=title)
async def list_milestones(
user_id: int, project_id: int, status: str | None = None
) -> list[Milestone]:
async with async_session() as session:
query = select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
)
if status:
query = query.where(Milestone.status == status)
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return None
for key, value in fields.items():
if hasattr(milestone, key):
setattr(milestone, key, value)
milestone.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(milestone)
return milestone
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return False
await session.delete(milestone)
await session.commit()
return True
async def get_milestone_progress(milestone_id: int) -> dict:
"""Return task completion stats for a milestone."""
async with async_session() as session:
rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(Note.milestone_id == milestone_id, Note.status.isnot(None))
.group_by(Note.status)
)
status_counts: dict[str, int] = {}
for status, count in rows.fetchall():
status_counts[status] = count
total = sum(status_counts.values())
completed = status_counts.get("done", 0)
pct = round(completed / total * 100, 1) if total > 0 else 0.0
return {
"total": total,
"completed": completed,
"pct": pct,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
},
}
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats."""
milestones = await list_milestones(user_id, project_id)
result = []
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result
+29 -1
View File
@@ -9,12 +9,26 @@ from fabledassistant.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
def _normalize_tags(tags: list[str]) -> list[str]:
"""Lowercase, strip, deduplicate, and drop empty tags."""
seen: set[str] = set()
out: list[str] = []
for t in tags:
normalized = t.strip().lower()
if normalized and normalized not in seen:
seen.add(normalized)
out.append(normalized)
return out
async def create_note(
user_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
parent_id: int | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
@@ -24,8 +38,10 @@ async def create_note(
user_id=user_id,
title=title,
body=body,
tags=tags or [],
tags=_normalize_tags(tags or []),
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
@@ -53,6 +69,8 @@ async def list_notes(
priority: str | None = None,
due_before: date | None = None,
due_after: date | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
@@ -105,6 +123,14 @@ async def list_notes(
query = query.where(Note.due_date >= due_after)
count_query = count_query.where(Note.due_date >= due_after)
if project_id is not None:
query = query.where(Note.project_id == project_id)
count_query = count_query.where(Note.project_id == project_id)
if milestone_id is not None:
query = query.where(Note.milestone_id == milestone_id)
count_query = count_query.where(Note.milestone_id == milestone_id)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
@@ -153,6 +179,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
value = TaskStatus(value).value
elif key == "priority" and isinstance(value, str):
value = TaskPriority(value).value
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
note.updated_at = datetime.now(timezone.utc)
await session.commit()
+146
View File
@@ -0,0 +1,146 @@
"""Project management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
logger = logging.getLogger(__name__)
async def create_project(
user_id: int,
title: str,
description: str = "",
goal: str = "",
color: str | None = None,
) -> Project:
async with async_session() as session:
project = Project(
user_id=user_id,
title=title,
description=description,
goal=goal,
color=color,
status="active",
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
async def get_project(user_id: int, project_id: int) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
return result.scalars().first()
async def get_project_by_title(user_id: int, title: str) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(
Project.user_id == user_id,
func.lower(Project.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def get_or_create_project(user_id: int, title: str) -> Project:
project = await get_project_by_title(user_id, title)
if project:
return project
return await create_project(user_id, title=title)
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
async with async_session() as session:
query = select(Project).where(Project.user_id == user_id)
if status:
query = query.where(Project.status == status)
query = query.order_by(Project.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return None
for key, value in fields.items():
if hasattr(project, key):
setattr(project, key, value)
project.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(project)
return project
async def delete_project(user_id: int, project_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return False
# Unlink notes (cascade handled by DB ON DELETE SET NULL, but we delete project here)
await session.delete(project)
await session.commit()
return True
async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity."""
async with async_session() as session:
# Task counts by status
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
)
.group_by(Note.status)
)
task_counts: dict[str, int] = {}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from fabledassistant.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
+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)
+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