668 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			668 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package llmapi
 | ||
| 
 | ||
| import (
 | ||
| 	"context"
 | ||
| 	"errors"
 | ||
| 	"fmt"
 | ||
| 	"gralias/broker"
 | ||
| 	"gralias/config"
 | ||
| 	"gralias/models"
 | ||
| 	"gralias/repos"
 | ||
| 	"io"
 | ||
| 	"log/slog"
 | ||
| 	"net/http"
 | ||
| 	"os"
 | ||
| 	"strconv"
 | ||
| 	"strings"
 | ||
| 	"sync"
 | ||
| 	"time"
 | ||
| )
 | ||
| 
 | ||
| var (
 | ||
| 	// botname -> channel
 | ||
| 	repo          = repos.RP
 | ||
| 	SignalChanMap = make(map[string]chan bool)
 | ||
| 	DoneChanMap   = make(map[string]chan bool)
 | ||
| 	mapMutex      = &sync.RWMutex{}
 | ||
| 	// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
 | ||
| 	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 you can choose from:\n%v`
 | ||
| 	MimeSimplePrompt      = `we are playing alias;\nyou are to give one word 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.`
 | ||
| 	GuesserSimplePromptRU = `мы играем в alias;\n тебе дана подсказка (clue): \"%s\";\nпожалуйста, верни свою догадку (guess), а также слова, что тоже подходят к подсказке, но ты меньше в них уверен, в формате json; пример:\n{\n\"guess\": \"отгадка\",\n\"could_be\": [\"слово1\", \"слово2\", ...]\n}\nвот список слов из которых нужно выбрать:\n%v`
 | ||
| 	MimeSimplePromptRU    = `мы играем в alias;\nтебе нужно дать подсказку одним словом и число слов, что ты подразумевал этой подсказкой; слова твоей комманды: %v;\nслова противоположной комманды, что ты хочешь избежать: %v;\nи вот ЧЕРНОЕ СЛОВО, открыв которое твоя комманда проиграет игру: %s;\nпожалуйста, верни подсказку (одним словом) и количество слов, что ты подразумеваешь в формате json; пример:\n{\n\"clue\": \"подсказка\",\n\"number\": \"число-от-0-до-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"слово1\", \"слово2\", ...]\n}\nпожалуйста верни только json.`
 | ||
| )
 | ||
| 
 | ||
| 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) checkGuess(word string, room *models.Room) error {
 | ||
| 	// color, exists := room.WCMap[word]
 | ||
| 	color, exists := room.FindColor(word)
 | ||
| 	b.log.Debug("bot trying to open card", "word", word, "color",
 | ||
| 		color, "exists", exists, "limit", room.ThisTurnLimit,
 | ||
| 		"opened", room.OpenedThisTurn)
 | ||
| 	if !exists {
 | ||
| 		return fmt.Errorf("fn: checkGuess; %s does not exists", word)
 | ||
| 	}
 | ||
| 	room.RevealSpecificWord(word)
 | ||
| 	if err := repo.WordCardReveal(context.Background(), word, room.ID); err != nil {
 | ||
| 		b.log.Error("failed to reveal word in db", "word", word, "color",
 | ||
| 			color, "exists", exists, "limit", room.ThisTurnLimit,
 | ||
| 			"opened", room.OpenedThisTurn)
 | ||
| 		return err
 | ||
| 	}
 | ||
| 	room.UpdateCounter()
 | ||
| 	action := models.Action{
 | ||
| 		RoomID:     room.ID,
 | ||
| 		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 {
 | ||
| 		b.log.Debug("ending turn on limit", "word", word, "color",
 | ||
| 			color, "exists", exists, "limit", room.ThisTurnLimit,
 | ||
| 			"opened", room.OpenedThisTurn)
 | ||
| 		// end turn
 | ||
| 		room.TeamTurn = oppositeColor
 | ||
| 		room.MimeDone = false
 | ||
| 		room.OpenedThisTurn = 0
 | ||
| 		room.ThisTurnLimit = 0
 | ||
| 		b.StopTurnTimer()
 | ||
| 	}
 | ||
| 	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{
 | ||
| 			RoomID:     room.ID,
 | ||
| 			Actor:      b.BotName,
 | ||
| 			ActorColor: string(b.Team),
 | ||
| 			WordColor:  models.WordColorBlack,
 | ||
| 			Action:     models.ActionTypeGameOver,
 | ||
| 		}
 | ||
| 		room.ActionHistory = append(room.ActionHistory, action)
 | ||
| 		b.StopTurnTimer()
 | ||
| 		updateStatsOnGameOver(context.Background(), room)
 | ||
| 	case string(models.WordColorWhite), string(oppositeColor):
 | ||
| 		// end turn
 | ||
| 		room.TeamTurn = oppositeColor
 | ||
| 		room.MimeDone = false
 | ||
| 		room.OpenedThisTurn = 0
 | ||
| 		room.ThisTurnLimit = 0
 | ||
| 		b.StopTurnTimer()
 | ||
| 	}
 | ||
| 	// 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{
 | ||
| 			RoomID:     room.ID,
 | ||
| 			Actor:      b.BotName,
 | ||
| 			ActorColor: string(b.Team),
 | ||
| 			WordColor:  models.WordColorBlack,
 | ||
| 			Action:     models.ActionTypeGameOver,
 | ||
| 		}
 | ||
