diff --git a/README.md b/README.md index 25c60d7..c79518d 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ Before you can run the project, you'll need to setup a This starter project works with [convex](https://www.convex.dev) so to run you need to use the [.env.example](.env.example) file to create your own .env file. -In convex, please add the following environment variables: +If you want to mock all models - in convex, please add the following environment variables: -- `npx convex env set MOCK_OPEN_AI true` +- `npx convex env set MOCK_MODELS true` Add optional environment variable/s for simulating real AI models without mockup responses(when mockup flags are set to FALSE): +- `npx convex env set GEMINI_API_KEY YOUR_API_KEY` - `npx convex env set OPENAI_API_KEY YOUR_API_KEY` also, you may need to run, but I think the initial setup does that. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 4169466..a4e3ff9 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -18,11 +18,9 @@ import type { import type * as auth from "../auth.js"; import type * as constants from "../constants.js"; import type * as games from "../games.js"; -import type * as gemini from "../gemini.js"; import type * as http from "../http.js"; import type * as init from "../init.js"; import type * as maps from "../maps.js"; -import type * as openai from "../openai.js"; import type * as results from "../results.js"; import type * as scores from "../scores.js"; import type * as users from "../users.js"; @@ -39,11 +37,9 @@ declare const fullApi: ApiFromModules<{ auth: typeof auth; constants: typeof constants; games: typeof games; - gemini: typeof gemini; http: typeof http; init: typeof init; maps: typeof maps; - openai: typeof openai; results: typeof results; scores: typeof scores; users: typeof users; diff --git a/convex/constants.ts b/convex/constants.ts index f3009e7..299421d 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -1,12 +1,12 @@ export const AI_MODELS = [ - { - model: "gpt-4o", - name: "OpenAI - 4o Mini", - }, { model: "gemini-1.5-pro", name: "Gemini - 1.5 Pro", }, + { + model: "gpt-4o", + name: "OpenAI - 4o Mini", + }, ]; export const AI_MODEL_IDS = AI_MODELS.map((model) => model.model); diff --git a/convex/games.ts b/convex/games.ts index 73b31e9..a4aa84c 100644 --- a/convex/games.ts +++ b/convex/games.ts @@ -25,19 +25,11 @@ export const startNewGame = mutation({ throw new Error("No map found for level 1"); } - if (args.modelId === "gpt-4o") { - await ctx.scheduler.runAfter(0, internal.openai.playMapAction, { - gameId, - modelId: args.modelId, - level: 1, - }); - } else if (args.modelId === "gemini-1.5-pro") { - await ctx.scheduler.runAfter(0, internal.gemini.playMapAction, { - gameId, - modelId: args.modelId, - level: 1, - }); - } + await ctx.scheduler.runAfter(0, internal.maps.playMapAction, { + gameId, + modelId: args.modelId, + level: 1, + }); return gameId; }, diff --git a/convex/gemini.ts b/convex/gemini.ts deleted file mode 100644 index a83df75..0000000 --- a/convex/gemini.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai"; -import { action, internalAction } from "./_generated/server"; -import { v } from "convex/values"; -import { api, internal } from "./_generated/api"; -import { Doc } from "./_generated/dataModel"; -import { ZombieSurvival } from "../simulators/zombie-survival"; - -const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY as string); - -const schema = { - description: "Game Round Results", - type: SchemaType.OBJECT, - properties: { - map: { - type: SchemaType.ARRAY, - items: { - type: SchemaType.ARRAY, - items: { - type: SchemaType.STRING, - }, - }, - description: "The resulting map after the placements", - }, - reasoning: { - type: SchemaType.STRING, - description: "The reasoning behind the move", - }, - playerCoordinates: { - type: SchemaType.ARRAY, - items: { - type: SchemaType.NUMBER, - }, - description: "The player's coordinates", - }, - boxCoordinates: { - type: SchemaType.ARRAY, - items: { - type: SchemaType.ARRAY, - items: { - type: SchemaType.NUMBER, - }, - }, - description: "The box coordinates", - }, - }, - required: ["map", "reasoning", "playerCoordinates", "boxCoordinates"], -}; - -const model = genAI.getGenerativeModel({ - model: "gemini-1.5-pro", - generationConfig: { - responseMimeType: "application/json", - responseSchema: schema, - }, -}); - -type playMapActionResponse = { - map: string[][]; - reasoning: string; - playerCoordinates: number[]; - boxCoordinates: number[][]; -}; - -export const playMapAction = internalAction({ - args: { - level: v.number(), - gameId: v.id("games"), - modelId: v.string(), - }, - handler: async (ctx, args) => { - const resultId = await ctx.runMutation( - internal.results.createInitialResult, - { - gameId: args.gameId, - level: args.level, - }, - ); - - const map: Doc<"maps"> | null = (await ctx.runQuery( - api.maps.getMapByLevel, - { - level: args.level, - }, - )) as any; - - if (!map) { - throw new Error("Map not found"); - } - - if (process.env.MOCK_GEMINI === "true") { - const existingMap = [...map.grid.map((row) => [...row])]; - existingMap[0][0] = "P"; - existingMap[0][1] = "B"; - existingMap[0][2] = "B"; - return { - map: existingMap, - reasoning: "This is a mock response", - playerCoordinates: [0, 0], - boxCoordinates: [], - }; - } - - const result = await model.generateContent( - `You're given a 2d grid of nums such that. - " " represents an empty space. - "Z" represents a zombie. Zombies move one Manhattan step every turn and aim to reach the player. - "R" represents rocks, which players can shoot over but zombies cannot pass through or break. - "P" represents the player, who cannot move. The player's goal is to shoot and kill zombies before they reach them. - "B" represents blocks that can be placed before the round begins to hinder the zombies. You can place up to two blocks on the map. - - Your goal is to place the player ("P") and two blocks ("B") in locations that maximize the player's survival by delaying the zombies' approach. - You can shoot any zombie regardless of where it is on the grid. - Returning a 2d grid with the player and blocks placed in the optimal locations, with the coordinates player ("P") and the blocks ("B"), also provide reasoning for the choices. - - You can't replace rocks R or zombies Z with blocks. If there is no room to place a block, do not place any. - - Grid: ${JSON.stringify(map)}`, - ); - - // todo: check if the response is valid acc to types and the player and box coordinates are valid, - // as sometimes the model returns a state that's erroring out in the simulator - - const parsedResponse = JSON.parse( - result.response.text(), - ) as playMapActionResponse; - - const game = new ZombieSurvival(parsedResponse.map); - while (!game.finished()) { - game.step(); - } - const isWin = !game.getPlayer().dead(); - - await ctx.runMutation(internal.results.updateResult, { - resultId, - isWin, - reasoning: parsedResponse.reasoning, - map: parsedResponse.map, - }); - }, -}); diff --git a/convex/maps.ts b/convex/maps.ts index 8312ef2..b6d2617 100644 --- a/convex/maps.ts +++ b/convex/maps.ts @@ -1,5 +1,12 @@ +import OpenAI from "openai"; +import { internalAction, internalMutation, query } from "./_generated/server"; import { v } from "convex/values"; -import { internalMutation, query } from "./_generated/server"; +import { z } from "zod"; +import { zodResponseFormat } from "openai/helpers/zod"; +import { Doc } from "./_generated/dataModel"; +import { ZombieSurvival } from "../simulators/zombie-survival"; +import { api, internal } from "./_generated/api"; +import { runModel } from "../models"; const MAPS = [ { @@ -98,3 +105,77 @@ export const getMapByLevel = query({ .first(); }, }); + +export const playMapAction = internalAction({ + args: { + level: v.number(), + gameId: v.id("games"), + modelId: v.string(), + }, + handler: async (ctx, args) => { + const resultId = await ctx.runMutation( + internal.results.createInitialResult, + { + gameId: args.gameId, + level: args.level, + }, + ); + + const map: Doc<"maps"> | null = (await ctx.runQuery( + api.maps.getMapByLevel, + { + level: args.level, + }, + )) as any; + + if (!map) { + throw new Error("Map not found"); + } + + if (process.env.MOCK_MODELS === "true") { + const existingMap = [...map.grid.map((row) => [...row])]; + + existingMap[0][0] = "P"; + existingMap[0][1] = "B"; + existingMap[0][2] = "B"; + + const game = new ZombieSurvival(existingMap); + + while (!game.finished()) { + game.step(); + } + + const isWin = !game.getPlayer().dead(); + + await ctx.runMutation(internal.results.updateResult, { + resultId, + isWin, + reasoning: "This is a mock response", + map: existingMap, + }); + + return; + } + + try { + const { solution, reasoning } = await runModel(args.modelId, map.grid); + const game = new ZombieSurvival(solution); + + while (!game.finished()) { + game.step(); + } + + const isWin = !game.getPlayer().dead(); + + await ctx.runMutation(internal.results.updateResult, { + resultId, + isWin, + reasoning, + map: solution, + }); + } catch (error) { + // todo: handle error + console.log(error); + } + }, +}); diff --git a/convex/openai.ts b/convex/openai.ts deleted file mode 100644 index ec28abc..0000000 --- a/convex/openai.ts +++ /dev/null @@ -1,108 +0,0 @@ -import OpenAI from "openai"; -import { v } from "convex/values"; -import { z } from "zod"; -import { zodResponseFormat } from "openai/helpers/zod"; -import { Doc } from "./_generated/dataModel"; -import { ZombieSurvival } from "../simulators/zombie-survival"; -import { api, internal } from "./_generated/api"; -import { internalAction } from "./_generated/server"; - -const ResponseSchema = z.object({ - map: z.array(z.array(z.string())), - reasoning: z.string(), - playerCoordinates: z.array(z.number()), - boxCoordinates: z.array(z.array(z.number())), -}); - -export const playMapAction = internalAction({ - args: { - level: v.number(), - gameId: v.id("games"), - modelId: v.string(), - }, - handler: async (ctx, args) => { - const resultId = await ctx.runMutation( - internal.results.createInitialResult, - { - gameId: args.gameId, - level: args.level, - }, - ); - - const map: Doc<"maps"> | null = (await ctx.runQuery( - api.maps.getMapByLevel, - { - level: args.level, - }, - )) as any; - - if (!map) { - throw new Error("Map not found"); - } - - if (process.env.MOCK_OPEN_AI === "true") { - const existingMap = [...map.grid.map((row) => [...row])]; - - existingMap[0][0] = "P"; - existingMap[0][1] = "B"; - existingMap[0][2] = "B"; - - await ctx.runMutation(internal.results.updateResult, { - resultId, - isWin: ZombieSurvival.isWin(existingMap), - reasoning: "This is a mock response", - map: existingMap, - }); - - return; - } - - // moving here for now so that I can get this deployed without a real key - const openai = new OpenAI(); - - try { - const completion = await openai.beta.chat.completions.parse({ - model: "gpt-4o-2024-08-06", - messages: [ - { - role: "system", - content: `You're given a 2d grid of nums such that. - " " represents an empty space. - "Z" represents a zombie. Zombies move one Manhattan step every turn and aim to reach the player. - "R" represents rocks, which players can shoot over but zombies cannot pass through or break. - "P" represents the player, who cannot move. The player's goal is to shoot and kill zombies before they reach them. - "B" represents blocks that can be placed before the round begins to hinder the zombies. You can place up to two blocks on the map. - - Your goal is to place the player ("P") and two blocks ("B") in locations that maximize the player's survival by delaying the zombies' approach. - You can shoot any zombie regardless of where it is on the grid. - Returning a 2d grid with the player and blocks placed in the optimal locations, with the coordinates player ("P") and the blocks ("B"), also provide reasoning for the choices. - - You can't replace rocks R or zombies Z with blocks. If there is no room to place a block, do not place any.`, - }, - { - role: "user", - content: JSON.stringify(map.grid), - }, - ], - response_format: zodResponseFormat(ResponseSchema, "game_map"), - }); - - const response = completion.choices[0].message; - - if (response.parsed) { - await ctx.runMutation(internal.results.updateResult, { - resultId, - isWin: ZombieSurvival.isWin(response.parsed.map), - reasoning: response.parsed.reasoning, - map: response.parsed.map, - }); - } else if (response.refusal) { - const refusal_res = response.refusal; - throw new Error(`Refusal: ${refusal_res}`); - } - } catch (error) { - // todo: handle error - console.log(error); - } - }, -}); diff --git a/convex/results.ts b/convex/results.ts index 489d114..3acb364 100644 --- a/convex/results.ts +++ b/convex/results.ts @@ -77,19 +77,11 @@ export const updateResult = internalMutation({ throw new Error("Next map not found"); } - if (game.modelId === "gpt-4o") { - await ctx.scheduler.runAfter(0, internal.openai.playMapAction, { - gameId: result.gameId, - modelId: game.modelId, - level: result.level + 1, - }); - } else if (game.modelId === "gemini-1.5-pro") { - await ctx.scheduler.runAfter(0, internal.gemini.playMapAction, { - gameId: result.gameId, - modelId: game.modelId, - level: result.level + 1, - }); - } + await ctx.scheduler.runAfter(0, internal.maps.playMapAction, { + gameId: result.gameId, + modelId: game.modelId, + level: result.level + 1, + }); } }, }); diff --git a/models/gemini-1.5-pro.ts b/models/gemini-1.5-pro.ts new file mode 100644 index 0000000..7cc9fcc --- /dev/null +++ b/models/gemini-1.5-pro.ts @@ -0,0 +1,87 @@ +import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai"; +import { type ModelResult } from "."; + +const schema = { + description: "Game Round Results", + type: SchemaType.OBJECT, + properties: { + map: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.STRING, + }, + }, + description: "The resulting map after the placements", + }, + reasoning: { + type: SchemaType.STRING, + description: "The reasoning behind the move", + }, + playerCoordinates: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.NUMBER, + }, + description: "The player's coordinates", + }, + boxCoordinates: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.NUMBER, + }, + }, + description: "The box coordinates", + }, + }, + required: ["map", "reasoning", "playerCoordinates", "boxCoordinates"], +}; + +interface GeminiResponse { + map: string[][]; + reasoning: string; + playerCoordinates: number[]; + boxCoordinates: number[][]; +} + +export async function gemini15pro(map: string[][]): Promise { + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); + + const model = genAI.getGenerativeModel({ + model: "gemini-1.5-pro", + generationConfig: { + responseMimeType: "application/json", + responseSchema: schema, + }, + }); + + const result = await model.generateContent( + `You're given a 2d grid of nums such that. + " " represents an empty space. + "Z" represents a zombie. Zombies move one Manhattan step every turn and aim to reach the player. + "R" represents rocks, which players can shoot over but zombies cannot pass through or break. + "P" represents the player, who cannot move. The player's goal is to shoot and kill zombies before they reach them. + "B" represents blocks that can be placed before the round begins to hinder the zombies. You can place up to two blocks on the map. + + Your goal is to place the player ("P") and two blocks ("B") in locations that maximize the player's survival by delaying the zombies' approach. + You can shoot any zombie regardless of where it is on the grid. + Returning a 2d grid with the player and blocks placed in the optimal locations, with the coordinates player ("P") and the blocks ("B"), also provide reasoning for the choices. + + You can't replace rocks R or zombies Z with blocks. If there is no room to place a block, do not place any. + + Grid: ${JSON.stringify(map)}`, + ); + + // todo: check if the response is valid acc to types and the player and box coordinates are valid, + // as sometimes the model returns a state that's erroring out in the simulator + + const parsedResponse = JSON.parse(result.response.text()) as GeminiResponse; + + return { + solution: parsedResponse.map, + reasoning: parsedResponse.reasoning, + }; +} diff --git a/models/gpt-4o.ts b/models/gpt-4o.ts new file mode 100644 index 0000000..a2f53e2 --- /dev/null +++ b/models/gpt-4o.ts @@ -0,0 +1,56 @@ +import OpenAI from "openai"; +import { z } from "zod"; +import { zodResponseFormat } from "openai/helpers/zod"; +import { type ModelResult } from "."; + +const ResponseSchema = z.object({ + map: z.array(z.array(z.string())), + reasoning: z.string(), + playerCoordinates: z.array(z.number()), + boxCoordinates: z.array(z.array(z.number())), +}); + +export async function gpt4o(map: string[][]): Promise { + const openai = new OpenAI(); + + const prompt = `You're given a 2d grid of nums such that. + " " represents an empty space. + "Z" represents a zombie. Zombies move one Manhattan step every turn and aim to reach the player. + "R" represents rocks, which players can shoot over but zombies cannot pass through or break. + "P" represents the player, who cannot move. The player's goal is to shoot and kill zombies before they reach them. + "B" represents blocks that can be placed before the round begins to hinder the zombies. You can place up to two blocks on the map. + + Your goal is to place the player ("P") and two blocks ("B") in locations that maximize the player's survival by delaying the zombies' approach. + You can shoot any zombie regardless of where it is on the grid. + Returning a 2d grid with the player and blocks placed in the optimal locations, with the coordinates player ("P") and the blocks ("B"), also provide reasoning for the choices. + + You can't replace rocks R or zombies Z with blocks. If there is no room to place a block, do not place any.`; + + const completion = await openai.beta.chat.completions.parse({ + model: "gpt-4o-2024-08-06", + messages: [ + { + role: "system", + content: prompt, + }, + { + role: "user", + content: JSON.stringify(map), + }, + ], + response_format: zodResponseFormat(ResponseSchema, "game_map"), + }); + + const response = completion.choices[0].message; + + if (response.refusal) { + throw new Error(`Refusal: ${response.refusal}`); + } else if (!response.parsed) { + throw new Error("Failed to run model GPT-4o"); + } + + return { + solution: response.parsed.map, + reasoning: response.parsed.reasoning, + }; +} diff --git a/models/index.ts b/models/index.ts new file mode 100644 index 0000000..0b994f7 --- /dev/null +++ b/models/index.ts @@ -0,0 +1,24 @@ +import { gemini15pro } from "./gemini-1.5-pro"; +import { gpt4o } from "./gpt-4o"; + +export interface ModelResult { + solution: string[][]; + reasoning: string; +} + +export async function runModel( + modelId: string, + map: string[][], +): Promise { + switch (modelId) { + case "gemini-1.5-pro": { + return gemini15pro(map); + } + case "gpt-4o": { + return gpt4o(map); + } + default: { + throw new Error(`Tried running unknown model '${modelId}'`); + } + } +}