diff --git a/requirements.txt b/requirements.txt index 454f63f..03d46d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ pydantic==2.7.1 python-dotenv==0.19.2 sqlalchemy==1.4.52 uvicorn==0.29.0 +openai==1.6.0 diff --git a/server/controllers/text_parser_controller.py b/server/controllers/text_parser_controller.py new file mode 100644 index 0000000..66953f1 --- /dev/null +++ b/server/controllers/text_parser_controller.py @@ -0,0 +1,15 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import HTTPException +from services.text_parser_service import TextParserService +from schemas.schemas import TextFileCreate + + +class TextParserController: + def __init__(self, db: AsyncSession): + self.service = TextParserService(db) + + async def parse_speech_text(self, text_file: TextFileCreate): + try: + return await self.service.parse_text_file(text_file) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/server/database/setup_db.py b/server/database/setup_db.py index 990848f..5c7cfa9 100644 --- a/server/database/setup_db.py +++ b/server/database/setup_db.py @@ -41,14 +41,14 @@ cursor.execute(''' CREATE TABLE IF NOT EXISTS games ( id TEXT PRIMARY KEY, - hometeam_id TEXT, - awayteam_id TEXT, + home_team_id TEXT, + away_team_id TEXT, home_team_score INTEGER, away_team_score INTEGER, updated_at TIMESTAMP, created_at TIMESTAMP, - FOREIGN KEY (hometeam_id) REFERENCES teams(id), - FOREIGN KEY (awayteam_id) REFERENCES teams(id) + FOREIGN KEY (home_team_id) REFERENCES teams(id), + FOREIGN KEY (away_team_id) REFERENCES teams(id) ) ''') print("Created games table") @@ -176,11 +176,13 @@ CREATE TABLE IF NOT EXISTS text_file ( id TEXT PRIMARY KEY, user_id TEXT, + game_id TEXT, audio_id TEXT, contents TEXT, created_at TIMESTAMP, file_path TEXT, FOREIGN KEY (audio_id) REFERENCES audio_file(id), + FOREIGN KEY (game_id) REFERENCES games(id), FOREIGN KEY (user_id) REFERENCES users(id) ) ''') diff --git a/server/models/models.py b/server/models/models.py index 2e78a02..6a3962e 100644 --- a/server/models/models.py +++ b/server/models/models.py @@ -56,6 +56,7 @@ class Game(Base): away_team = relationship("Team", foreign_keys=[away_team_id], back_populates="away_games") team_stats = relationship("TeamStats", back_populates="game") player_stats = relationship("PlayerStats", back_populates="game") + text_files = relationship("TextFile", back_populates="game") class Player(Base): @@ -160,7 +161,9 @@ class TextFile(Base): contents = Column(Text) created_at = Column(TIMESTAMP) file_path = Column(String) + game_id = Column(String, ForeignKey('games.id')) user_id = Column(String, ForeignKey('users.id')) + game = relationship("Game", back_populates="text_files") user = relationship("User", back_populates="text_files") audio_file = relationship("AudioFile", back_populates="text_files") diff --git a/server/routes/text_parser_router.py b/server/routes/text_parser_router.py new file mode 100644 index 0000000..d605147 --- /dev/null +++ b/server/routes/text_parser_router.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from database.config import get_db +from schemas.schemas import TextFileCreate + + +parser_router = APIRouter() + + +@parser_router.post("/speech_text_command/") +async def speech_text_command(text_file: TextFileCreate, db: AsyncSession = Depends(get_db)): + try: + from controllers.text_parser_controller import TextParserController + msg = await TextParserController(db).parse_speech_text(text_file) + except Exception as e: + msg = str(e) + return { + "message": msg, + } diff --git a/server/schemas/schemas.py b/server/schemas/schemas.py index f2d36fb..7429b4a 100644 --- a/server/schemas/schemas.py +++ b/server/schemas/schemas.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, EmailStr -from typing import List, Optional +from typing import Optional from datetime import datetime @@ -92,9 +92,9 @@ class TeamStatsBase(BaseModel): points: int rebounds: int assists: int - steal: int - block: int - turnover: int + steals: int + blocks: int + turnovers: int fouls: int fg_made: int fg_attempt: int @@ -162,9 +162,9 @@ class PlayerStatsBase(BaseModel): points: int rebounds: int assists: int - steal: int - block: int - turnover: int + steals: int + blocks: int + turnovers: int fouls: int fg_made: int fg_attempt: int @@ -211,6 +211,7 @@ class Config: class TextFileBase(BaseModel): id: str audio_id: str + game_id: str contents: str file_path: str diff --git a/server/server.py b/server/server.py index d0028de..9d52cdd 100644 --- a/server/server.py +++ b/server/server.py @@ -3,12 +3,14 @@ from middleware import cors_middleware from routes.game_router import game_router +from routes.text_parser_router import parser_router try: app = FastAPI() cors_middleware.add(app) app.include_router(game_router) + app.include_router(parser_router) uvicorn.run(app, port=9091) except KeyboardInterrupt as e: pass diff --git a/server/services/game_service.py b/server/services/game_service.py index 11265c5..5e508b3 100644 --- a/server/services/game_service.py +++ b/server/services/game_service.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Union, List from sqlalchemy.ext.asyncio import AsyncSession from models.models import Game, Player, PlayerStats, Team, TeamStats @@ -32,12 +32,20 @@ async def create_player(self, player_data: PlayerCreate) -> Player: new_player = await self.create(Player(**player_data.dict())) return new_player + async def fetch_game(self, game_id: str) -> Game: + return await self.db \ + .query(Game) \ + .filter(Game.game_id == game_id) \ + .scalars() \ + .first() + async def fetch_player_stats(self, player_id: str, game_id: str) -> PlayerStats: return await self.db \ .query(PlayerStats) \ .filter( PlayerStats.player_id == player_id, PlayerStats.game_id == game_id) \ + .scalars() \ .first() async def fetch_team_stats(self, team_id: str, game_id: str) -> TeamStats: @@ -46,8 +54,23 @@ async def fetch_team_stats(self, team_id: str, game_id: str) -> TeamStats: .filter( TeamStats.team_id == team_id, TeamStats.game_id == game_id) \ + .scalars() \ + .first() + + async def fetch_team(self, team_id: str) -> Team: + return await self.db \ + .query(Team) \ + .filter(Team.id == team_id) \ + .scalars() \ .first() + async def fetch_players(self, team_id: str) -> List[Player]: + return await self.db \ + .query(Player) \ + .filter(Player.team_id == team_id) \ + .scalars() \ + .all() + async def update_player_stats(self, stats_update: PlayerStatsUpdate): player_stats = await self.fetch_player_stats(stats_update.player_id) if player_stats: @@ -58,4 +81,5 @@ async def update_player_stats(self, stats_update: PlayerStatsUpdate): stat_to_update, getattr(player_stats, stat_to_update) + value ) + await self.db.add(player_stats) await self.db.commit() diff --git a/server/services/text_parser_service.py b/server/services/text_parser_service.py new file mode 100644 index 0000000..a5b1a87 --- /dev/null +++ b/server/services/text_parser_service.py @@ -0,0 +1,134 @@ +import ast +import os +from datetime import datetime +from dotenv import load_dotenv +from openai import AzureOpenAI +from typing import List +from sqlalchemy.ext.asyncio import AsyncSession + +from models.models import Game, TextFile +from schemas.schemas import TextFileCreate +from services.game_service import GameService + +load_dotenv() + + +API_KEY = os.getenv("AZURE_API_KEY") +ENDPOINT = os.getenv("AZURE_PROXY_ENDPOINT") +API_VERSION = "2024-02-01" +MODEL_NAME = "gpt-35-turbo" +SYSTEM_CONTENT = """ + You are a box score keeper for a basketball game. + + You will be given a sentence and need to parse the input into the following format: + -> [[TEAM_NAME, PLAYER_NUMBER, STAT_ACTION, STAT_VALUE], ...] + + Do not add more content beyond the format and exa mple outputs. + + Example 1 + Input: John got a rebound + Output: [[Raptors, 12, rebounds, 1]] + Explanation: John (#12) from Raptors got a rebound + Example 2 + Input: Warriors player Steve gave an assist to John, and player John made a three point shot + Output: [[Warriors, 1, assists, 1], [Warriors, 3, three_pts_made, 1]] + Explanation: Steve (#1) from Warriors gave an assist to John (#3) from the same team, and John (#3) made a three-point shot + Example 3 + Input: Mike from Raptors missed a shot + Output: [[Raptors, 23, fg_attempt, 1]] + Explanation: Mike (#23) from Raptors missed a field goal attempt + Example 4 + Input: Boston player Dave blocked a shot + Output: [[Boston, 17, blocks, 1]] + Explanation: Dave (#17) from Boston blocked a shot + Example 5 + Input: Kobe Bryant stole the ball from Paul Piece + Output: [[Lakers, 24, steals, 1], [Boston, 34, turnovers, 1]] + Explanation: Kobe Bryant (#24) from Lakers and hence Paul Pierce (#34) from Boston has a turnover + + You may refer to the following supporting content to make sure outputs are valid. + Sometimes only players name/number are given. Refer to the supporting content to fill the output. + + Supporting content for parsing the user input texts: + Stats to fill: [rebounds,assists,steals,blocks,turnovers,fouls,fg_made,fg_attempt,ft_made,ft_attempt,three_pts_made,three_pts_attempt] +""" + + +class TextParserService: + db: AsyncSession + client: AzureOpenAI + + def __init__(self, db: AsyncSession): + self.db = db + self.game_service = GameService(db) + self.openai_client = AzureOpenAI( + azure_endpoint=ENDPOINT, + api_key=API_KEY, + api_version=API_VERSION, + ) + + def completion_request(self, message) -> List[List]: + completion = self.openai_client.chat.completions.create( + model=MODEL_NAME, + messages=message, + ) + results = ast.literal_eval((completion.choices[0].message.content)) + + return results + + async def parse_text_file(self, text_file: TextFileCreate) -> List[List]: + self.save_text_file(text_file) + + # request + message = await self.build_messages(text_file) + results = self.completion_request(message) + + if self.check_results(results): + return results + + print(f"The output was invalid: {results}") + return [[]] + + async def save_text_file(self, text_file: TextFileCreate): + now = datetime.now() + new_entry = TextFile(**text_file.dict()) + new_entry.id = f"textfile_{text_file.game_id}_{now.strftime('%H%M%S')}" + new_entry.created_at = now + self.db.add(new_entry) + await self.db.commit() + await self.db.refresh(new_entry) + + async def build_messages(self, text_file: TextFileCreate): + game = await self.game_service.fetch_game(text_file.game_id) + supporting_content = await self.build_supporting_content(game) + content = SYSTEM_CONTENT + "\n" + supporting_content + return [ + {"role": "system", "content": content}, + {"role": "user", "content": text_file.contents} + ] + + async def build_supporting_content(self, game: Game) -> str: + home_team = await self.game_service.fetch_team(game.home_team_id) + home_team_players = await self.game_service.fetch_players(game.home_team_id) + away_team = await self.game_service.fetch_team(game.away_team_id) + away_team_players = await self.game_service.fetch_players(game.away_team_id) + + supporting_content = f"Home team name: {home_team.name}\n" + supporting_content += f"Home team players: {[(player.name, player.number) for player in home_team_players]}\n" + supporting_content += f"Away team name: {away_team.name}\n" + supporting_content += f"Away team players: {[(player.name, player.number) for player in away_team_players]}\n" + + return supporting_content + + def check_results(self, results: List) -> bool: + is_valid_team_name = is_valid_player_number = is_valid_stat = is_valid_value = False\ + + for result in results: + team_name, player_number, stat, value = result + is_valid_team_name = isinstance(team_name, str) + is_valid_player_number = isinstance(player_number, int) + is_valid_stat = isinstance(stat, str) + is_valid_value = isinstance(value, int) + + return is_valid_team_name and is_valid_player_number \ + and is_valid_stat and is_valid_value