| 		room.ActionHistory = append(room.ActionHistory, action)
 | ||
| 		b.StopTurnTimer()
 | ||
| 		updateStatsOnGameOver(context.Background(), room)
 | ||
| 	}
 | ||
| 	if room.RedCounter == 0 {
 | ||
| 		// red won
 | ||
| 		room.IsRunning = false
 | ||
| 		room.IsOver = true
 | ||
| 		room.TeamWon = "red"
 | ||
| 		room.OpenedThisTurn = 0
 | ||
| 		room.ThisTurnLimit = 0
 | ||
| 		action := models.Action{
 | ||
| 			RoomID:     room.ID,
 | ||
| 			Actor:      b.BotName,
 | ||
| 			ActorColor: string(b.Team),
 | ||
| 			WordColor:  models.WordColorBlack,
 | ||
| 			Action:     models.ActionTypeGameOver,
 | ||
| 		}
 | ||
| 		room.ActionHistory = append(room.ActionHistory, action)
 | ||
| 		b.StopTurnTimer()
 | ||
| 		updateStatsOnGameOver(context.Background(), room)
 | ||
| 	}
 | ||
| 	ctx, tx, err := repo.InitTx(context.Background())
 | ||
| 	// nolint: errcheck
 | ||
| 	defer tx.Commit()
 | ||
| 	if err != nil {
 | ||
| 		b.log.Error("failed to init tx", "error", err)
 | ||
| 	}
 | ||
| 	if err := repo.ActionCreate(ctx, &action); err != nil {
 | ||
| 		// nolint: errcheck
 | ||
| 		tx.Rollback()
 | ||
| 		b.log.Error("failed to create action", "error", err, "action", action)
 | ||
| 		return err
 | ||
| 	}
 | ||
| 
 | ||
| 	if err := repo.RoomUpdate(ctx, room); err != nil {
 | ||
| 		// nolint: errcheck
 | ||
| 		tx.Rollback()
 | ||
| 		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)
 | ||
| 	room, err := repo.RoomGetExtended(context.Background(), b.RoomID)
 | ||
| 	if err != nil {
 | ||
| 		b.log.Error("bot loop", "error", err)
 | ||
| 		return
 | ||
| 	}
 | ||
| 	if room.BotFailed {
 | ||
| 		if err := repo.RoomUnSetBotFailed(context.Background(), room.ID); err != nil {
 | ||
| 			b.log.Error("failed to unset bot failed bool", "error", err)
 | ||
| 		}
 | ||
| 	}
 | ||
| 	// eventName := models.NotifyBacklogPrefix + room.ID
 | ||
| 	eventName := models.NotifyRoomUpdatePrefix + room.ID
 | ||
| 	eventPayload := ""
 | ||
