package llmapi import ( "encoding/json" "errors" "fmt" "gralias/broker" "gralias/config" "gralias/models" "gralias/pkg/cache" "io" "log/slog" "net/http" "os" "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` 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 game info in json:\n%s` ) type DSResp struct { ID string `json:"id"` Choices []struct { Text string `json:"text"` Index int `json:"index"` FinishReason string `json:"finish_reason"` } `json:"choices"` Created int `json:"created"` Model string `json:"model"` SystemFingerprint string `json:"system_fingerprint"` Object string `json:"object"` } type LLMResp struct { Choices []struct { FinishReason string `json:"finish_reason"` Index int `json:"index"` Message struct { Content string `json:"content"` Role string `json:"role"` } `json:"message"` } `json:"choices"` Created int `json:"created"` Model string `json:"model"` Object string `json:"object"` } type MimeResp struct { Clue string `json:"clue"` Number string `json:"number"` Answer []string `json:"words_I_mean_my_team_to_open"` } type GusserResp struct { Guesses []string `json:"guesses"` CouldBe []string `json:"could_be"` } type Bot struct { Role string // gueeser | mime Team string cfg *config.Config RoomID string // can we get a room from here? BotName string log *slog.Logger LLMParser RespParser // channels for communicaton // channels are not serializable // SignalsCh chan bool // DoneCh chan bool } // StartBot func (b *Bot) StartBot() { for { select { case <-SignalChanMap[b.BotName]: 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 } // form prompt prompt := b.BuildPrompt(room) b.log.Debug("got prompt", "prompt", prompt) // call llm llmResp, err := b.CallLLM(prompt) if err != nil { b.log.Error("bot loop", "error", err) return } tempMap, err := b.LLMParser.ParseBytes(llmResp) if err != nil { b.log.Error("bot loop", "error", err) continue } eventName := models.NotifyBacklogPrefix + room.ID eventPayload := "" 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: "gave clue", Word: mimeResp.Clue, Number: mimeResp.Number, } room.ActionHistory = append(room.ActionHistory, action) room.MimeDone = true eventPayload = mimeResp.Clue + mimeResp.Number case models.UserRoleGuesser: for _, word := range tempMap["guesses"].([]string) { color, exists := room.WCMap[word] b.log.Debug("bot trying to open card", "word", word, "color", color, "exists", exists) if !exists { return } room.RevealSpecificWord(word) room.UpdateCounter() action := models.Action{ Actor: b.BotName, ActorColor: b.Team, WordColor: string(color), Action: "guessed", 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) return } } b.log.Info("mime resp log", "guesserResp", tempMap) eventName = models.NotifyRoomUpdatePrefix + room.ID eventPayload = "" default: b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap) return } // save room if err := saveRoom(room); err != nil { b.log.Error("failed to save room", "error", err) return } if botName := room.WhichBotToMove(); botName != "" { SignalChanMap[botName] <- true } broker.Notifier.Notifier <- broker.NotificationEvent{ EventName: eventName, Payload: eventPayload, } // update room info case <-DoneChanMap[b.BotName]: b.log.Debug("got done signal", "bot-name", b.BotName) return } } } // EndBot func NewBot(role, team, name, roomID string, cfg *config.Config) (*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) } // add to room room, err := getRoomByID(bot.RoomID) if err != nil { return nil, err } // check if not running if role == "mime" && room.IsRunning { 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 { 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 = append(room.RedTeam.Guessers, 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 = append(room.BlueTeam.Guessers, 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 } SignalChanMap[bot.BotName] = make(chan bool) DoneChanMap[bot.BotName] = make(chan bool) go bot.StartBot() // run bot routine return bot, nil } func saveBot(bot *Bot) error { key := "botkey_" + 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) 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 } if b.Role == models.UserRoleGuesser { copiedCards := make([]models.WordCard, len(room.Cards)) copy(copiedCards, room.Cards) for i, card := range copiedCards { if !card.Revealed { copiedCards[i].Color = models.WordColorUknown } } toText["cards"] = copiedCards } 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 "" } func (b *Bot) CallLLM(prompt string) ([]byte, error) { method := "POST" payload := strings.NewReader(fmt.Sprintf(`{ "model": "deepseek-chat", "prompt": "%s", "echo": false, "frequency_penalty": 0, "logprobs": 0, "max_tokens": 1024, "presence_penalty": 0, "stop": null, "stream": false, "stream_options": null, "suffix": null, "temperature": 1, "top_p": 1 }`, 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 }