Phase 5: Notifications — WebSocket, APScheduler, AI tool, health review
Backend: - Notification model + Alembic migration - Notification service: CRUD, mark read, unread count, pending scheduled - WebSocket manager singleton for real-time push - WebSocket endpoint /ws/notifications with JWT auth via query param - APScheduler integration: periodic notification sender (every 60s), daily proactive health review job (8 AM) - AI tool: schedule_notification (immediate or scheduled) - Health review worker: analyzes user memory via Claude, creates ai_generated notifications with WebSocket push Frontend: - Notification API client + Zustand store - WebSocket hook with auto-reconnect (exponential backoff) - Notification bell in header with unread count badge + dropdown - Notifications page with type badges, mark read, mark all read - WebSocket initialized in AppLayout for app-wide real-time updates - Enabled notifications nav in sidebar - English + Russian translations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
39
backend/alembic/versions/005_create_notifications.py
Normal file
39
backend/alembic/versions/005_create_notifications.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Create notifications table
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("body", sa.Text, nullable=False),
|
||||
sa.Column("type", sa.String(30), nullable=False, server_default="info"),
|
||||
sa.Column("channel", sa.String(20), nullable=False, server_default="in_app"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", JSONB, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("notifications")
|
||||
57
backend/app/api/v1/notifications.py
Normal file
57
backend/app/api/v1/notifications.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.notification import NotificationListResponse, NotificationResponse, UnreadCountResponse
|
||||
from app.services import notification_service
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("/", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=50, le=200),
|
||||
offset: int = Query(default=0),
|
||||
):
|
||||
notifications = await notification_service.get_user_notifications(db, user.id, status_filter, limit, offset)
|
||||
unread = await notification_service.get_unread_count(db, user.id)
|
||||
return NotificationListResponse(
|
||||
notifications=[NotificationResponse.model_validate(n) for n in notifications],
|
||||
unread_count=unread,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def unread_count(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
count = await notification_service.get_unread_count(db, user.id)
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationResponse)
|
||||
async def mark_read(
|
||||
notification_id: uuid.UUID,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
notif = await notification_service.mark_as_read(db, notification_id, user.id)
|
||||
return NotificationResponse.model_validate(notif)
|
||||
|
||||
|
||||
@router.post("/mark-all-read", status_code=status.HTTP_200_OK)
|
||||
async def mark_all_read(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
count = await notification_service.mark_all_read(db, user.id)
|
||||
return {"marked": count}
|
||||
@@ -7,6 +7,8 @@ from app.api.v1.skills import router as skills_router
|
||||
from app.api.v1.users import router as users_router
|
||||
from app.api.v1.documents import router as documents_router
|
||||
from app.api.v1.memory import router as memory_router
|
||||
from app.api.v1.notifications import router as notifications_router
|
||||
from app.api.v1.ws import router as ws_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -17,6 +19,8 @@ api_v1_router.include_router(skills_router)
|
||||
api_v1_router.include_router(users_router)
|
||||
api_v1_router.include_router(documents_router)
|
||||
api_v1_router.include_router(memory_router)
|
||||
api_v1_router.include_router(notifications_router)
|
||||
api_v1_router.include_router(ws_router)
|
||||
|
||||
|
||||
@api_v1_router.get("/health")
|
||||
|
||||
55
backend/app/api/v1/ws.py
Normal file
55
backend/app/api/v1/ws.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import decode_access_token
|
||||
from app.database import async_session_factory
|
||||
from app.models.user import User
|
||||
from app.services.ws_manager import manager
|
||||
from app.services.notification_service import get_unread_count
|
||||
|
||||
router = APIRouter(tags=["websocket"])
|
||||
|
||||
|
||||
async def _authenticate_ws(token: str) -> uuid.UUID | None:
|
||||
payload = decode_access_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
return None
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(User).where(User.id == uid, User.is_active == True)) # noqa: E712
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None
|
||||
return uid
|
||||
|
||||
|
||||
@router.websocket("/ws/notifications")
|
||||
async def ws_notifications(websocket: WebSocket, token: str = Query(...)):
|
||||
user_id = await _authenticate_ws(token)
|
||||
if not user_id:
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await manager.connect(user_id, websocket)
|
||||
|
||||
try:
|
||||
# Send initial unread count
|
||||
async with async_session_factory() as db:
|
||||
count = await get_unread_count(db, user_id)
|
||||
await websocket.send_json({"type": "unread_count", "count": count})
|
||||
|
||||
# Keep alive - wait for disconnect
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.disconnect(user_id, websocket)
|
||||
@@ -13,8 +13,14 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
from app.services.scheduler_service import start_scheduler, shutdown_scheduler
|
||||
start_scheduler()
|
||||
|
||||
yield
|
||||
|
||||
shutdown_scheduler()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
|
||||
@@ -6,5 +6,6 @@ from app.models.context_file import ContextFile
|
||||
from app.models.skill import Skill
|
||||
from app.models.document import Document
|
||||
from app.models.memory_entry import MemoryEntry
|
||||
from app.models.notification import Notification
|
||||
|
||||
__all__ = ["User", "Session", "Chat", "Message", "ContextFile", "Skill", "Document", "MemoryEntry"]
|
||||
__all__ = ["User", "Session", "Chat", "Message", "ContextFile", "Skill", "Document", "MemoryEntry", "Notification"]
|
||||
|
||||
27
backend/app/models/notification.py
Normal file
27
backend/app/models/notification.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(30), nullable=False, default="info")
|
||||
channel: Mapped[str] = mapped_column(String(20), nullable=False, default="in_app")
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="notifications") # noqa: F821
|
||||
@@ -29,3 +29,4 @@ class User(Base):
|
||||
skills: Mapped[list["Skill"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
|
||||
documents: Mapped[list["Document"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
|
||||
memory_entries: Mapped[list["MemoryEntry"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
|
||||
notifications: Mapped[list["Notification"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
|
||||
|
||||
30
backend/app/schemas/notification.py
Normal file
30
backend/app/schemas/notification.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
title: str
|
||||
body: str
|
||||
type: str
|
||||
channel: str
|
||||
status: str
|
||||
scheduled_at: datetime | None
|
||||
sent_at: datetime | None
|
||||
read_at: datetime | None
|
||||
metadata: dict | None = Field(None, alias="metadata_")
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True, "populate_by_name": True}
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
notifications: list[NotificationResponse]
|
||||
unread_count: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
@@ -14,6 +14,8 @@ from app.services.context_service import DEFAULT_SYSTEM_PROMPT, get_primary_cont
|
||||
from app.services.chat_service import get_chat, save_message
|
||||
from app.services.memory_service import get_critical_memories, create_memory, get_user_memories
|
||||
from app.services.document_service import search_documents
|
||||
from app.services.notification_service import create_notification
|
||||
from app.services.ws_manager import manager
|
||||
|
||||
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
|
||||
@@ -68,6 +70,28 @@ AI_TOOLS = [
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "schedule_notification",
|
||||
"description": "Schedule a notification or reminder for the user. Can be immediate or scheduled for a future time.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Notification title"},
|
||||
"body": {"type": "string", "description": "Notification body text"},
|
||||
"scheduled_at": {
|
||||
"type": "string",
|
||||
"description": "ISO 8601 datetime for scheduled delivery. Omit for immediate.",
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["reminder", "alert", "info"],
|
||||
"description": "Notification type",
|
||||
"default": "reminder",
|
||||
},
|
||||
},
|
||||
"required": ["title", "body"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -104,6 +128,40 @@ async def _execute_tool(
|
||||
items = [{"category": e.category, "title": e.title, "content": e.content, "importance": e.importance} for e in entries]
|
||||
return json.dumps({"entries": items, "count": len(items)})
|
||||
|
||||
elif tool_name == "schedule_notification":
|
||||
from datetime import datetime
|
||||
scheduled_at = None
|
||||
if tool_input.get("scheduled_at"):
|
||||
scheduled_at = datetime.fromisoformat(tool_input["scheduled_at"])
|
||||
|
||||
notif = await create_notification(
|
||||
db, user_id,
|
||||
title=tool_input["title"],
|
||||
body=tool_input["body"],
|
||||
type=tool_input.get("type", "reminder"),
|
||||
scheduled_at=scheduled_at,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Push immediately if not scheduled
|
||||
if not scheduled_at:
|
||||
await manager.send_to_user(user_id, {
|
||||
"type": "new_notification",
|
||||
"notification": {
|
||||
"id": str(notif.id),
|
||||
"title": notif.title,
|
||||
"body": notif.body,
|
||||
"type": notif.type,
|
||||
"created_at": notif.created_at.isoformat(),
|
||||
},
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"status": "scheduled" if scheduled_at else "sent",
|
||||
"id": str(notif.id),
|
||||
"title": notif.title,
|
||||
})
|
||||
|
||||
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
||||
|
||||
|
||||
|
||||
102
backend/app/services/notification_service.py
Normal file
102
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
title: str,
|
||||
body: str,
|
||||
type: str = "info",
|
||||
channel: str = "in_app",
|
||||
scheduled_at: datetime | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Notification:
|
||||
notif = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
body=body,
|
||||
type=type,
|
||||
channel=channel,
|
||||
status="pending" if scheduled_at else "sent",
|
||||
scheduled_at=scheduled_at,
|
||||
sent_at=None if scheduled_at else datetime.now(timezone.utc),
|
||||
metadata_=metadata,
|
||||
)
|
||||
db.add(notif)
|
||||
await db.flush()
|
||||
return notif
|
||||
|
||||
|
||||
async def get_user_notifications(
|
||||
db: AsyncSession, user_id: uuid.UUID,
|
||||
status_filter: str | None = None, limit: int = 50, offset: int = 0,
|
||||
) -> list[Notification]:
|
||||
stmt = select(Notification).where(Notification.user_id == user_id)
|
||||
if status_filter:
|
||||
stmt = stmt.where(Notification.status == status_filter)
|
||||
stmt = stmt.order_by(Notification.created_at.desc()).limit(limit).offset(offset)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_unread_count(db: AsyncSession, user_id: uuid.UUID) -> int:
|
||||
result = await db.scalar(
|
||||
select(func.count()).select_from(Notification).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.status.in_(["sent", "delivered"]),
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result or 0
|
||||
|
||||
|
||||
async def mark_as_read(db: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID) -> Notification:
|
||||
result = await db.execute(
|
||||
select(Notification).where(Notification.id == notification_id, Notification.user_id == user_id)
|
||||
)
|
||||
notif = result.scalar_one_or_none()
|
||||
if not notif:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
|
||||
notif.status = "read"
|
||||
notif.read_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return notif
|
||||
|
||||
|
||||
async def mark_all_read(db: AsyncSession, user_id: uuid.UUID) -> int:
|
||||
result = await db.execute(
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
.values(status="read", read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def get_pending_scheduled(db: AsyncSession) -> list[Notification]:
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
select(Notification).where(
|
||||
Notification.status == "pending",
|
||||
Notification.scheduled_at <= now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def mark_as_sent(db: AsyncSession, notification_id: uuid.UUID) -> None:
|
||||
result = await db.execute(select(Notification).where(Notification.id == notification_id))
|
||||
notif = result.scalar_one_or_none()
|
||||
if notif:
|
||||
notif.status = "sent"
|
||||
notif.sent_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
41
backend/app/services/scheduler_service.py
Normal file
41
backend/app/services/scheduler_service.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
if not scheduler.running:
|
||||
# Register periodic jobs
|
||||
from app.workers.notification_sender import send_pending_notifications
|
||||
scheduler.add_job(
|
||||
send_pending_notifications,
|
||||
trigger=CronTrigger(second="0", minute="*"), # every minute
|
||||
id="send_pending_notifications",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
from app.workers.health_review import run_daily_health_review
|
||||
scheduler.add_job(
|
||||
run_daily_health_review,
|
||||
trigger=CronTrigger(hour=8, minute=0),
|
||||
id="daily_health_review",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
|
||||
def shutdown_scheduler():
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def schedule_one_time(job_id: str, run_date, func, **kwargs):
|
||||
scheduler.add_job(func, trigger=DateTrigger(run_date=run_date), id=job_id, replace_existing=True, kwargs=kwargs)
|
||||
|
||||
|
||||
def schedule_recurring(job_id: str, cron_expr: str, func, **kwargs):
|
||||
trigger = CronTrigger.from_crontab(cron_expr)
|
||||
scheduler.add_job(func, trigger=trigger, id=job_id, replace_existing=True, kwargs=kwargs)
|
||||
37
backend/app/services/ws_manager.py
Normal file
37
backend/app/services/ws_manager.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: dict[uuid.UUID, list[WebSocket]] = {}
|
||||
|
||||
async def connect(self, user_id: uuid.UUID, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
if user_id not in self.active_connections:
|
||||
self.active_connections[user_id] = []
|
||||
self.active_connections[user_id].append(websocket)
|
||||
|
||||
def disconnect(self, user_id: uuid.UUID, websocket: WebSocket):
|
||||
if user_id in self.active_connections:
|
||||
self.active_connections[user_id] = [
|
||||
ws for ws in self.active_connections[user_id] if ws != websocket
|
||||
]
|
||||
if not self.active_connections[user_id]:
|
||||
del self.active_connections[user_id]
|
||||
|
||||
async def send_to_user(self, user_id: uuid.UUID, data: dict):
|
||||
connections = self.active_connections.get(user_id, [])
|
||||
dead = []
|
||||
for ws in connections:
|
||||
try:
|
||||
await ws.send_text(json.dumps(data))
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(user_id, ws)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
83
backend/app/workers/health_review.py
Normal file
83
backend/app/workers/health_review.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Daily job: proactive health review for all users with health data."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session_factory
|
||||
from app.models.user import User
|
||||
from app.services.memory_service import get_critical_memories
|
||||
from app.services.notification_service import create_notification
|
||||
from app.services.ws_manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
|
||||
|
||||
async def run_daily_health_review():
|
||||
"""Review each user's health profile and generate reminder notifications."""
|
||||
if not settings.ANTHROPIC_API_KEY:
|
||||
return
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
|
||||
users = result.scalars().all()
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await _review_user(user)
|
||||
await asyncio.sleep(1) # Rate limit
|
||||
except Exception:
|
||||
logger.exception(f"Health review failed for user {user.id}")
|
||||
|
||||
|
||||
async def _review_user(user: User):
|
||||
async with async_session_factory() as db:
|
||||
memories = await get_critical_memories(db, user.id)
|
||||
if not memories:
|
||||
return
|
||||
|
||||
memory_text = "\n".join(f"- [{m.category}] {m.title}: {m.content}" for m in memories)
|
||||
|
||||
response = await client.messages.create(
|
||||
model=settings.CLAUDE_MODEL,
|
||||
max_tokens=500,
|
||||
system="You are a health assistant. Based on the user's health profile, suggest any upcoming checkups, medication reviews, or health actions that should be reminded. Respond with a JSON array of objects with 'title' and 'body' fields. If no reminders are needed, return an empty array [].",
|
||||
messages=[{"role": "user", "content": f"User health profile:\n{memory_text}"}],
|
||||
)
|
||||
|
||||
import json
|
||||
try:
|
||||
text = response.content[0].text.strip()
|
||||
# Extract JSON from response
|
||||
if "[" in text:
|
||||
json_str = text[text.index("["):text.rindex("]") + 1]
|
||||
reminders = json.loads(json_str)
|
||||
else:
|
||||
return
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
|
||||
for reminder in reminders[:5]: # Max 5 reminders per user per day
|
||||
if "title" in reminder and "body" in reminder:
|
||||
notif = await create_notification(
|
||||
db,
|
||||
user.id,
|
||||
title=reminder["title"],
|
||||
body=reminder["body"],
|
||||
type="ai_generated",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await manager.send_to_user(user.id, {
|
||||
"type": "new_notification",
|
||||
"notification": {
|
||||
"id": str(notif.id),
|
||||
"title": notif.title,
|
||||
"body": notif.body,
|
||||
"type": notif.type,
|
||||
"created_at": notif.created_at.isoformat(),
|
||||
},
|
||||
})
|
||||
24
backend/app/workers/notification_sender.py
Normal file
24
backend/app/workers/notification_sender.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Periodic job: send pending scheduled notifications via WebSocket."""
|
||||
from app.database import async_session_factory
|
||||
from app.services.notification_service import get_pending_scheduled, mark_as_sent
|
||||
from app.services.ws_manager import manager
|
||||
|
||||
|
||||
async def send_pending_notifications():
|
||||
async with async_session_factory() as db:
|
||||
pending = await get_pending_scheduled(db)
|
||||
for notif in pending:
|
||||
await mark_as_sent(db, notif.id)
|
||||
await db.commit()
|
||||
|
||||
# Push via WebSocket
|
||||
await manager.send_to_user(notif.user_id, {
|
||||
"type": "new_notification",
|
||||
"notification": {
|
||||
"id": str(notif.id),
|
||||
"title": notif.title,
|
||||
"body": notif.body,
|
||||
"type": notif.type,
|
||||
"created_at": notif.created_at.isoformat(),
|
||||
},
|
||||
})
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"anthropic>=0.40.0",
|
||||
"pymupdf>=1.24.0",
|
||||
"aiofiles>=24.0.0",
|
||||
"apscheduler>=3.10.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
46
backend/tests/test_notifications.py
Normal file
46
backend/tests/test_notifications.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_headers(client: AsyncClient):
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "notifuser@example.com",
|
||||
"username": "notifuser",
|
||||
"password": "testpass123",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
async def test_list_notifications_empty(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/v1/notifications/", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["notifications"] == []
|
||||
assert data["unread_count"] == 0
|
||||
|
||||
|
||||
async def test_unread_count(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/v1/notifications/unread-count", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["count"] == 0
|
||||
|
||||
|
||||
async def test_mark_all_read(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.post("/api/v1/notifications/mark-all-read", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["marked"] == 0
|
||||
|
||||
|
||||
async def test_mark_nonexistent_read(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.patch(
|
||||
"/api/v1/notifications/00000000-0000-0000-0000-000000000000/read",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_unauthenticated(client: AsyncClient):
|
||||
resp = await client.get("/api/v1/notifications/")
|
||||
assert resp.status_code == 401
|
||||
Reference in New Issue
Block a user