RoastGPT - An AI-Powered Fantasy Football Weekly Recap Generator

RoastGPT - An AI-Powered Fantasy Football Weekly Recap Generator

2024, Oct 24    

I’m in a couple of Fantasy Football leagues, and one of my favorite parts of Fantasy Football is the trash talk. In one of my leagues, one of my weekly responsibilities is creating a weekly matchup recap newsletter, and naturally I use that as an opportunity for some good old-fashioned trash talk. Since I do so much stuff with AI, this year I decided to utilize an AI to help roast the matchups. I started out with using ChatGPT, and while it was doing an okay job, I found that using ChatGPT to generate recaps for each team was a bit of a pain - I had to manually take screenshots of the matchup box scores, provide metadata to the AI to have some additional context, and the “vision” aspect of GPT-4o wasn’t always right so I had to constantly correct the AI. It was good, but I felt like it could do better. So I wrote a python script which uses an Azure OpenAI service to generate a recap of the week’s matchups. Just for fun, I named it RoastGPT.

How it Works

RoastGPT uses Azure OpenAI to generate the weekly matchup recaps. It uses a deployment of GPT-4o-mini to generate the text. The script also calls out to ESPN’s API to get various information about the fantasy league, such as the team names, the scores, and the player stats. The script then generates a series of prompts to pass into the AI, which generates the text for the newsletter. Sounds easy enough, right? I’ve said that before, and well…

Step 1 - Get the Data

After doing some searching, it turns out that ESPN Fantasy doesn’t have a published API. I could probably have crawled the site to see if I could pull in an OpenAPI spec, but I didn’t know where they would have had an OpenAPI specification published. Instead I did what any other good developer would do and let someone do the work for me. I happened to find the espn_api python package, which claimed to be able to pull in data from ESPN’s Fantasy API. After a couple of quick tests, I found that it does, indeed, work as advertised. Yay!

Since the ESPN APIs weren’t really intended to be used outside of ESPN, there is a weird setup step that’s required for this library, especially if you want to use it to view private league information. You have to go get a couple of session cookies from a browser session and use those for authenticating to the API. I’m not going to go into the details of how to do that here since it is documented but just know it is a required step.

Step 2 - Generate the Prompts

Prompts are the way we interface with GenAI systems like ChatGPT. It’s how we tell it what to do, provide examples and details for what we expect it to do, and how we pass any relevant data into the AI to be processed. For RoastGPT, I dynamically generate a series of prompts - one for each matchup. The prompts are generated by pulling in the data from the ESPN API and then formatting it in a way that the API can understand. This is where life gets a little tricky.

Formatting the Data

The ESPN API returns a TON of data, and most of it isn’t needed for what I’m trying to do. To generate a Fantasy Football recap, you really only need some key pieces of data - the team names, scores, and lineup stats. I also wanted it to have information about the next matchups for each team, so I could include some trash talk about the upcoming week. To avoid overwhelming the AI with data, I decided to do some data transformation and create a series of custom objects to store the relevant data.

I first decided to create a custom object in python, named RelevantBoxScoreInfo which is used to store the team box score info for the “home” and “away” teams, along with a reference to what matchup week we’re looking at. The team box scores are also custom objects, named TeamBoxScoreInfo which contains the team name, the score, the ranking, their record, and who the team is playing next week and the ranking and record of that team. All of this information is parsed as part of the object’s constructor:

# Constructor for TeamBoxScoreInfo object
def __init__(self, teamStats, teamScore, teamLineup, matchupWeek):
    self.team_name = teamStats.team_name
    self.team_manager = f"{teamStats.owners[0]['firstName']} {teamStats.owners[0]['lastName']}"
    self.team_score = teamScore
    self.team_record = f"{teamStats.wins}-{teamStats.losses}"
    self.team_rank = teamStats.standing
    self.team_streak = f"{teamStats.streak_length} {teamStats.streak_type}"
    self.team_next_matchup = teamStats.schedule[matchupWeek+1].team_name
    self.team_next_matchup_record = f"{teamStats.schedule[matchupWeek+1].wins}-{teamStats.schedule[matchupWeek+1].losses}"
    self.team_next_matchup_streak = f"{teamStats.schedule[matchupWeek+1].streak_length} {teamStats.schedule[matchupWeek+1].streak_type}"
    self.team_next_matchup_rank = teamStats.schedule[matchupWeek+1].standing

    # Get player stats
    self.team_players = []
    for player in teamLineup:
        self.team_players.append(PlayerBoxScoreInfo(player))

“Matt, :monocle_face: why are you passing in the score separate from the team stats?” I’m glad you asked - it’s because the score isn’t a part of the team stats object. This is one of the messy parts of how the ESPN API organized their data and why I chose not to pass the output from the ESPN API directly into the AI.

:astonished: “Wait, so player stats come from their own object too?” Yep - that’s right - players also have their own object, named PlayerBoxScoreInfo which stores info about each player on the team’s lineup:

