From 70c65eeafc03622375272a605021c07ea5a38d3c Mon Sep 17 00:00:00 2001 From: noahpro Date: Fri, 28 Jun 2024 21:07:42 -0400 Subject: [PATCH 1/3] optional user --- backend/src/routers/books.py | 5 ++++- frontend/src/api/models/Interaction.ts | 2 ++ frontend/src/api/services/BooksService.ts | 3 +++ frontend/src/pages/HomePage.tsx | 15 ++++++++++++--- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/src/routers/books.py b/backend/src/routers/books.py index 86299fe8..b0899b84 100644 --- a/backend/src/routers/books.py +++ b/backend/src/routers/books.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from prisma import Json from pydantic import BaseModel -from src.auth import get_user +from src.auth import get_user, get_user_from_api_key from src.db import db from prisma.models import Book, User from typing import Annotated, Optional, List @@ -19,6 +19,7 @@ async def search_books( owner_id: Optional[int] = None, published: Optional[bool] = None, query: Optional[str] = None, + user_token: Optional[str] = None, ) -> List[Book]: where: BookWhereInput = {} if category: @@ -34,6 +35,8 @@ async def search_books( where=where, order={"category": "asc"}, ) + if user_token: + user = await get_user_from_api_key(user_token) if query: filtered_and_sorted_books = [ diff --git a/frontend/src/api/models/Interaction.ts b/frontend/src/api/models/Interaction.ts index e385d31d..97b04ce5 100644 --- a/frontend/src/api/models/Interaction.ts +++ b/frontend/src/api/models/Interaction.ts @@ -17,6 +17,8 @@ export type Interaction = { correct?: boolean | null; date: string; timeSinceLoad: number; + bookId?: number | null; + pageId?: number | null; question?: Question | null; questionId?: number | null; }; diff --git a/frontend/src/api/services/BooksService.ts b/frontend/src/api/services/BooksService.ts index f9287ac0..166c1013 100644 --- a/frontend/src/api/services/BooksService.ts +++ b/frontend/src/api/services/BooksService.ts @@ -16,6 +16,7 @@ export class BooksService { * @param ownerId * @param published * @param query + * @param userToken * @returns Book Successful Response * @throws ApiError */ @@ -25,6 +26,7 @@ export class BooksService { ownerId?: number | null, published?: boolean | null, query?: string | null, + userToken?: string | null, ): CancelablePromise> { return __request(OpenAPI, { method: "GET", @@ -35,6 +37,7 @@ export class BooksService { owner_id: ownerId, published: published, query: query, + user_token: userToken, }, errors: { 422: `Validation Error`, diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index c382e2f9..ff7e287f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -5,8 +5,10 @@ import Navbar from "../components/Navbar"; import useSound from "use-sound"; import { Book, BookCategory, BooksService } from "../api"; import ActivityBookList from "../components/ActivityBookList"; +import { useAuth } from "../context/AuthContext"; export default function HomePage() { + const { user } = useAuth(); const [playSound] = useSound("/sounds/low-click.mp3", { volume: 0.5 }); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); @@ -23,7 +25,14 @@ export default function HomePage() { const loadBookResults = useCallback( (category: BookCategory | null, query: string | null) => { setLoading(true); - BooksService.searchBooksBooksGet(category, null, null, true, query) + BooksService.searchBooksBooksGet( + category, + null, + null, + true, + query, + user?.token, + ) .then((response) => { setResults(response); setLoading(false); @@ -33,12 +42,12 @@ export default function HomePage() { setLoading(false); }); }, - [setResults, setLoading], + [setResults, setLoading, user], ); useEffect(() => { loadBookResults(null, null); - }, [loadBookResults]); + }, [loadBookResults, user]); return ( <> From 288f5b9f67b19e9651b851378a4e9f0a593a2ffa Mon Sep 17 00:00:00 2001 From: Asfandiyar Khan Date: Wed, 3 Jul 2024 19:20:46 -0400 Subject: [PATCH 2/3] Q-learning implementation for book recommendations. --- backend/prisma/schema.prisma | 15 ++++- backend/src/routers/books.py | 34 +++++++---- backend/src/routers/interactions.py | 73 ++++++++++++++++++++---- frontend/src/api/core/OpenAPI.ts | 2 +- frontend/src/api/index.ts | 1 + frontend/src/api/models/Book.ts | 2 + frontend/src/api/models/User.ts | 2 + frontend/src/api/models/UserBookScore.ts | 17 ++++++ 8 files changed, 123 insertions(+), 23 deletions(-) create mode 100644 frontend/src/api/models/UserBookScore.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 035f56d3..e3fc1ccb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,7 +11,7 @@ datasource db { model Image { id Int @id @default(autoincrement()) - name String + name String image_url String } @@ -26,6 +26,7 @@ model User { courses Course[] CourseStudent CourseStudent[] books Book[] + bookScores UserBookScore[] //Added new relation } enum AccountType { @@ -89,6 +90,7 @@ model Book { courses BookCourse[] owner User @relation(fields: [ownerId], references: [id]) ownerId Int @default(1) + bookScores UserBookScore[] //Added new relation } model Page { @@ -129,3 +131,14 @@ model BookCourse { course Course @relation(fields: [courseId], references: [id]) courseId Int } + +model UserBookScore { + id Int @id @default(autoincrement()) + userId Int + bookId Int + score Float + user User @relation(fields: [userId], references: [id]) + book Book @relation(fields: [bookId], references: [id]) + + @@map("user_book_scores") +} diff --git a/backend/src/routers/books.py b/backend/src/routers/books.py index b0899b84..18a11b82 100644 --- a/backend/src/routers/books.py +++ b/backend/src/routers/books.py @@ -4,7 +4,7 @@ from pydantic import BaseModel from src.auth import get_user, get_user_from_api_key from src.db import db -from prisma.models import Book, User +from prisma.models import Book, User, UserBookScore from typing import Annotated, Optional, List from prisma.enums import BookCategory, AccountType from prisma.types import BookWhereInput, BookUpdateInput @@ -29,15 +29,27 @@ async def search_books( if published is not None: where["published"] = published +# Fetch from db books = await db.book.find_many( take=limit, include={"courses": True, "pages": True}, where=where, order={"category": "asc"}, ) + user = None if user_token: user = await get_user_from_api_key(user_token) - + + # ---------------------------------------------- + user_scores = {} + if user: + # Fetches user's book scores if user is logged in + scores = await db.userbookscore.find_many( + where={"userId": user.id} + ) + # Creates a dictionary of book IDs to scores for the user + user_scores = {score.bookId: score.score for score in scores} + # ---------------------------------------------- if query: filtered_and_sorted_books = [ book @@ -62,18 +74,23 @@ async def search_books( re.IGNORECASE, ) ), + user_scores.get(book.id, 0) # Added user score as a secondary sort key ) for book in books ], - key=lambda x: x[1], + key=lambda x: (x[1], x[2]), # Sorts by query match count first, then by the user score reverse=True, ) if _ > 0 ] return filtered_and_sorted_books - - return books - + else: + sorted_books = sorted( + books, + key=lambda book: user_scores.get(book.id, 0), # Sorts by user score + reverse=True + ) + return sorted_books @books_router.get("/books/{book_id}", tags=["books"]) async def get_book(book_id: int) -> Book: @@ -82,7 +99,6 @@ async def get_book(book_id: int) -> Book: raise HTTPException(status_code=404, detail="Book not found") return book - class CreateBookRequest(BaseModel): title: str category: BookCategory @@ -93,7 +109,6 @@ class CreateBookRequest(BaseModel): blurb: Optional[str] = None readyForPublish: Optional[bool] = False - @books_router.post("/books", tags=["books"]) async def create_book( req: CreateBookRequest, @@ -121,7 +136,6 @@ async def create_book( ) return book - @books_router.put("/books/{book_id}", tags=["books"]) async def edit_book( book_id: int, @@ -156,4 +170,4 @@ async def edit_book( ) if not book: raise HTTPException(status_code=404, detail="Book not found") - return book + return book \ No newline at end of file diff --git a/backend/src/routers/interactions.py b/backend/src/routers/interactions.py index 08f4c7a3..606b777e 100644 --- a/backend/src/routers/interactions.py +++ b/backend/src/routers/interactions.py @@ -1,25 +1,19 @@ +from prisma import Prisma from datetime import datetime from typing import Annotated, Optional, List -from fastapi import APIRouter +from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends from src.db import db -from prisma.models import CourseStudent, BookCourse +from prisma.models import CourseStudent, BookCourse, UserBookScore from pydantic import BaseModel -from typing import Annotated, Optional from fastapi import APIRouter, Depends -from fastapi import HTTPException -from src.db import db from prisma.models import User, CourseStudent, BookCourse, Course, Interaction - - from prisma.enums import AccountType, InteractionType from src.auth import get_user -from pydantic import BaseModel from prisma.types import InteractionCreateInput - +import numpy as np interactions_router = APIRouter() - class InteractionCreateRequest(BaseModel): interaction_type: InteractionType time_since_load: int @@ -30,14 +24,70 @@ class InteractionCreateRequest(BaseModel): bookId: Optional[int] = None pageId: Optional[int] = None - class InteractionCreateResponse(BaseModel): id: int +alpha = 0.1 +gamma = 0.6 +epsilon = 0.1 +Q = {} + +def get_state(interaction_data: InteractionCreateRequest): + return (interaction_data.user_id, interaction_data.bookId, interaction_data.pageId) + +def get_action(): + if np.random.uniform(0, 1) < epsilon: + return "explore" + else: + return "exploit" + +def update_q_table(state, action, reward, next_state): + if state not in Q: + Q[state] = {} + if action not in Q[state]: + Q[state][action] = 0 + + max_future_q = max(Q[next_state].values()) if next_state in Q else 0 + current_q = Q[state][action] + + Q[state][action] = current_q + alpha * (reward + gamma * max_future_q - current_q) + +def calculate_score(interaction_data: InteractionCreateRequest) -> float: + state = get_state(interaction_data) + action = get_action() + reward = 10.0 if interaction_data.correct else -5.0 + + next_state = get_state(interaction_data) #Placeholder for now, define accordingly!!! + + update_q_table(state, action, reward, next_state) + + return reward + +async def run_ml_inference(interaction_data: InteractionCreateRequest): + score = calculate_score(interaction_data) + + existing_score = await db.userbookscore.find_first( + where={"userId": interaction_data.user_id, "bookId": interaction_data.bookId} + ) + if existing_score: + new_score = existing_score.score + score + await db.userbookscore.update( + where={"id": existing_score.id}, + data={"score": new_score} + ) + else: + await db.userbookscore.create( + data={ + "userId": interaction_data.user_id, + "bookId": interaction_data.bookId, + "score": score, + }, + ) @interactions_router.post("/interactions", tags=["interactions"]) async def create_interaction( interaction_data: InteractionCreateRequest, + background_tasks: BackgroundTasks ) -> InteractionCreateResponse: try: interaction = await db.interaction.create( @@ -53,6 +103,7 @@ async def create_interaction( "pageId": interaction_data.pageId, } ) + background_tasks.add_task(run_ml_inference, interaction_data) except Exception as e: print(e) raise HTTPException(status_code=400, detail="Unable to create interaction") diff --git a/frontend/src/api/core/OpenAPI.ts b/frontend/src/api/core/OpenAPI.ts index 378b5291..bfcc1746 100644 --- a/frontend/src/api/core/OpenAPI.ts +++ b/frontend/src/api/core/OpenAPI.ts @@ -20,7 +20,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: "https://codekids-backend.endeavour.cs.vt.edu", + BASE: "http://localhost:8080", VERSION: "0.1.0", WITH_CREDENTIALS: false, CREDENTIALS: "include", diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 527f5027..5bbda947 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -29,6 +29,7 @@ export type { SignupRequest } from "./models/SignupRequest"; export type { UpdatePage } from "./models/UpdatePage"; export type { UpdateUserRequest } from "./models/UpdateUserRequest"; export type { User } from "./models/User"; +export type { UserBookScore } from "./models/UserBookScore"; export type { UserLightNoPassword } from "./models/UserLightNoPassword"; export type { ValidationError } from "./models/ValidationError"; diff --git a/frontend/src/api/models/Book.ts b/frontend/src/api/models/Book.ts index 03b27f0d..898a44d2 100644 --- a/frontend/src/api/models/Book.ts +++ b/frontend/src/api/models/Book.ts @@ -6,6 +6,7 @@ import type { BookCategory } from "./BookCategory"; import type { BookCourse } from "./BookCourse"; import type { Page } from "./Page"; import type { User } from "./User"; +import type { UserBookScore } from "./UserBookScore"; /** * Represents a Book record */ @@ -24,4 +25,5 @@ export type Book = { courses?: Array | null; owner?: User | null; ownerId: number; + bookScores?: Array | null; }; diff --git a/frontend/src/api/models/User.ts b/frontend/src/api/models/User.ts index 12409ec0..67a7575c 100644 --- a/frontend/src/api/models/User.ts +++ b/frontend/src/api/models/User.ts @@ -7,6 +7,7 @@ import type { Book } from "./Book"; import type { Course } from "./Course"; import type { CourseStudent } from "./CourseStudent"; import type { Interaction } from "./Interaction"; +import type { UserBookScore } from "./UserBookScore"; /** * Represents a User record */ @@ -21,4 +22,5 @@ export type User = { courses?: Array | null; CourseStudent?: Array | null; books?: Array | null; + bookScores?: Array | null; }; diff --git a/frontend/src/api/models/UserBookScore.ts b/frontend/src/api/models/UserBookScore.ts new file mode 100644 index 00000000..a78ced67 --- /dev/null +++ b/frontend/src/api/models/UserBookScore.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Book } from "./Book"; +import type { User } from "./User"; +/** + * Represents a UserBookScore record + */ +export type UserBookScore = { + id: number; + userId: number; + bookId: number; + score: number; + user?: User | null; + book?: Book | null; +}; From 5765a46a2e657dfe6003f77ffdf3d6cb7a88acbd Mon Sep 17 00:00:00 2001 From: Asfandiyar Khan Date: Thu, 4 Jul 2024 18:06:42 -0400 Subject: [PATCH 3/3] Q-learning implementation for book recommendations. --- backend/src/routers/books.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/src/routers/books.py b/backend/src/routers/books.py index 18a11b82..f1bb5446 100644 --- a/backend/src/routers/books.py +++ b/backend/src/routers/books.py @@ -29,7 +29,7 @@ async def search_books( if published is not None: where["published"] = published -# Fetch from db + # Fetch from db books = await db.book.find_many( take=limit, include={"courses": True, "pages": True}, @@ -40,7 +40,6 @@ async def search_books( if user_token: user = await get_user_from_api_key(user_token) - # ---------------------------------------------- user_scores = {} if user: # Fetches user's book scores if user is logged in @@ -49,7 +48,7 @@ async def search_books( ) # Creates a dictionary of book IDs to scores for the user user_scores = {score.bookId: score.score for score in scores} - # ---------------------------------------------- + if query: filtered_and_sorted_books = [ book