Files
gralias/llmapi/main.go
2025-06-15 11:44:39 +03:00

399 lines
12 KiB
Go

package llmapi
import (
"encoding/json"
"errors"
"fmt"
"gralias/broker"
"gralias/config"
"gralias/models"
"gralias/pkg/cache"
"io"
"log/slog"
"net/http"
"os"
"strings"
)
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 game info in json:\n%s`
)
type DSResp struct {
ID string `json:"id"`
Choices []struct {
Text string `json:"text"`
Index int `json:"index"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Created int `json:"created"`
Model string `json:"model"`
SystemFingerprint string `json:"system_fingerprint"`
Object string `json:"object"`
}
// type LLMResp struct {
// Choices []struct {
// FinishReason string `json:"finish_reason"`
// Index int `json:"index"`
// Message struct {
// Content string `json:"content"`
// Role string `json:"role"`
// } `json:"message"`
// } `json:"choices"`
// Created int `json:"created"`
// Model string `json:"model"`
// Object string `json:"object"`
// }
type LLMResp struct {
Index int `json:"index"`
Content string `json:"content"`
Tokens []any `json:"tokens"`
IDSlot int `json:"id_slot"`
Stop bool `json:"stop"`
Model string `json:"model"`
TokensPredicted int `json:"tokens_predicted"`
TokensEvaluated int `json:"tokens_evaluated"`
Prompt string `json:"prompt"`
HasNewLine bool `json:"has_new_line"`
Truncated bool `json:"truncated"`
StopType string `json:"stop_type"`
StoppingWord string `json:"stopping_word"`
TokensCached int `json:"tokens_cached"`
}
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 `json:"role"`
Team string `json:"team"`
cfg *config.Config `json:"-"`
RoomID string `json:"room_id"` // can we get a room from here?
BotName string `json:"bot_name"`
log *slog.Logger `json:"-"`
LLMParser RespParser `json:"-"`
// 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
}
tempMap, err := b.LLMParser.ParseBytes(llmResp)
if err != nil {
b.log.Error("bot loop", "error", err)
continue
}
eventName := models.NotifyBacklogPrefix + room.ID
eventPayload := ""
switch b.Role {
case models.UserRoleMime:
mimeResp := MimeResp{}
b.log.Info("mime resp log", "mimeResp", tempMap)
mimeResp.Clue = tempMap["clue"].(string)
mimeResp.Number = tempMap["number"].(string)
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
eventPayload = mimeResp.Clue + mimeResp.Number
case models.UserRoleGuesser:
for _, word := range tempMap["guesses"].([]string) {
color, exists := room.WCMap[word]
b.log.Debug("bot trying to open card", "word", word, "color",
color, "exists", exists)
if !exists {
return
}
room.RevealSpecificWord(word)
room.UpdateCounter()
action := models.Action{
Actor: b.BotName,
ActorColor: b.Team,
WordColor: string(color),
Action: "guessed",
Word: word,
}
room.ActionHistory = append(room.ActionHistory, action)
// if opened card is of color of opp team, change turn
oppositeColor := room.GetOppositeTeamColor()
switch string(color) {
case "black":
// game over
room.IsRunning = false
room.IsOver = true
room.TeamWon = oppositeColor
case "white", string(oppositeColor):
// end turn
room.TeamTurn = oppositeColor
room.MimeDone = false
}
// check if no cards left => game over
if room.BlueCounter == 0 {
// blue won
room.IsRunning = false
room.IsOver = true
room.TeamWon = "blue"
}
if room.RedCounter == 0 {
// red won
room.IsRunning = false
room.IsOver = true
room.TeamWon = "red"
}
if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "room", room)
return
}
}
b.log.Info("mime resp log", "guesserResp", tempMap)
eventName = models.NotifyRoomUpdatePrefix + room.ID
eventPayload = ""
default:
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
return
}
// save room
if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err)
return
}
if botName := room.WhichBotToMove(); botName != "" {
SignalChanMap[botName] <- true
}
broker.Notifier.Notifier <- broker.NotificationEvent{
EventName: eventName,
Payload: eventPayload,
}
// 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,
})),
}
// 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)
}
// 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),
}
// 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 {
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 = 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 := 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) 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 := make([]models.WordCard, len(room.Cards))
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, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
}
if b.Role == models.UserRoleGuesser {
return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
}
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), "url", b.cfg.LLMConfig.URL)
return body, nil
}