| 	defer func() { // save room
 | ||
| 		// just incase, get the room once more
 | ||
| 		// room, err = repo.RoomGetExtended(context.Background(), b.RoomID)
 | ||
| 		// if err != nil {
 | ||
| 		// 	b.log.Error("bot loop", "error", err)
 | ||
| 		// 	return
 | ||
| 		// }
 | ||
| 		if err := saveRoom(room); err != nil {
 | ||
| 			b.log.Error("failed to save room", "error", err)
 | ||
| 			return
 | ||
| 		}
 | ||
| 		if botName := room.WhichBotToMove(); botName != "" {
 | ||
| 			b.log.Debug("notifying bot", "name", botName)
 | ||
| 			mapMutex.RLock()
 | ||
| 			if sigChan, ok := SignalChanMap[botName]; ok {
 | ||
| 				sigChan <- true
 | ||
| 			}
 | ||
| 			mapMutex.RUnlock()
 | ||
| 			b.log.Debug("after sending the signal", "name", botName)
 | ||
| 		}
 | ||
| 		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 {
 | ||
| 		lj := models.Journal{
 | ||
| 			Entry:    fmt.Sprintf("bot '%s' exceeded attempts to call llm;", b.BotName),
 | ||
| 			Username: b.BotName,
 | ||
| 			RoomID:   b.RoomID,
 | ||
| 		}
 | ||
| 		if err := repo.JournalCreate(context.Background(), &lj); err != nil {
 | ||
| 			b.log.Warn("failed to write to journal", "entry", lj)
 | ||
| 		}
 | ||
| 		b.log.Error("bot loop", "error", err)
 | ||
| 		if err := repo.RoomSetBotFailed(context.Background(), room.ID); err != nil {
 | ||
| 			b.log.Error("failed to set bot failed bool", "error", err)
 | ||
| 		}
 | ||
| 		return
 | ||
| 	}
 | ||
| 	tempMap, err := b.LLMParser.ParseBytes(llmResp)
 | ||
| 	if err != nil {
 | ||
| 		lj := models.Journal{
 | ||
| 			Entry:    fmt.Sprintf("bot '%s' parsing resp failed;", b.BotName),
 | ||
| 			Username: b.BotName,
 | ||
| 			RoomID:   b.RoomID,
 | ||
| 		}
 | ||
| 		if err := repo.JournalCreate(context.Background(), &lj); err != nil {
 | ||
| 			b.log.Warn("failed to write to journal", "entry", lj)
 | ||
| 		}
 | ||
| 		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))
 | ||
| 		for _, card := range room.Cards {
 | ||
| 			if strings.ToLower(card.Word) == mimeResp.Clue {
 | ||
| 				b.log.Warn("bot-mime clue is one of the words on the board; retrying", "clue", mimeResp.Clue, "bot", b.BotName)
 | ||
| 				entry := fmt.Sprintf("bot-mime '%s' gave a clue '%s' which is one of the words on the board. retrying.", b.BotName, mimeResp.Clue)
 | ||
| 				lj := models.Journal{
 | ||
| 					Entry:    entry,
 | ||
| 					Username: b.BotName,
 | ||
| 					RoomID:   room.ID,
 | ||
| 				}
 | ||
| 				room.LogJournal = append(room.LogJournal, lj)
 | ||
| 				if err := repo.JournalCreate(context.Background(), &lj); err != nil {
 | ||
| 					b.log.Warn("failed to write to journal", "entry", lj)
 | ||
| 				}
 | ||
| 				return
 | ||
| 			}
 | ||
| 		}
 | ||
