Calendar + NLP modules implemented

This commit is contained in:
c-d-p
2025-04-17 11:25:21 +02:00
parent 18ddb2f332
commit 4f3946d1c3
36 changed files with 366 additions and 23 deletions

BIN
MAIA_ICON.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
MAIA_ICON2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,2 +1,3 @@
PEPPER = "LsD7%"
JWT_SECRET_KEY="1c8cf3ca6972b365f8108dad247e61abdcb6faff5a6c8ba00cb6fa17396702bf"
GOOGLE_API_KEY="AIzaSyBrte_mETZJce8qE6cRTSz_fHOjdjlShBk"

View File

@@ -18,4 +18,6 @@ class Settings(BaseSettings):
PEPPER: str = getenv("PEPPER", "")
JWT_SECRET_KEY: str = getenv("JWT_SECRET_KEY", "")
GOOGLE_API_KEY: str = getenv("GOOGLE_API_KEY", "")
settings = Settings()

View File

@@ -1,25 +1,26 @@
# main.py
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
from typing import Any, Callable
from fastapi import FastAPI, Depends
from core.database import get_engine, Base
from modules.auth.api import router as auth_router
from modules.user.api import router as user_router
from modules.admin.api import router as admin_router
from modules.auth.dependencies import admin_only
from modules import router
import logging
from modules.auth.security import get_current_user
logging.getLogger('passlib').setLevel(logging.ERROR) # fix bc package logging is broken
# Create DB tables (remove in production; use migrations instead)
def lifespan(app):
# Base.metadata.drop_all(bind=get_engine())
Base.metadata.create_all(bind=get_engine())
yield
def lifespan_factory() -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]]:
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.drop_all(bind=get_engine())
Base.metadata.create_all(bind=get_engine())
yield
return lifespan
lifespan = lifespan_factory()
app = FastAPI(lifespan=lifespan)
# Include all module routers
app.include_router(auth_router, prefix="/api/auth")
app.include_router(user_router, prefix="/api/user")
app.include_router(admin_router, prefix="/api/admin", dependencies=[Depends(admin_only)])
# Include module router
app.include_router(router)

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from .admin.api import router as admin_router
from .auth.api import router as auth_router
from .user.api import router as user_router
from .calendar.api import router as calendar_router
from .nlp.api import router as nlp_router
router = APIRouter(prefix="/api")
router.include_router(admin_router)
router.include_router(auth_router)
router.include_router(user_router)
router.include_router(calendar_router)
router.include_router(nlp_router)

View File

@@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from core.database import Base, get_db
from modules.auth.models import User, UserRole
from modules.auth.dependencies import admin_only
router = APIRouter()
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(admin_only)])
@router.get("/")
def read_admin():

View File

@@ -13,7 +13,7 @@ from datetime import timedelta
from core.config import settings # Assuming settings is defined in core.config
from core.exceptions import unauthorized_exception
router = APIRouter()
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register(user: UserCreate, db: Annotated[Session, Depends(get_db)]):

View File

@@ -11,7 +11,7 @@ class RoleChecker:
def __call__(self, user: User = Depends(get_current_user)):
if user.role not in self.allowed_roles:
forbidden_exception("You do not have permission to perform this action.")
raise forbidden_exception("You do not have permission to perform this action.")
return user
admin_only = RoleChecker([UserRole.ADMIN])

View File

@@ -1,6 +1,7 @@
# modules/auth/models.py
from core.database import Base
from sqlalchemy import CheckConstraint, Column, Integer, String, Enum, DateTime
from sqlalchemy import Column, Integer, String, Enum, DateTime
from sqlalchemy.orm import relationship
from enum import Enum as PyEnum
class UserRole(str, PyEnum):
@@ -12,10 +13,11 @@ class User(Base):
id = Column(Integer, primary_key=True)
uuid = Column(String, unique=True)
username = Column(String, unique=True)
hashed_password = Column(String)
role = Column(Enum(UserRole), nullable=False, default=UserRole.USER)
name = Column(String)
role = Column(Enum(UserRole), nullable=False, default=UserRole.USER)
hashed_password = Column(String)
calendar_events = relationship("CalendarEvent", back_populates="user")
class TokenBlacklist(Base):
__tablename__ = "token_blacklist"

View File