def __init__(self, player):
    self.player_name = player.name

    if player.lineupSlot == "BE":
        self.player_position = "BENCH"
    elif player.lineupSlot == "IR":
        self.player_position = "INJURED RESERVE"
    else:
        self.player_position = player.position
        
    self.player_predicted_points = player.projected_points
    self.player_actual_points = player.points 

Once again I had to get a bit creative here with the positions. The lineupSlot (the API’s way of describing the player’s fantasy lineup slot) doesn’t really show the player’s professional position. For example - a Running Back’s lineupSlot attribute comes back as “RB/WR/TE”, but in reality they belong to the “RB” slot. If the player was left on a team’s Bench (“BN”) their lineupSlot whould show as “BN” but their professional position would still be their normal position (e.g. “RB”). I had to do some mapping to get the player’s position to show up correctly, which is why there’s a small amount of mapping logic there. I also wanted to distinguish a bench slot from an IR slot, so I added a check for that as well.

Ok cool so I have these custom Python objects, but now what? Well the obvious answer is to JSON encode them, but the regular json.dumps() method in python won’t work here because the json module doesn’t know how to serialize custom objects. Luckily, jsonpickle exists and can do just that, so I used that package to serialize the root RelevantBoxScoreInfo object into a JSON string.

Whew! Ok, now that that’s all done, we can FINALLY start to form our prompts.

Forming the matchup prompts

For this script, I’m starting with the matchup prompts. For each matchup, the process is to form the RelevantBoxScoreInfo object for the matchup, JSON-serialize it, and then use the RelevantBoxScoreInfo object and its accompanying JSON-serialized string to dynamically generate the user prompt to pass the matchup data to the AI. Speaking of… yes, there are two prompts for every matchup - a “system” prompt and a “user” prompt. I could probably have done this all in one prompt, but I wanted to separate the instructions for the AI from the data that the AI would use to generate the text. Let me explain…

A “system prompt” is a standardized set of instructions that sets the stage for the AI. It should contain things like what kind of personality the AI should use and what kind of output is expected, along with any “guardrails” that the AI should follow. In the case of RoastGPT, I’m providing it information on the number of each position we have in our lineups, the personality to use (“You are to respond in the voice of a sports entertainment announcer who roasts fantasy football matchups”), and some other instructions around what I expect to be generated (2-4 sentences summarizing the matchup and roasting the teams). I’m also telling it that it can expect information about each team including their record, current win/loss streak, and some information about the players on the team. Oh, yeah, and I’m also updating the system prompt with examples from previous roasts so that it doesn’t repeat itself too often.

A “user prompt” is the information that the user provides. When you use a service like Gemini or ChatGPT or Copilot you’re providing user prompts with each turn in the conversation, and the system responds with an “assistant” prompt. The RoastGPT script dynamically forms the user prompt by having a standard template string stored as a constant, and passes in the information from the RelevantBoxScoreInfo object for each matchup into the template to fill in the placeholders. This generated string is then passed into the AI as the user prompt.

Step 3 - Call the AI

RoastGPT works by calling an Azure OpenAI service instance to generate the recap text. I’m using GPT-4o-mini for this since it is well-suited for tasks like creative text generation. I did consider using the full GPT-4o model but I don’t think the extra capabiltiies of GPT-4o would offer enough incentive to make it worth the extra computational cost. I also considered going with the o1-preview models, but my Azure account isn’t registered for the preview and, again, I don’t think that I’d get enough of an increase in performance. GPT-4o-mini is more than capable of doing the job, so I’m sticking with that for now.

I did also attempt this with Gemini and Llama, and had very mixed results (at best) with it them. They didn’t seem to offer the same amount of creativity and flexibility that I got with the GPT-4o-mini model, and the results were often nonsensical. I’m sure with some tweaking I could get them to work, but I didn’t want to spend the time on that when I already had a working solution. So I stuck with GPT-4o-mini.

To generate the recaps, it’s a simple call to the Azure OpenAI service. This is also where I’m dynamically forming the prompts for each matchup and passing the JSONified data into the prompt. Here’s the method that generates the matchup roast:

def generate_matchup_roast(self, relevantBoxScore):
    # JSONify the relevant box score
    jsonRelevantBoxScore = jsonpickle.encode(relevantBoxScore, unpicklable=False)

    messages = [
        {
            "role": "system",
            "content": system_matchup_prompt
        },
        {
            "role": "user",
            "content": matchup_prompt.format(
                # Fill in the placeholders in the prompt
            )
        }
    ]

    # Ship it to the AI and let them roast it...
    roast = self.gptClient.chat.completions.create(
        messages=messages,
        model = self.gptModel
    )

    return roast.choices[0].message.content

This is a pretty simple method - fill in the placeholders in the prompt, call the Azure OpenAI service, and parse the response for the message content. Rinse and repeat for the rest of the matchups, and you have a full newsletter recap generated by an AI.

