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")
|
||
}
|