Files
gralias/llmapi/main.go
2025-07-14 19:04:35 +03:00

668 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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