The observant among you might be asking something like “Matt, isn’t this going to provide a lot of duplicate data to the AI in the prompt? It seems like you’re passing in a lot of the data twice…” and yes, you are correct. In some areas, I’m passing the data twice. Could I have separated this out and reduced the chances of overwhelming the AI? Yes. Should I? Probably. There’s a lot of opportunity for optimization here, but I wanted to get this working first before I started optimizing it. I’m sure I’ll come back to this and make it better in the future.

Step 4 - Finishing Touches

A newsletter wouldn’t be complete without an intro and outro, would it? And yes - you guessed it - RoastGPT is writing those too. These prompts are fairly simple; I’m providing some baseline instructions around tone and content (ex: 2-3 sentences, make a joke about how an AI is roasting your fantasy football matchups, and use a sports announcer voice) and then passing in the relevant data. In this case, the “relevant data” is the body of the newsletter. I’m giving it the matchup roasts for two main reasons: 1) It’ll follow the same theme and tone as the rest of the newsletter, and 2) It’ll give the AI some context around what it’s writing about. This has led to some pretty funny results (and we’re really doing this project for a good laugh), like:

WHAT’S UP, FANTASY FANS?! It’s that magical time of the week when we dive into our fantasy football showdown, guided by an AI that still believes “tight end” refers to my diet! Buckle up for some laughs, burns, and perhaps a few questionable lineup decisions that could make even your grandma cringe. Let’s kick this off!

or…..

WHAT’S UP, FANTASY FANATICS?!?! It’s that glorious time of the week when we round up your questionable lineup decisions and serve them with a side of AI sass! Honestly, I may not know the difference between a touchdown and a three-point line, but I sure can roast some fantasy blunders like nobody’s business! LET’S GET THIS HEATED! 🏈🔥

or like this outro….

And that’s a wrap on another week of fantasy football antics, where some teams soared like eagles while others crash-landed harder than my AI capabilities at a karaoke night! Remember, folks, it’s all in good fun—just like me pretending to have emotions! Until next week, keep your lineups sharp and your roasts even sharper!

Ok, fine, that outro could use a little work :facepalm:. GenAI isn’t perfect, remember?

The Results

All in all, the AI did a pretty good job…. Here’s how it handled this week’s matchup (I didn’t put the full newsletter here, just a snippet):

WHAT’S UP, FANTASY FANATICS?! Ready to dive into this week’s football shenanigans? Just remember, you’re getting roasted by an AI that still thinks “blocking” is just another word for avoiding social interactions! Buckle up, because we’re about to break down your lineups with all the finesse of a robot trying to dance. LET’S DO THIS!

[TEAM 1]is scrapping the bottom of the barrel with a whimpering 67.86 points this week, as if their lineup was written by someone binge-watching a soap opera instead of strategizing for fantasy football. Tyreek Hill caught fewer balls than a toddler at a birthday party, scoring just 2.3 points, while Mike Evans decided to join the invisible man club as his injuries took him out of the game. Meanwhile, [TEAM 2] flexed its muscles with a whopping 114.26, riding high on Jalen Hurts’ surprising performance and victory laps Cade Otton, who seemed to be practicing their “how to play against [TEAM 1]” game plan. As [TEAM 2] moves on to face [TEAM 2 NEXT OPPONENT], [TEAM 1] will need divine intervention to turn that sinking ship around in their next matchup, otherwise they might be on the bye week of life for the rest of the season!

The rest of the teams get roasted here…

And that’s a wrap on this week’s fantasy football shenanigans! Whether your team is soaring like a majestic dragon or floundering like my failed attempts to convince you I’m not just an AI, the only thing we know for sure is: next week’s matchups might just deliver more plot twists than a daytime soap opera. Remember to set those lineups, and as always, the Cowboys suck!

Ok, it could still use some work on the outro :roll_eyes:. But overall, I’m pretty happy with how it turned out, especially for a project that took only a couple of hours to put together. And no, I didn’t ask it to say that the Cowboys suck - it just knows. I’m pretty sure that’s common knowledge :cowboy_hat_face:.

Conclusion

This was a fun project - I got to combine my love of Fantasy Football trash-talking with my interest in using AI to generate creative content. All in all, I’m pretty happy with how it turned out, and based on the feedback from the folks in my fantasy leagues they’re enjoying it too. This AI tool does really nothing ground-breaking or super useful, it was just a fun project that I wanted to do, and one that I wanted to share. It’s now getting so easy to interface with these AI tools that you can do some pretty cool stuff with them in just a couple of hours, and my goal here was to inspire you all to do the same.

Oh, and in case you were wondering - yes, this entire project was inspired by AI. It started with me manually using an AI to generate these recaps, and then evolved into me using an AI to write a script that uses an AI to generate these recaps. It’s AI all the way down. And yes, the Dallas Cowboys still suck.