| 		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{
 | ||
| 			RoomID:     room.ID,
 | ||
| 			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
 | ||
| 		// entry := fmt.Sprintf("meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
 | ||
| 		// lj := models.Journal{
 | ||
| 		// 	Entry:    entry,
 | ||
| 		// 	Username: b.BotName,
 | ||
| 		// 	RoomID:   room.ID,
 | ||
| 		// }
 | ||
| 		// room.LogJournal = append(room.LogJournal, lj)
 | ||
| 		// if err := repo.JournalCreate(context.Background(), &lj); err != nil {
 | ||
| 		// 	b.log.Warn("failed to write to journal", "entry", lj)
 | ||
| 		// }
 | ||
| 		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.OpenedThisTurn = 0 // in case it is not
 | ||
| 		room.ThisTurnLimit = uint8(guessLimitU64)
 | ||
| 		if room.ThisTurnLimit == 0 {
 | ||
| 			b.log.Warn("turn limit is 0", "mimeResp", mimeResp)
 | ||
| 			room.ThisTurnLimit = 9
 | ||
| 		}
 | ||
| 		if err := repo.ActionCreate(context.Background(), &action); err != nil {
 | ||
| 			b.log.Error("failed to create action", "error", err)
 | ||
| 			return
 | ||
| 		}
 | ||
| 		b.StartTurnTimer(room.Settings.RoundTime)
 | ||
| 		if err := saveRoom(room); err != nil {
 | ||
| 			b.log.Error("failed to save room", "error", err)
 | ||
| 			return
 | ||
| 		}
 | ||
| 	case models.UserRoleGuesser:
 | ||
| 		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.Error("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
 | ||
| 			entry := fmt.Sprintf("failed to check guess; mimeResp: %v; guess: %s; error: %v", tempMap, guess, err)
 | ||
| 			lj := models.Journal{
 | ||
| 				Entry:    entry,
 | ||
| 				Username: b.BotName,
 | ||
| 				RoomID:   room.ID,
 | ||
| 			}
 | ||
| 			room.LogJournal = append(room.LogJournal, lj)
 | ||
| 			if err := repo.JournalCreate(context.Background(), &lj); err != nil {
 | ||
| 				b.log.Warn("failed to write to journal", "entry", lj)
 | ||
| 			}
 | ||
| 		}
 | ||
| 		b.log.Info("guesser 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)
 | ||
| 		}
 | ||
| 		entry := fmt.Sprintf("%s guessed: %s; also considered this: %v", b.BotName, guess, couldBe)
 | ||
| 		lj := models.Journal{
 | ||
| 			Entry:    entry,
 | ||
| 			Username: b.BotName,
 | ||
| 			RoomID:   room.ID,
 | ||
| 		}
 | ||
| 		room.LogJournal = append(room.LogJournal, lj)
 | ||
| 		if err := repo.JournalCreate(context.Background(), &lj); err != nil {
 | ||
| 			b.log.Warn("failed to write to journal", "entry", lj)
 | ||
| 		}
 | ||
| 	default:
 | ||
| 		b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
 | ||
| 		return
 | ||
| 	}
 | ||
| }
 | ||
| 
 | ||
| // StartBot
 | ||
| func (b *Bot) StartBot() {
 | ||
| 	mapMutex.Lock()
 | ||
| 	signalChan, sOk := SignalChanMap[b.BotName]
 | ||
| 	doneChan, dOk := DoneChanMap[b.BotName]
 | ||
| 	mapMutex.Unlock()
 | ||
| 
 | ||
| 	if !sOk || !dOk {
 | ||
| 		b.log.Error("bot channels not found in map", "bot-name", b.BotName)
 | ||
| 		return
 | ||
| 	}
 | ||
| 
 | ||
| 	for {
 | ||
| 		select {
 | ||
| 		case <-signalChan:
 | ||
| 			b.BotMove()
 | ||
| 		case <-doneChan:
 | ||
| 			b.log.Debug("got done signal", "bot-name", b.BotName)
 | ||
| 			return
 | ||
| 		}
 | ||
| 	}
 | ||
| }
 | ||
| 
 | ||
| func RemoveBot(botName string, room *models.Room) error {
 | ||
| 	mapMutex.Lock()
 | ||
| 	// channels
 | ||
| 	if doneChan, ok := DoneChanMap[botName]; ok {
 | ||
| 		doneChan <- true
 | ||
| 		close(doneChan)
 | ||
| 	}
 | ||
| 	if signalChan, ok := SignalChanMap[botName]; ok {
 | ||
| 		close(signalChan)
 | ||
| 	}
 | ||
| 	// maps
 | ||
| 	delete(DoneChanMap, botName)
 | ||
| 	delete(SignalChanMap, botName)
 | ||
| 	mapMutex.Unlock()
 | ||
| 
 | ||
| 	delete(room.BotMap, botName)
 | ||
| 	// remove role from room
 | ||
| 	room.RemovePlayer(botName)
 | ||
| 	slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
 | ||
| 	if err := repo.PlayerDelete(context.Background(), botName); err != nil {
 | ||
| 		slog.Error("failed to remove bot player", "name", botName, "room_id", room.ID)
 | ||
| 		return err
 | ||
| 	}
 | ||
| 	return saveRoom(room)
 | ||
| }
 | ||
