package llmapi import ( "encoding/json" "errors" "fmt" "golias/broker" "golias/config" "golias/models" "golias/pkg/cache" "io" "log/slog" "net/http" "os" "strings" ) // TODO: config for url and token // completion prompt // MIME: llm needs to know all the cards, colors and previous actions // GUESSER: llm needs to know all the cards and previous actions // channels are not serializable 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 red team (people who would guess by your clue want open the red 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 red team (people who would guess by your clue want open the red cards) by given clue and a number of meant guesses;\nplease return your guesses and words that you did not wish to open, but think that they could be also meant by the clue 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` // notifier = ) 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 // 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 } // parse response // if mime -> give clue // if guesser -> open card (does opening one card prompting new loop?) // send notification to sse broker switch b.Role { case models.UserRoleMime: // respMap := make(map[string]any) mimeResp := MimeResp{} if err := json.Unmarshal(llmResp, &mimeResp); err != nil { b.log.Error("failed to unmarshal mime resp", "error", err) return } 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 // notify(models.NotifyBacklogPrefix+room.ID, clue+num) case models.UserRoleGuesser: gr := GusserResp{} if err := json.Unmarshal(llmResp, &gr); err != nil { b.log.Error("failed to unmarshal guesser resp", "error", err) return } default: b.log.Error("unexpected role", "role", b.Role) return } broker.Notifier.Notifier <- broker.NotificationEvent{ EventName: "", Payload: "", } // 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, })), } // 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), } 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 := []models.WordCard{} 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, room.BlueCounter, room.RedCounter, escapedData) } if b.Role == models.UserRoleMime { // TODO: return "" } 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)) // TODO: case where it has other text, not only json // TODO: how to know which resp it is? mime or guessser? // resp := &MimeResp{} // if err := json.Unmarshal(body, &resp); err != nil { // return nil, err // } return body, nil }