From 3e2e1da03610a5ecf0ad1f420d427fc5c4b74195 Mon Sep 17 00:00:00 2001 From: bxtbold Date: Sun, 23 Jun 2024 16:16:07 +0900 Subject: [PATCH 1/6] feat: add service & controller for speech text parsing --- requirements.txt | 1 + server/controllers/text_parser_controller.py | 15 +++ server/models/models.py | 3 + server/schemas/schemas.py | 1 + server/services/game_service.py | 26 +++- server/services/text_parser_service.py | 130 +++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 server/controllers/text_parser_controller.py create mode 100644 server/services/text_parser_service.py 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..38c30f6 --- /dev/null +++ b/server/controllers/text_parser_controller.py @@ -0,0 +1,15 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import HTTPException +from server.services.text_parser_service import TextParserService +from schemas.schemas import TextFile + + +class TextParserController: + def __init__(self, db: AsyncSession): + self.service = TextParserService(db) + + async def parse_speech_text(self, text_file: TextFile): + 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/models/models.py b/server/models/models.py index 2e78a02..17ec898 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('game.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/schemas/schemas.py b/server/schemas/schemas.py index f2d36fb..1806595 100644 --- a/server/schemas/schemas.py +++ b/server/schemas/schemas.py @@ -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/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..73471c5 --- /dev/null +++ b/server/services/text_parser_service.py @@ -0,0 +1,130 @@ +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 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: TextFile) -> 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: TextFile): + now = datetime.now() + text_file.id = f"textfile_{text_file.game_id}_{now.strftime('%H%M%S')}" + await self.db.add(text_file) + await self.db.commit() + + async def build_messages(self, text_file: TextFile): + game = await self.game_service.fetch_team(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} + } + + 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 From 99f5af8c476dfc4cda9680f08873687d30dc66ad Mon Sep 17 00:00:00 2001 From: bxtbold Date: Sun, 23 Jun 2024 16:19:17 +0900 Subject: [PATCH 2/6] feat: add parser_router --- server/routes/text_parser_router.py | 20 ++++++++++++++++++++ server/server.py | 2 ++ 2 files changed, 22 insertions(+) create mode 100644 server/routes/text_parser_router.py diff --git a/server/routes/text_parser_router.py b/server/routes/text_parser_router.py new file mode 100644 index 0000000..3b182d1 --- /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 TextFile +from controllers.text_parser_controller import TextParserController + +parser_router = APIRouter() + + +@parser_router.post("/speech_text_command/") +async def speech_text_command(text_file: TextFile, db: AsyncSession = Depends(get_db)): + try: + await TextParserController(db).parse_speech_text(text_file) + msg = "Player stats updated successfully" + except Exception as e: + msg = str(e) + return { + "message": msg, + } 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 From 7f8069164852b6165d703fd5b7b676369c4c1b00 Mon Sep 17 00:00:00 2001 From: bxtbold Date: Sun, 23 Jun 2024 19:34:15 +0900 Subject: [PATCH 3/6] chore: minor fixes --- server/controllers/text_parser_controller.py | 4 ++-- server/services/text_parser_service.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/controllers/text_parser_controller.py b/server/controllers/text_parser_controller.py index 38c30f6..83fbd93 100644 --- a/server/controllers/text_parser_controller.py +++ b/server/controllers/text_parser_controller.py @@ -1,14 +1,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi import HTTPException from server.services.text_parser_service import TextParserService -from schemas.schemas import TextFile +from schemas.schemas import TextFileCreate class TextParserController: def __init__(self, db: AsyncSession): self.service = TextParserService(db) - async def parse_speech_text(self, text_file: TextFile): + async def parse_speech_text(self, text_file: TextFileCreate): try: return await self.service.parse_text_file(text_file) except Exception as e: diff --git a/server/services/text_parser_service.py b/server/services/text_parser_service.py index 73471c5..541f123 100644 --- a/server/services/text_parser_service.py +++ b/server/services/text_parser_service.py @@ -7,6 +7,7 @@ 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() @@ -75,7 +76,7 @@ def completion_request(self, message) -> List[List]: return results - async def parse_text_file(self, text_file: TextFile) -> List[List]: + async def parse_text_file(self, text_file: TextFileCreate) -> List[List]: self.save_text_file(text_file) # request @@ -88,13 +89,16 @@ async def parse_text_file(self, text_file: TextFile) -> List[List]: print(f"The output was invalid: {results}") return [[]] - async def save_text_file(self, text_file: TextFile): + async def save_text_file(self, text_file: TextFileCreate): now = datetime.now() - text_file.id = f"textfile_{text_file.game_id}_{now.strftime('%H%M%S')}" - await self.db.add(text_file) + 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: TextFile): + async def build_messages(self, text_file: TextFileCreate): game = await self.game_service.fetch_team(text_file.game_id) supporting_content = await self.build_supporting_content(game) content = SYSTEM_CONTENT + "\n" + supporting_content From a83e7752cefe98ebb7dec307641870d48be17e59 Mon Sep 17 00:00:00 2001 From: bxtbold Date: Sun, 23 Jun 2024 22:48:09 +0900 Subject: [PATCH 4/6] chore: fix bugs --- server/controllers/text_parser_controller.py | 2 +- server/database/setup_db.py | 10 ++++++---- server/models/models.py | 2 +- server/routes/text_parser_router.py | 10 +++++----- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/server/controllers/text_parser_controller.py b/server/controllers/text_parser_controller.py index 83fbd93..66953f1 100644 --- a/server/controllers/text_parser_controller.py +++ b/server/controllers/text_parser_controller.py @@ -1,6 +1,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi import HTTPException -from server.services.text_parser_service import TextParserService +from services.text_parser_service import TextParserService from schemas.schemas import TextFileCreate 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 17ec898..6a3962e 100644 --- a/server/models/models.py +++ b/server/models/models.py @@ -161,7 +161,7 @@ class TextFile(Base): contents = Column(Text) created_at = Column(TIMESTAMP) file_path = Column(String) - game_id = Column(String, ForeignKey('game.id')) + game_id = Column(String, ForeignKey('games.id')) user_id = Column(String, ForeignKey('users.id')) game = relationship("Game", back_populates="text_files") diff --git a/server/routes/text_parser_router.py b/server/routes/text_parser_router.py index 3b182d1..d605147 100644 --- a/server/routes/text_parser_router.py +++ b/server/routes/text_parser_router.py @@ -2,17 +2,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from database.config import get_db -from schemas.schemas import TextFile -from controllers.text_parser_controller import TextParserController +from schemas.schemas import TextFileCreate + parser_router = APIRouter() @parser_router.post("/speech_text_command/") -async def speech_text_command(text_file: TextFile, db: AsyncSession = Depends(get_db)): +async def speech_text_command(text_file: TextFileCreate, db: AsyncSession = Depends(get_db)): try: - await TextParserController(db).parse_speech_text(text_file) - msg = "Player stats updated successfully" + from controllers.text_parser_controller import TextParserController + msg = await TextParserController(db).parse_speech_text(text_file) except Exception as e: msg = str(e) return { From ec21dafa9072ad102d6a92a822466866a4d99e9b Mon Sep 17 00:00:00 2001 From: bxtbold Date: Tue, 25 Jun 2024 07:49:17 +0900 Subject: [PATCH 5/6] fix: debug building messages for text parsing --- server/services/text_parser_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/services/text_parser_service.py b/server/services/text_parser_service.py index 541f123..a5b1a87 100644 --- a/server/services/text_parser_service.py +++ b/server/services/text_parser_service.py @@ -99,13 +99,13 @@ async def save_text_file(self, text_file: TextFileCreate): await self.db.refresh(new_entry) async def build_messages(self, text_file: TextFileCreate): - game = await self.game_service.fetch_team(text_file.game_id) + 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 { + return [ {"role": "system", "content": content}, - {"role": "user", "content": text_file} - } + {"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) From 10c0db0bc4b03f4574018ce42b325380d7efb33e Mon Sep 17 00:00:00 2001 From: bxtbold Date: Tue, 25 Jun 2024 07:50:17 +0900 Subject: [PATCH 6/6] chore: update attributes (rename stats as plural) --- server/schemas/schemas.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/schemas/schemas.py b/server/schemas/schemas.py index 1806595..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