532 lines
18 KiB
Go
532 lines
18 KiB
Go
package llmapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"gralias/broker"
|
|
"gralias/config"
|
|
"gralias/models"
|
|
"gralias/pkg/cache"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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 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`
|
|
MimeSimplePrompt = `we are playing alias;\nyou are to give a clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\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-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;`
|
|
)
|
|
|
|
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()
|
|
room.OpenedThisTurn++
|
|
if room.OpenedThisTurn >= room.ThisTurnLimit {
|
|
// end turn
|
|
room.TeamTurn = oppositeColor
|
|
room.MimeDone = false
|
|
room.OpenedThisTurn = 0
|
|
room.ThisTurnLimit = 0
|
|
}
|
|
switch string(color) {
|
|
case string(models.WordColorBlack):
|
|
// game over
|
|
room.IsRunning = false
|
|
room.IsOver = true
|
|
room.TeamWon = oppositeColor
|
|
room.OpenedThisTurn = 0
|
|
room.ThisTurnLimit = 0
|
|
action := models.Action{
|
|
Actor: b.BotName,
|
|
ActorColor: string(b.Team),
|
|
WordColor: models.WordColorBlack,
|
|
Action: models.ActionTypeGameOver,
|
|
}
|
|
room.ActionHistory = append(room.ActionHistory, action)
|
|
case string(models.WordColorWhite), string(oppositeColor):
|
|
// end turn
|
|
room.TeamTurn = oppositeColor
|
|
room.MimeDone = false
|
|
room.OpenedThisTurn = 0
|
|
room.ThisTurnLimit = 0
|
|
}
|
|
// check if no cards left => game over
|
|
if room.BlueCounter == 0 {
|
|
// blue won
|
|
room.IsRunning = false
|
|
room.IsOver = true
|
|
room.TeamWon = "blue"
|
|
room.OpenedThisTurn = 0
|
|
room.ThisTurnLimit = 0
|
|
action := models.Action{
|
|
Actor: b.BotName,
|
|
ActorColor: string(b.Team),
|
|
WordColor: models.WordColorBlack,
|
|
Action: models.ActionTypeGameOver,
|
|
}
|
|
room.ActionHistory = append(room.ActionHistory, action)
|
|
}
|
|
if room.RedCounter == 0 {
|
|
// red won
|
|
room.IsRunning = false
|
|
room.IsOver = true
|
|
room.TeamWon = "red"
|
|
room.OpenedThisTurn = 0
|
|
room.ThisTurnLimit = 0
|
|
action := models.Action{
|
|
Actor: b.BotName,
|
|
ActorColor: string(b.Team),
|
|
WordColor: models.WordColorBlack,
|
|
Action: models.ActionTypeGameOver,
|
|
}
|
|
room.ActionHistory = append(room.ActionHistory, action)
|
|
}
|
|
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)
|
|
// 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 = strings.ToLower(tempMap["clue"].(string))
|
|
var ok bool
|
|
mimeResp.Number, ok = tempMap["number"].(string)
|
|
if !ok {
|
|
b.log.Debug("failed to convert the clue number", "tesp", tempMap, "bot_name", b.BotName)
|
|
return
|
|
}
|
|
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
|
|
meant := fmt.Sprintf(b.BotName+" meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
|
|
room.LogJournal = append(room.LogJournal, meant)
|
|
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: %v; bot_name: %s; guess: %s; error: %v", tempMap, b.BotName, guess, 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 {
|
|
if card.Revealed { // skipped already opened
|
|
continue
|
|
}
|
|
words[i] = card.Word
|
|
}
|
|
return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
|
|
}
|
|
|
|
func (b *Bot) BuildSimpleMimePrompt(room *models.Room) string {
|
|
ourwords := []string{}
|
|
theirwords := []string{}
|
|
blackWord := ""
|
|
for _, card := range room.Cards {
|
|
if card.Revealed { // skipped already opened
|
|
continue
|
|
}
|
|
switch card.Color {
|
|
case models.WordColorBlack:
|
|
blackWord = card.Word
|
|
case models.WordColorBlue:
|
|
if b.Team == models.UserTeamBlue {
|
|
ourwords = append(ourwords, card.Word)
|
|
continue
|
|
}
|
|
theirwords = append(theirwords, card.Word)
|
|
case models.WordColorRed:
|
|
if b.Team == models.UserTeamRed {
|
|
ourwords = append(ourwords, card.Word)
|
|
continue
|
|
}
|
|
theirwords = append(theirwords, card.Word)
|
|
}
|
|
}
|
|
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord, room.BlueCounter, room.RedCounter)
|
|
}
|
|
|
|
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(MimeSimplePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
|
// return fmt.Sprintf(MimePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
|
return b.BuildSimpleMimePrompt(room)
|
|
}
|
|
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"
|
|
// Generate the payload once as bytes
|
|
payloadReader := b.LLMParser.MakePayload(prompt)
|
|
client := &http.Client{}
|
|
maxRetries := 6
|
|
baseDelay := 2 // seconds
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
// Create a new request for the attempt
|
|
req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payloadReader)
|
|
if err != nil {
|
|
if attempt == maxRetries-1 {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
b.log.Error("failed to make new request; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
|
time.Sleep(time.Duration(baseDelay) * time.Second * time.Duration(attempt+1))
|
|
continue
|
|
}
|
|
req.Header.Add("Content-Type", "application/json")
|
|
req.Header.Add("Accept", "application/json")
|
|
req.Header.Add("Authorization", "Bearer "+b.cfg.LLMConfig.TOKEN)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
if attempt == maxRetries-1 {
|
|
return nil, fmt.Errorf("http request failed: %w", err)
|
|
}
|
|
b.log.Error("http request failed; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
|
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
|
time.Sleep(delay)
|
|
continue
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
if attempt == maxRetries-1 {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
b.log.Error("failed to read response body; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
|
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
|
time.Sleep(delay)
|
|
continue
|
|
}
|
|
// Check status code
|
|
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
|
if attempt == maxRetries-1 {
|
|
return nil, fmt.Errorf("after %d retries, still got status %d", maxRetries, resp.StatusCode)
|
|
}
|
|
b.log.Warn("retriable status code; will retry", "code", resp.StatusCode, "attempt", attempt)
|
|
delay := time.Duration((baseDelay * (1 << attempt))) * time.Second
|
|
time.Sleep(delay)
|
|
continue
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
// For non-retriable errors, return immediately
|
|
return nil, fmt.Errorf("non-retriable status %d, body: %s", resp.StatusCode, string(body))
|
|
}
|
|
// Success
|
|
b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
|
return body, nil
|
|
}
|
|
// This line should not be reached because each error path returns in the loop.
|
|
return nil, fmt.Errorf("unknown error in retry loop")
|
|
}
|