@@ -0,0 +1,46 @@
# modules/calendar/api.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from datetime import datetime
from modules.auth.dependencies import get_current_user
from core.database import get_db
from modules.auth.models import User
from modules.calendar.schemas import CalendarEventCreate, CalendarEventResponse
from modules.calendar.service import create_calendar_event, get_calendar_events, update_calendar_event, delete_calendar_event
router = APIRouter(prefix="/calendar", tags=["calendar"])
@router.post("/events", response_model=CalendarEventResponse)
def create_event(
event: CalendarEventCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return create_calendar_event(db, user.id, event)
@router.get("/events", response_model=list[CalendarEventResponse])
def get_events(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
start: datetime | None = None,
end: datetime | None = None
):
return get_calendar_events(db, user.id, start, end)
@router.put("/events/{event_id}", response_model=CalendarEventResponse)
def update_event(
event_id: int,
event: CalendarEventCreate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return update_calendar_event(db, user.id, event_id, event)
@router.delete("/events/{event_id}")
def delete_event(
event_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
delete_calendar_event(db, user.id, event_id)
return {"message": "Event deleted"}

View File

@@ -0,0 +1,18 @@
# modules/calendar/models.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from core.database import Base
class CalendarEvent(Base):
__tablename__ = "calendar_events"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
description = Column(String)
start = Column(DateTime, nullable=False)
end = Column(DateTime)
location = Column(String)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # <-- Relationship
# Bi-directional relationship (for eager loading)
user = relationship("User", back_populates="calendar_events")

View File

@@ -0,0 +1,17 @@
# modules/calendar/schemas.py
from datetime import datetime
from pydantic import BaseModel
class CalendarEventCreate(BaseModel):
title: str
description: str | None = None
start: datetime
end: datetime | None = None
location: str | None = None
class CalendarEventResponse(CalendarEventCreate):
id: int
user_id: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,45 @@
# modules/calendar/service.py
from sqlalchemy.orm import Session
from datetime import datetime
from modules.calendar.models import CalendarEvent
from core.exceptions import not_found_exception
def create_calendar_event(db: Session, user_id: int, event_data):
event = CalendarEvent(**event_data.dict(), user_id=user_id)
db.add(event)
db.commit()
db.refresh(event)
return event
def get_calendar_events(db: Session, user_id: int, start: datetime, end: datetime):
query = db.query(CalendarEvent).filter(
CalendarEvent.user_id == user_id
)
if start:
query = query.filter(CalendarEvent.start_time >= start)
if end:
query = query.filter(CalendarEvent.end_time <= end)
return query.all()
def update_calendar_event(db: Session, user_id: int, event_id: int, event_data):
event = db.query(CalendarEvent).filter(
CalendarEvent.id == event_id,
CalendarEvent.user_id == user_id
).first()
if not event:
raise not_found_exception()
for key, value in event_data.dict().items():
setattr(event, key, value)
db.commit()
db.refresh(event)
return event
def delete_calendar_event(db: Session, user_id: int, event_id: int):
event = db.query(CalendarEvent).filter(
CalendarEvent.id == event_id,
CalendarEvent.user_id == user_id
).first()
if not event:
raise not_found_exception()
db.delete(event)
db.commit()

Binary file not shown.

View File

@@ -0,0 +1,53 @@
# modules/nlp/api.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from core.database import get_db
from core.exceptions import bad_request_exception
from modules.auth.dependencies import get_current_user
from modules.auth.models import User
from modules.nlp.service import process_request, ask_ai
from modules.calendar.service import create_calendar_event, get_calendar_events, update_calendar_event, delete_calendar_event
from modules.calendar.schemas import CalendarEventCreate
router = APIRouter(prefix="/nlp", tags=["nlp"])
@router.post("/process-command")
def process_command(user_input: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Process the user command and return the appropriate action.
"""
command = process_request(user_input)
if "error" in command:
raise bad_request_exception(command["error"])
match command["intent"]:
case "ask_ai":
result = ask_ai(**command["params"])
return {"action": "ai_response", "details": result}
case "get_calendar_events":
result = get_calendar_events(db, current_user.id, **command["params"])
return {"action": "calendar_events_retrieved", "details": result}
case "add_calendar_event":
event = CalendarEventCreate(**command["params"])
result = create_calendar_event(db, current_user.id, event)
return {"action": "calendar_event_created", "details": result}
case "update_calendar_event":
event = CalendarEventCreate(**command["params"])
result = update_calendar_event(db, current_user.id, 0, event_data=event) ## PLACEHOLDER
return {"action": "calendar_event_updated", "details": result}
case "delete_calendar_event":
result = update_calendar_event(db, current_user.id, 0) ## PLACEHOLDER
return {"action": "calendar_event_deleted", "details": result}
case "unknown":
return {"action": "unknown_command", "details": command["params"]}
case _:
raise bad_request_exception(400, detail="Unrecognized command")

View File

@@ -0,0 +1,100 @@
# modules/nlp/service.py
from google import genai
import json
from datetime import datetime, timezone
# from core.config import settings
# client = genai.Client(api_key=settings.GOOGLE_API_KEY)
client = genai.Client(api_key="AIzaSyBrte_mETZJce8qE6cRTSz_fHOjdjlShBk")
### Base prompt for MAIA, used for inital user requests
SYSTEM_PROMPT = """
You are MAIA - My AI Assistant. Your job is to parse user requests into structured JSON commands.
Available functions:
1. ask_ai(request: str). If the intent of the request is a simple question (e.x. What is the weather like today?), you should call this function, and forward the user's request as the parameter.
2. get_calendar_events(start: Optional[datetime], end: Optional[datetime])
3. add_calendar_event(title: str, description: str, start: datetime, end: Optional[datetime], location: str)
4. update_calendar_event(event_id: int, title: Optional[str], description: Optional[str], start: Optional[datetime], end: Optional[datetime], location: Optional[str])
5. delete_calendar_event(event_id: int)
Respond **ONLY** with JSON like this:
{
"intent": "add_calendar_event",
"params": {
"title": "Team Meeting",
"description": "Discuss project updates",
"start": "2025-04-16 15:00:00.000000+00:00",
"end": "2025-04-16 16:00:00.000000+00:00",
"location": "Office"
}
}
The datetime right now is """+str(datetime.now(timezone.utc))+""".
"""
### Prompt for MAIA to forward user request to AI
SYSTEM_FORWARD_PROMPT = f"""
You are MAIA - My AI Assistant. Your job is to answer user simple user requests.
Here is some context for you:
- The datetime right now is {str(datetime.now(timezone.utc))}.
Here is the user request:
"""
def process_request(request: str):
"""
Process the user request using the Google GenAI API.
"""
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=SYSTEM_PROMPT + f"\n\nUser: {request}\nMAIA:",
config={
"temperature": 0.3, # Less creativity, more factual
"response_mime_type": "application/json",
# "response_schema": { ### NOT WORKING
# "type": "object",
# "properties": {
# "intent": {
# "type": "string",
# "enum": [
# "get_calendar_events",
# "add_calendar_event",
# "update_calendar_event",
# "delete_calendar_event"
# ]
# },
# "params": {
# "type": "object",
# "properties": {
# "title": {"type": "string"},
# "description": {"type": "string"},
# "start": {"type": "string", "format": "date-time"},
# "end": {"type": "string", "format": "date-time"},
# "location": {"type": "string"},
# "event_id": {"type": "integer"},
# },
# }
# },
# "required": ["intent", "params"]
# }
}
)
# Parse the JSON response
try:
return json.loads(response.text)
except ValueError:
raise ValueError("Invalid JSON response from AI")
def ask_ai(request: str):
"""
Ask the AI a question.
This is only called by MAIA when the intent is a simple question.
"""
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=SYSTEM_FORWARD_PROMPT+request,
)
return response.text

View File

@@ -9,7 +9,7 @@ from modules.auth.schemas import UserPatch, UserResponse
from modules.auth.dependencies import get_current_user
from modules.auth.models import User
router = APIRouter()
router = APIRouter(prefix="/user", tags=["user"])
@router.get("/me", response_model=UserResponse)
def me(db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)]) -> UserResponse:

View File

@@ -0,0 +1,44 @@
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from tests.helpers import generators
def test_create_event(client: TestClient, db: Session) -> None:
user, unhashed_password = generators.create_user(db)
rsp = generators.login(db, user.username, unhashed_password)
access_token = rsp["access_token"]
refresh_token = rsp["refresh_token"]
response = client.post("/api/calendar/events",
json={
"title": "Test Event",
"start_time": "2024-03-20T15:00:00Z"
},
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
)
assert response.status_code == 200
assert response.json()["title"] == "Test Event"
def test_get_events(client: TestClient, db: Session) -> None:
user, unhashed_password = generators.create_user(db)
rsp = generators.login(db, user.username, unhashed_password)
access_token = rsp["access_token"]
refresh_token = rsp["refresh_token"]
# Create an event to retrieve
client.post("/api/calendar/events",
json={
"title": "Test Event",
"start_time": "2024-03-20T15:00:00Z"
},
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
)
response = client.get("/api/calendar/events",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
)
assert response.status_code == 200
assert len(response.json()) > 0