| 
 | ||
| func RemoveBotNoRoom(botName string) error {
 | ||
| 	mapMutex.Lock()
 | ||
| 	// channels
 | ||
| 	dc, ok := DoneChanMap[botName]
 | ||
| 	if ok {
 | ||
| 		dc <- true
 | ||
| 		close(DoneChanMap[botName])
 | ||
| 	}
 | ||
| 	sc, ok := SignalChanMap[botName]
 | ||
| 	if ok {
 | ||
| 		close(sc)
 | ||
| 	}
 | ||
| 	// maps
 | ||
| 	delete(DoneChanMap, botName)
 | ||
| 	delete(SignalChanMap, botName)
 | ||
| 	mapMutex.Unlock()
 | ||
| 	// remove role from room
 | ||
| 	return repo.PlayerDelete(context.Background(), botName)
 | ||
| }
 | ||
| 
 | ||
| // 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)
 | ||
| 	room, err := repo.RoomGetExtended(context.Background(), 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 !recovery {
 | ||
| 		if err := saveBot(bot); err != nil {
 | ||
| 			return nil, err
 | ||
| 		}
 | ||
| 	}
 | ||
| 	bot.log.Debug("before adding to ch map", "name", bot.BotName)
 | ||
| 	// buffered channel to send to it in the same goroutine
 | ||
| 	mapMutex.Lock()
 | ||
| 	SignalChanMap[bot.BotName] = make(chan bool, 1)
 | ||
| 	DoneChanMap[bot.BotName] = make(chan bool, 1)
 | ||
| 	mapMutex.Unlock()
 | ||
| 	bot.log.Debug("after adding to ch map", "name", bot.BotName)
 | ||
| 	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)
 | ||
| 	botPlayer := bot.ToPlayer()
 | ||
| 	return repo.PlayerAdd(context.Background(), botPlayer)
 | ||
| }
 | ||
| 
 | ||
| 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)
 | ||
| 	// ------------
 | ||
| 	// probably need to update other tables
 | ||
| 	// like word_cards or marks;
 | ||
| 	return repo.RoomUpdate(context.Background(), room)
 | ||
| }
 | ||
| 
 | ||
| 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
 | ||
| 	}
 | ||
| 	if strings.EqualFold(room.Settings.Language, "ru") {
 | ||
| 		return fmt.Sprintf(MimeSimplePromptRU, clueAction.Word, words)
 | ||
| 	}
 | ||
| 	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)
 | ||
| 		}
 | ||
| 	}
 | ||
| 	if strings.EqualFold(room.Settings.Language, "ru") {
 | ||
| 		return fmt.Sprintf(MimeSimplePromptRU, ourwords, theirwords, blackWord)
 | ||
| 	}
 | ||
| 	return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord)
 | ||
| }
 | ||
| 
 | ||
| func (b *Bot) BuildPrompt(room *models.Room) string {
 | ||
| 	if b.Role == "" {
 | ||
| 		return ""
 | ||
| 	}
 | ||
| 	if b.Role == models.UserRoleMime {
 | ||
| 		return b.BuildSimpleMimePrompt(room)
 | ||
| 	}
 | ||
| 	if b.Role == models.UserRoleGuesser {
 | ||
| 		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("LLM call failed after %d retries on request creation: %w", maxRetries, 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("LLM call failed after %d retries on client.Do: %w", maxRetries, 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("LLM call failed after %d retries on reading body: %w", maxRetries, 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("LLM call failed after %d retries, 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
 | ||
| 	}
 | ||
| 	return nil, errors.New("unknown error in retry loop")
 | ||
| }
 | 
