diff --git a/MAIA_ICON.jpeg b/MAIA_ICON.jpeg new file mode 100644 index 0000000..9016bc0 Binary files /dev/null and b/MAIA_ICON.jpeg differ diff --git a/MAIA_ICON2.jpeg b/MAIA_ICON2.jpeg new file mode 100644 index 0000000..2383cb9 Binary files /dev/null and b/MAIA_ICON2.jpeg differ diff --git a/backend/.env b/backend/.env index 0726ac7..d0c81d1 100644 --- a/backend/.env +++ b/backend/.env @@ -1,2 +1,3 @@ PEPPER = "LsD7%" -JWT_SECRET_KEY="1c8cf3ca6972b365f8108dad247e61abdcb6faff5a6c8ba00cb6fa17396702bf" \ No newline at end of file +JWT_SECRET_KEY="1c8cf3ca6972b365f8108dad247e61abdcb6faff5a6c8ba00cb6fa17396702bf" +GOOGLE_API_KEY="AIzaSyBrte_mETZJce8qE6cRTSz_fHOjdjlShBk" \ No newline at end of file diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc index bbfcf28..3000c46 100644 Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/backend/core/__pycache__/config.cpython-312.pyc b/backend/core/__pycache__/config.cpython-312.pyc index ccff7aa..72ecfb1 100644 Binary files a/backend/core/__pycache__/config.cpython-312.pyc and b/backend/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/core/config.py b/backend/core/config.py index b37efe8..392a083 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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() diff --git a/backend/main.py b/backend/main.py index cef61bd..6bae420 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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)]) \ No newline at end of file +# Include module router +app.include_router(router) \ No newline at end of file diff --git a/backend/modules/__init__.py b/backend/modules/__init__.py index e69de29..154386d 100644 --- a/backend/modules/__init__.py +++ b/backend/modules/__init__.py @@ -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) diff --git a/backend/modules/__pycache__/__init__.cpython-312.pyc b/backend/modules/__pycache__/__init__.cpython-312.pyc index 7ed90b2..8e2100d 100644 Binary files a/backend/modules/__pycache__/__init__.cpython-312.pyc and b/backend/modules/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/modules/admin/__pycache__/api.cpython-312.pyc b/backend/modules/admin/__pycache__/api.cpython-312.pyc index 171f615..3090f20 100644 Binary files a/backend/modules/admin/__pycache__/api.cpython-312.pyc and b/backend/modules/admin/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/admin/api.py b/backend/modules/admin/api.py index 78f2635..56f7761 100644 --- a/backend/modules/admin/api.py +++ b/backend/modules/admin/api.py @@ -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(): diff --git a/backend/modules/auth/__pycache__/api.cpython-312.pyc b/backend/modules/auth/__pycache__/api.cpython-312.pyc index c55ab03..e50b601 100644 Binary files a/backend/modules/auth/__pycache__/api.cpython-312.pyc and b/backend/modules/auth/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/auth/__pycache__/dependencies.cpython-312.pyc b/backend/modules/auth/__pycache__/dependencies.cpython-312.pyc index 9cbe258..b6e718c 100644 Binary files a/backend/modules/auth/__pycache__/dependencies.cpython-312.pyc and b/backend/modules/auth/__pycache__/dependencies.cpython-312.pyc differ diff --git a/backend/modules/auth/__pycache__/models.cpython-312.pyc b/backend/modules/auth/__pycache__/models.cpython-312.pyc index 3ae7a10..fe97777 100644 Binary files a/backend/modules/auth/__pycache__/models.cpython-312.pyc and b/backend/modules/auth/__pycache__/models.cpython-312.pyc differ diff --git a/backend/modules/auth/api.py b/backend/modules/auth/api.py index 9a37b00..d7eea09 100644 --- a/backend/modules/auth/api.py +++ b/backend/modules/auth/api.py @@ -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)]): diff --git a/backend/modules/auth/dependencies.py b/backend/modules/auth/dependencies.py index 8b0dfb3..ee42a7b 100644 --- a/backend/modules/auth/dependencies.py +++ b/backend/modules/auth/dependencies.py @@ -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]) diff --git a/backend/modules/auth/models.py b/backend/modules/auth/models.py index 42b2b4f..e650234 100644 --- a/backend/modules/auth/models.py +++ b/backend/modules/auth/models.py @@ -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" diff --git a/backend/modules/calendar/__pycache__/api.cpython-312.pyc b/backend/modules/calendar/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..bc3f6ad Binary files /dev/null and b/backend/modules/calendar/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/models.cpython-312.pyc b/backend/modules/calendar/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..aa9c860 Binary files /dev/null and b/backend/modules/calendar/__pycache__/models.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..694dcc2 Binary files /dev/null and b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/service.cpython-312.pyc b/backend/modules/calendar/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000..8088b0d Binary files /dev/null and b/backend/modules/calendar/__pycache__/service.cpython-312.pyc differ diff --git a/backend/modules/calendar/api.py b/backend/modules/calendar/api.py new file mode 100644 index 0000000..a754225 --- /dev/null +++ b/backend/modules/calendar/api.py @@ -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"} \ No newline at end of file diff --git a/backend/modules/calendar/models.py b/backend/modules/calendar/models.py new file mode 100644 index 0000000..ae27719 --- /dev/null +++ b/backend/modules/calendar/models.py @@ -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") \ No newline at end of file diff --git a/backend/modules/calendar/schemas.py b/backend/modules/calendar/schemas.py new file mode 100644 index 0000000..00b6755 --- /dev/null +++ b/backend/modules/calendar/schemas.py @@ -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 \ No newline at end of file diff --git a/backend/modules/calendar/service.py b/backend/modules/calendar/service.py new file mode 100644 index 0000000..0a9830f --- /dev/null +++ b/backend/modules/calendar/service.py @@ -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() \ No newline at end of file diff --git a/backend/modules/nlp/__pycache__/api.cpython-312.pyc b/backend/modules/nlp/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000..bfb7551 Binary files /dev/null and b/backend/modules/nlp/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/nlp/__pycache__/service.cpython-312.pyc b/backend/modules/nlp/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000..fa577a7 Binary files /dev/null and b/backend/modules/nlp/__pycache__/service.cpython-312.pyc differ diff --git a/backend/modules/nlp/api.py b/backend/modules/nlp/api.py new file mode 100644 index 0000000..b495008 --- /dev/null +++ b/backend/modules/nlp/api.py @@ -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") \ No newline at end of file diff --git a/backend/modules/nlp/service.py b/backend/modules/nlp/service.py new file mode 100644 index 0000000..24c825d --- /dev/null +++ b/backend/modules/nlp/service.py @@ -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 \ No newline at end of file diff --git a/backend/modules/user/__pycache__/api.cpython-312.pyc b/backend/modules/user/__pycache__/api.cpython-312.pyc index 102b33b..73019e3 100644 Binary files a/backend/modules/user/__pycache__/api.cpython-312.pyc and b/backend/modules/user/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/user/api.py b/backend/modules/user/api.py index c4ecf47..964b891 100644 --- a/backend/modules/user/api.py +++ b/backend/modules/user/api.py @@ -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: diff --git a/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc index d5548a8..9470be8 100644 Binary files a/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc and b/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000..c04a8ee Binary files /dev/null and b/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/helpers/__pycache__/generators.cpython-312.pyc b/backend/tests/helpers/__pycache__/generators.cpython-312.pyc index b572c20..636ef09 100644 Binary files a/backend/tests/helpers/__pycache__/generators.cpython-312.pyc and b/backend/tests/helpers/__pycache__/generators.cpython-312.pyc differ diff --git a/backend/tests/helpers/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc b/backend/tests/helpers/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000..f71f8e1 Binary files /dev/null and b/backend/tests/helpers/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/test_calendar.py b/backend/tests/test_calendar.py new file mode 100644 index 0000000..caba7ab --- /dev/null +++ b/backend/tests/test_calendar.py @@ -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 \ No newline at end of file