package llmapi import ( "encoding/json" "errors" "fmt" "gralias/broker" "gralias/config" "gralias/models" "gralias/pkg/cache" "io" "log/slog" "net/http" "os" "strconv" "strings" ) var ( // botname -> channel SignalChanMap = make(map[string]chan bool) DoneChanMap = make(map[string]chan bool) // got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0 MimePrompt = `we are playing alias;\nyou are a mime (player who gives a clue of one noun word and number of cards you expect them to open) of the %s team (people who would guess by your clue want open the %s cards);\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the game info in json:\n%s` // TODO: simplify; bot gets confused; so show it only unrevealed cards and last clue (maybe older clues as well); GuesserPrompt = `we are playing alias;\nyou are to guess words of the %s team (you want open %s cards) by given clue and a number of meant guesses;\nplease return your guesses and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guesses\": [\"word1\", \"word2\", ...],\n\"could_be\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the cards (and other info), you need to choose revealed==false words:\n%s` GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that left:\n%v` ) func convertToSliceOfStrings(value any) ([]string, error) { switch v := value.(type) { case []string: // Directly return if it's already []string return v, nil case []interface{}: // Convert each element to string result := make([]string, len(v)) for i, item := range v { str, ok := item.(string) if !ok { return nil, fmt.Errorf("element at index %d is not a string (got %T)", i, item) } result[i] = str } return result, nil default: return nil, fmt.Errorf("unsupported type: %T", value) } } func (b *Bot) checkGuesses(tempMap map[string]any, room *models.Room) error { guesses, err := convertToSliceOfStrings(tempMap["guesses"]) if err != nil { b.log.Warn("failed to parse bot given guesses", "mimeResp", tempMap, "bot_name", b.BotName) return err } for _, word := range guesses { if err := b.checkGuess(word, room); err != nil { // log error b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", word) return err } } return nil } func (b *Bot) checkGuess(word string, room *models.Room) error { color, exists := room.WCMap[word] b.log.Debug("bot trying to open card", "word", word, "color", color, "exists", exists) if !exists { return fmt.Errorf("fn: checkGuess; %s does not exists", word) } room.RevealSpecificWord(word) room.UpdateCounter() action := models.Action{ Actor: b.BotName, ActorColor: b.Team, WordColor: string(color), Action: models.ActionTypeGuess, Word: word, } room.ActionHistory = append(room.ActionHistory, action) // if opened card is of color of opp team, change turn oppositeColor := room.GetOppositeTeamColor() switch string(color) { case "black": // game over room.IsRunning = false room.IsOver = true room.TeamWon = oppositeColor case "white", string(oppositeColor): // end turn room.TeamTurn = oppositeColor room.MimeDone = false } // check if no cards left => game over if room.BlueCounter == 0 { // blue won room.IsRunning = false room.IsOver = true room.TeamWon = "blue" } if room.RedCounter == 0 { // red won room.IsRunning = false room.IsOver = true room.TeamWon = "red" } if err := saveRoom(room); err != nil { b.log.Error("failed to save room", "room", room) err = fmt.Errorf("fn: checkGuess, failed to save room; err: %w", err) return err } return nil } func (b *Bot) BotMove() { // botJournalName := models.NotifyJournalPrefix + b.RoomID b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role) // get room cards and actions room, err := getRoomByID(b.RoomID) if err != nil { b.log.Error("bot loop", "error", err) return } eventName := models.NotifyBacklogPrefix + room.ID eventPayload := "" defer func() { // save room if err := saveRoom(room); err != nil { b.log.Error("failed to save room", "error", err) return } broker.Notifier.Notifier <- broker.NotificationEvent{ EventName: eventName, Payload: eventPayload, } }() // form prompt prompt := b.BuildPrompt(room) b.log.Debug("got prompt", "prompt", prompt) room.LogJournal = append(room.LogJournal, b.BotName+" got prompt: "+prompt) // call llm llmResp, err := b.CallLLM(prompt) if err != nil { room.LogJournal = append(room.LogJournal, b.BotName+" send call got error: "+err.Error()) b.log.Error("bot loop", "error", err) return } tempMap, err := b.LLMParser.ParseBytes(llmResp) if err != nil { room.LogJournal = append(room.LogJournal, b.BotName+" parse resp got error: "+err.Error()) b.log.Error("bot loop", "error", err, "resp", string(llmResp)) return } switch b.Role { case models.UserRoleMime: mimeResp := MimeResp{} b.log.Info("mime resp log", "mimeResp", tempMap) mimeResp.Clue = tempMap["clue"].(string) mimeResp.Number = tempMap["number"].(string) action := models.Action{ Actor: b.BotName, ActorColor: b.Team, WordColor: b.Team, Action: models.ActionTypeClue, Word: mimeResp.Clue, Number: mimeResp.Number, } room.ActionHistory = append(room.ActionHistory, action) room.MimeDone = true eventPayload = mimeResp.Clue + mimeResp.Number guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8) if err != nil { b.log.Warn("failed to parse bot given limit", "mimeResp", mimeResp, "bot_name", b.BotName) } room.ThisTurnLimit = uint8(guessLimitU64) case models.UserRoleGuesser: // // deprecated // if err := b.checkGuesses(tempMap, room); err != nil { // b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName) // continue // } guess, ok := tempMap["guess"].(string) if !ok || guess == "" { b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName) } if err := b.checkGuess(guess, room); err != nil { b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err) msg := fmt.Sprintf("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err) room.LogJournal = append(room.LogJournal, msg) } b.log.Info("mime resp log", "guesserResp", tempMap) couldBe, err := convertToSliceOfStrings(tempMap["could_be"]) if err != nil { b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName) } room.LogJournal = append(room.LogJournal, fmt.Sprintf("%s also considered this: %v", b.BotName, couldBe)) eventName = models.NotifyRoomUpdatePrefix + room.ID eventPayload = "" // TODO: needs to decide if it wants to open the next cardword or end turn // or end turn on limit default: b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap) return } if botName := room.WhichBotToMove(); botName != "" { b.log.Debug("notifying bot", "name", botName) SignalChanMap[botName] <- true } } // StartBot func (b *Bot) StartBot() { for { select { case <-SignalChanMap[b.BotName]: b.BotMove() case <-DoneChanMap[b.BotName]: b.log.Debug("got done signal", "bot-name", b.BotName) return } } } func RemoveBot(botName string, room *models.Room) error { // channels DoneChanMap[botName] <- true close(DoneChanMap[botName]) close(SignalChanMap[botName]) // maps delete(room.BotMap, botName) delete(DoneChanMap, botName) delete(SignalChanMap, botName) // remove role from room room.RemovePlayer(botName) return saveRoom(room) } // EndBot func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) { bot := &Bot{ Role: role, RoomID: roomID, BotName: name, Team: team, cfg: cfg, log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelDebug, AddSource: true, })), } // there might be a better way bot.LLMParser = NewLCPRespParser(bot.log) if strings.Contains(cfg.LLMConfig.URL, "api.deepseek.com") { bot.LLMParser = NewDeepSeekParser(bot.log) } else if strings.Contains(cfg.LLMConfig.URL, "openrouter.ai") { bot.LLMParser = NewOpenRouterParser(bot.log) } // add to room room, err := getRoomByID(bot.RoomID) if err != nil { return nil, err } // check if not running if role == "mime" && room.IsRunning && !recovery { return nil, errors.New("cannot join after game started") } // room.PlayerList = append(room.PlayerList, name) bp := models.BotPlayer{ Role: models.StrToUserRole(role), Team: models.StrToUserTeam(team), } // check if already has the same team-role bot // only one is allowed for n, p := range room.BotMap { if p.Role == bp.Role && p.Team == bp.Team && !recovery { return nil, fmt.Errorf("already has such bot with name: %s", n) } } room.BotMap[name] = bp switch team { case "red": if role == "mime" { room.RedTeam.Mime = name } else if role == "guesser" { room.RedTeam.Guessers = []string{name} } else { return nil, fmt.Errorf("uknown role: %s", role) } case "blue": if role == "mime" { room.BlueTeam.Mime = name } else if role == "guesser" { room.BlueTeam.Guessers = []string{name} } else { return nil, fmt.Errorf("uknown role: %s", role) } default: return nil, fmt.Errorf("uknown team: %s", team) } if err := saveRoom(room); err != nil { return nil, err } if err := saveBot(bot); err != nil { return nil, err } // buffered channel to send to it in the same goroutine SignalChanMap[bot.BotName] = make(chan bool, 1) DoneChanMap[bot.BotName] = make(chan bool, 1) go bot.StartBot() // run bot routine return bot, nil } func saveBot(bot *Bot) error { key := models.CacheBotPredix + bot.RoomID + bot.BotName data, err := json.Marshal(bot) if err != nil { return err } cache.MemCache.Set(key, data) return nil } func getRoomByID(roomID string) (*models.Room, error) { roomBytes, err := cache.MemCache.Get(models.CacheRoomPrefix + roomID) if err != nil { return nil, err } resp := &models.Room{} if err := json.Unmarshal(roomBytes, &resp); err != nil { return nil, err } return resp, nil } func saveRoom(room *models.Room) error { key := models.CacheRoomPrefix + room.ID data, err := json.Marshal(room) if err != nil { return err } cache.MemCache.Set(key, data) return nil } func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string { // find not last action, but the clue // clue := room.ActionHistory[len(room.ActionHistory)-1].Word clueAction, err := room.FetchLastClue() if err != nil { b.log.Error("failed to fetch last clue", "error", err, "room", room, "bot_name", b.BotName) return "" } // number := room.ActionHistory[len(room.ActionHistory)-1].Number words := make([]string, len(room.Cards)) for i, card := range room.Cards { words[i] = card.Word } return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words) } func (b *Bot) BuildPrompt(room *models.Room) string { if b.Role == "" { return "" } toText := make(map[string]any) toText["backlog"] = room.ActionHistory // mime sees all colors; // guesser sees only revealed ones if b.Role == models.UserRoleMime { toText["cards"] = room.Cards } data, err := json.Marshal(toText) if err != nil { b.log.Error("failed to marshal", "error", err) return "" } // Escape the JSON string for inclusion in another JSON field escapedData := strings.ReplaceAll(string(data), `"`, `\"`) if b.Role == models.UserRoleMime { return fmt.Sprintf(MimePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData) } if b.Role == models.UserRoleGuesser { // return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData) return b.BuildSimpleGuesserPrompt(room) } return "" } func (b *Bot) CallLLM(prompt string) ([]byte, error) { method := "POST" payload := b.LLMParser.MakePayload(prompt) client := &http.Client{} req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payload) if err != nil { b.log.Error("failed to make new request", "error", err, "url", b.cfg.LLMConfig.URL) return nil, err } req.Header.Add("Content-Type", "application/json") req.Header.Add("Accept", "application/json") req.Header.Add("Authorization", "Bearer "+b.cfg.LLMConfig.TOKEN) res, err := client.Do(req) if err != nil { b.log.Error("failed to make request", "error", err, "url", b.cfg.LLMConfig.URL) return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { b.log.Error("failed to read resp body", "error", err, "url", b.cfg.LLMConfig.URL) return nil, err } b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL) return body, nil }