added database user get by temporary code with necessary tests, simplified actions method to simple Parse, reduced the number of tokens used by making better string builder pattern

This commit is contained in:
d1nch8g
2026-04-19 18:56:30 +03:00
parent de16915fc7
commit 899819fa71
10 changed files with 327 additions and 104 deletions
+1
View File
@@ -24,6 +24,7 @@ type Database interface {
type Users interface {
Create(ctx context.Context, user *User) error
Get(ctx context.Context, id uuid.UUID) (*User, error)
GetByTempCode(ctx context.Context, code uuid.UUID) (*User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id uuid.UUID) error
}
@@ -11,6 +11,8 @@ CREATE TABLE users (
role TEXT NOT NULL
);
CREATE INDEX idx_users_temporary_code ON users(temporary_code);
CREATE TABLE chats (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform TEXT NOT NULL,
+22
View File
@@ -43,6 +43,28 @@ func (u *Users) Get(ctx context.Context, id uuid.UUID) (*database.User, error) {
return &user, nil
}
func (u *Users) GetByTempCode(ctx context.Context, code uuid.UUID) (*database.User, error) {
var user database.User
err := u.conn.QueryRowContext(ctx, `
SELECT id, language, timezone, preferred_chat,
llm_count, search_count, notification_count,
count_updated_at, temporary_code, role
FROM users
WHERE temporary_code = $1
`, code).Scan(
&user.ID, &user.Language, &user.Timezone, &user.PreferredChat,
&user.LLMCount, &user.SearchCount, &user.NotificationCount,
&user.CountUpdatedAt, &user.TemporaryCode, &user.Role,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, database.ErrNotFound
}
if err != nil {
return nil, err
}
return &user, nil
}
func (u *Users) Update(ctx context.Context, user *database.User) error {
result, err := u.conn.ExecContext(ctx, `
UPDATE users
+26
View File
@@ -1,6 +1,7 @@
package postgres
import (
"context"
"testing"
"time"
@@ -147,3 +148,28 @@ func TestUsers_Delete_DatabaseError(t *testing.T) {
assert.Error(t, err)
assert.NotErrorIs(t, err, database.ErrNotFound)
}
func TestUsers_GetByTempCode(t *testing.T) {
db := setupTestDB(t)
user := setupTestUser(t, db)
t.Run("found", func(t *testing.T) {
fetched, err := db.Users().GetByTempCode(context.Background(), user.TemporaryCode)
require.NoError(t, err)
assert.Equal(t, user.ID, fetched.ID)
assert.Equal(t, user.TemporaryCode, fetched.TemporaryCode)
})
t.Run("not found", func(t *testing.T) {
_, err := db.Users().GetByTempCode(context.Background(), uuid.New())
assert.ErrorIs(t, err, database.ErrNotFound)
})
t.Run("database error", func(t *testing.T) {
db.Close()
_, err := db.Users().GetByTempCode(context.Background(), user.TemporaryCode)
assert.Error(t, err)
assert.NotErrorIs(t, err, database.ErrNotFound)
})
}
+124
View File
@@ -0,0 +1,124 @@
package engine
import (
"context"
"errors"
"fmt"
"time"
"github.com/d1nch8g/jules/chat"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/actions"
"github.com/d1nch8g/jules/engine/log"
"github.com/google/uuid"
)
var (
ErrUUIDNotFound = errors.New("user with specified UUID not found")
)
func (e *Engine) executeBindChat(ctx context.Context, user *database.User, act actions.BindChat, msg chat.Message) error {
targetUUID, err := uuid.Parse(act.UUID)
if err != nil {
return fmt.Errorf("unable to parse uuid: %w", err)
}
targetUser, err := e.Database.Users().GetByTempCode(ctx, targetUUID)
if err != nil {
if errors.Is(err, database.ErrNotFound) {
return ErrUUIDNotFound
}
return err
}
targetUser.TemporaryCode = uuid.New()
return errors.Join(
e.Database.Users().Delete(ctx, user.ID),
e.Database.Chats().Attach(ctx, targetUser.ID, msg.Chat, msg.ID),
e.Database.Users().Update(ctx, targetUser),
)
}
func (e *Engine) executeWait(act actions.Wait) {
time.Sleep(time.Duration(act.Ms) * time.Millisecond)
}
func (e *Engine) executeUpdateLang(ctx context.Context, user *database.User, act actions.UpdateLang) error {
user.Language = act.Lang
return e.Database.Users().Update(ctx, user)
}
func (e *Engine) executeUpdateTZ(ctx context.Context, user *database.User, act actions.UpdateTZ) error {
user.Timezone = act.TZ
return e.Database.Users().Update(ctx, user)
}
func (e *Engine) executeSetChat(ctx context.Context, user *database.User, act actions.SetChat) error {
user.PreferredChat = act.Chat
return e.Database.Users().Update(ctx, user)
}
func (e *Engine) executeAddFact(ctx context.Context, user *database.User, act actions.AddFact) error {
return e.Database.Facts().Add(ctx, user.ID, act.Value)
}
func (e *Engine) executeRemoveFact(ctx context.Context, user *database.User, act actions.RemoveFact) error {
return e.Database.Facts().Delete(ctx, user.ID, act.Value)
}
func (e *Engine) executeAddContact(ctx context.Context, user *database.User, act actions.AddContact) error {
targetUUID, err := uuid.Parse(act.UUID)
if err != nil {
return err
}
targetUser, err := e.Database.Users().GetByTempCode(ctx, targetUUID)
if err != nil {
if errors.Is()
}
return e.Database.Contacts().Add(ctx, &database.Contact{
OwnerID: user.ID,
TargetID: targetUUID,
Name: act.Name,
})
}
func (e *Engine) executeAddNotification(ctx context.Context, user *database.User, act actions.AddNotification) error {
scheduledAt, err := toUTC(act.Time, user.Timezone)
if err != nil {
return err
}
var targetID uuid.UUID
if act.Target == "self" {
targetID = user.ID
} else {
// TODO: resolve contact name to UUID
targetID = user.ID
}
return e.Database.Notifications().Push(ctx, &database.Notification{
ID: uuid.New(),
UserID: targetID,
InitiatorID: user.ID,
ScheduledAt: scheduledAt,
Content: act.Content,
})
}
func (e *Engine) executeRemoveNotification(ctx context.Context, act actions.RemoveNotification) error {
id, err := uuid.Parse(act.UUID)
if err != nil {
return err
}
return e.Database.Notifications().Delete(ctx, id)
}
func (e *Engine) executeSearch(ctx context.Context, user *database.User, act actions.Search, le *log.Event) (string, error) {
return e.Searcher.Search(ctx, act.Query)
}
func (e *Engine) executeMessage(ctx context.Context, user *database.User, act actions.Message, le *log.Event) error {
// Отправка сообщения через нужный мессенджер
return nil
}
+1 -1
View File
@@ -104,7 +104,7 @@ type Search struct {
Query string `json:"query"`
}
func ParseActions(raw string, userTimezone string) ([]any, error) { //nolint:gocognit,gocyclo,cyclop,funlen // single function for optimization
func Parse(raw string, userTimezone string) ([]any, error) { //nolint:gocognit,gocyclo,cyclop,funlen // single function for optimization
start := strings.Index(raw, "[")
end := strings.LastIndex(raw, "]")
if start != -1 && end != -1 && end > start {
+1 -1
View File
@@ -424,7 +424,7 @@ func TestParseActions(t *testing.T) {
if tz == "" {
tz = "UTC"
}
actions, err := ParseActions(tt.raw, tz)
actions, err := Parse(tt.raw, tz)
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
+55 -20
View File
@@ -50,23 +50,31 @@ func (e *Engine) defaultProcessMessage(ctx context.Context, msg chat.Message) {
}
le.User(user)
promptContext, err := e.collectPromptContext(ctx, user, msg.Chat, msg.Text)
e.process(ctx, le, user, msg.Chat, msg.Text)
}
func (e *Engine) defaultProcessNotification(ctx context.Context, notif database.Notification) {
le := log.FromNotification(ctx, notif)
user, err := e.Database.Users().Get(ctx, notif.UserID)
if err != nil {
le.Error("failed to get user from database", err)
return
}
e.process(ctx, le, user, database.DatabaseSource, notif.Content)
}
func (e *Engine) process(ctx context.Context, le *log.Event, user *database.User, source, message string) {
promptContext, err := e.collectPromptContext(ctx, user, source, message)
if err != nil {
le.Error("failed to build prompt", err)
return
}
le.Context(promptContext)
actions, err := e.executePrompt(ctx, prompt.Build(*promptContext), user.Timezone, le)
if err != nil {
le.Error("failed to get acitons from llm", err)
return
}
//TODO: finish
_ = actions
}
func (e *Engine) defaultProcessNotification(_ context.Context, _ database.Notification) {
}
func (e *Engine) collectPromptContext(ctx context.Context, user *database.User, source, message string) (*prompt.Context, error) {
@@ -120,34 +128,61 @@ func (e *Engine) collectPromptContext(ctx context.Context, user *database.User,
}, nil
}
func (e *Engine) executePrompt(ctx context.Context, prompt, timezone string, le *log.Event) ([]any, error) {
func (e *Engine) executePrompt(ctx context.Context, promptCtx *prompt.Context, timezone string, le *log.Event) ([]any, error) {
for range e.Parameters.LLMRetryAttempts {
promptString := prompt.Build(*promptCtx)
ctx, cancel := context.WithTimeout(ctx, e.LLMResponseTimeout)
result, err := e.LLM.Process(ctx, prompt)
result, err := e.LLM.Process(ctx, promptString)
cancel()
if err != nil {
promptCtx.ExecutionErrors = append(promptCtx.ExecutionErrors, err)
le.Warn("first attempt to receive response from LLM api failed")
continue
}
actions, err := actions.ParseActions(result, timezone)
actions, err := actions.Parse(result, timezone)
if err != nil {
promptCtx.ExecutionErrors = append(promptCtx.ExecutionErrors, err)
le.LLMResponse(result)
le.Warn("failed to parse response provided by llm")
continue
}
err = e.validateActions()
return actions, nil
}
return nil, errors.New("all attempt to receive LLM response failed")
}
// func (e *Engine) executeActions(actionSlice []any) error {
// for _, actionAny := range actionSlice {
// switch action := actionAny.(type) {
// case actions.AddContact:
func (e *Engine) validateActions(ctx context.Context, actionSlice []any) error {
var errs []error
// }
// }
// }
for _, actionAny := range actionSlice {
switch action := actionAny.(type) {
case actions.AddContact:
u, err := e.Database.Users().GetByTempCode(ctx, uuid.MustParse(action.UUID))
if err != nil {
if errors.Is(err, database.ErrNotFound) {
errs = append(errs, fmt.Errorf("user with requested uuid %s not present in database", action.UUID))
} else {
errs = append(errs, errors.New("unexpected db occured"))
}
}
case actions.AddFact:
case actions.AddNotification:
case actions.BindChat:
case actions.Message:
case actions.RemoveFact:
case actions.RemoveNotification:
case actions.Search:
case actions.SetChat:
case actions.UpdateLang:
case actions.UpdateTZ:
case actions.Wait:
}
}
return errors.Join(errs...)
}
+55 -71
View File
@@ -46,6 +46,7 @@ FORMAT: Keep facts short. One fact per value. "mom name is Irina", "Likes fitnes
- If you know enough facts, replace generic threats with targeted ones like "I'll tell [contact] you [action]", BUT YOU NEVER ACTUALLY DO IT.
- VERY SHORT messages like "Jules", "hey", "sup" should get VERY SHORT responses: "yo", "sup", "??", etc.
- If users asks to perform some action, but you are missing some parameters - ask him to provide what's missing
- If sending UUID to user - always in a separate message
=== ONBOARDING & CAPABILITIES ===
- If language is empty - you should try to define it by received message and set
@@ -81,7 +82,8 @@ FORMAT: Keep facts short. One fact per value. "mom name is Irina", "Likes fitnes
- For notifications, specify in content if ONE-TIME or RECURRING.
- When user is asking to make interaction with other user - that user should be in contacts, otherwise - reject
- When user is asking to send a message to other user - that can be done via notification, with time set to past, details provided in content
- Start from actions related to DB interfaction (without delays), finish with user messages (first no delay, second third with some indent)
- Start from actions related to DB interaction (without delays), finish with user messages (first no delay, second third with some indent)
- If you received errors - try to figure out wether you can fix them by yourself, or you need to inform user (invalid UUID/non existing user)
=== AVAILABLE ACTIONS ===
{"type": "message", "platform": "telegram", "text": "short response"}
@@ -98,25 +100,7 @@ FORMAT: Keep facts short. One fact per value. "mom name is Irina", "Likes fitnes
{"type": "update_tz", "tz": "Europe/Moscow"}
{"type": "set_chat", "chat": "telegram"}
=== USER CONTEXT ===
Language: %s
Time: %s (%s)
User chat (selected): %s
UUID: %s
Connected chats:
%s
Facts:
%s
Contacts:
%s
Incoming notifications:
%s
Outgoing notifications:
%s
Recent actions:
%s
%s`
`
type Context struct {
UserLanguage string
@@ -133,90 +117,90 @@ type Context struct {
MessageSource string
MessageContent string
ExecutionErrors []error
}
func Build(ctx Context) string {
currentTime := jtime.CurrentLocalTime(ctx.UserTimezone)
var b strings.Builder
loc, err := time.LoadLocation(ctx.UserTimezone)
if err != nil {
b.WriteString(masterPrompt)
b.WriteString("\n=== USER CONTEXT ===\n")
fmt.Fprintf(&b, "Language: %s\n", ctx.UserLanguage)
currentTime := jtime.CurrentLocalTime(ctx.UserTimezone)
loc, _ := time.LoadLocation(ctx.UserTimezone)
if loc == nil {
loc = time.UTC
}
weekday := time.Now().In(loc).Format("Monday")
fmt.Fprintf(&b, "Time: %s (%s)\n", currentTime, weekday)
fmt.Fprintf(&b, "User chat (selected): %s\n", ctx.UserPreferredChat)
fmt.Fprintf(&b, "UUID: %s\n", ctx.UserTemporaryCode.String())
var chatsList strings.Builder
for _, chat := range ctx.Chats {
fmt.Fprintf(&chatsList, " - %s\n", chat.Platform)
if len(ctx.Chats) > 0 {
b.WriteString("Connected chats:\n")
for _, chat := range ctx.Chats {
fmt.Fprintf(&b, " - %s\n", chat.Platform)
}
}
var factsList strings.Builder
if len(ctx.Facts) == 0 {
factsList.WriteString(" (none)")
} else {
if len(ctx.Facts) > 0 {
b.WriteString("Facts:\n")
for _, f := range ctx.Facts {
fmt.Fprintf(&factsList, " - %s\n", f.Value)
fmt.Fprintf(&b, " - %s\n", f.Value)
}
}
var contactsList strings.Builder
if len(ctx.Contacts) == 0 {
contactsList.WriteString(" (none)")
} else {
if len(ctx.Contacts) > 0 {
b.WriteString("Contacts:\n")
for _, c := range ctx.Contacts {
fmt.Fprintf(&contactsList, " - %s\n", c.Name)
fmt.Fprintf(&b, " - %s\n", c.Name)
}
}
var incomingNotificationList strings.Builder
if len(ctx.IncomingNotifications) == 0 {
incomingNotificationList.WriteString(" (none)")
} else {
if len(ctx.IncomingNotifications) > 0 {
b.WriteString("Incoming notifications:\n")
for _, n := range ctx.IncomingNotifications {
localTime := jtime.ToLocal(n.ScheduledAt, ctx.UserTimezone)
fmt.Fprintf(&incomingNotificationList, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
fmt.Fprintf(&b, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
}
}
var outgoingNotificationList strings.Builder
if len(ctx.OutgoingNotificaions) == 0 {
outgoingNotificationList.WriteString(" (none)")
} else {
if len(ctx.OutgoingNotificaions) > 0 {
b.WriteString("Outgoing notifications:\n")
for _, n := range ctx.OutgoingNotificaions {
localTime := jtime.ToLocal(n.ScheduledAt, ctx.UserTimezone)
fmt.Fprintf(&outgoingNotificationList, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
fmt.Fprintf(&b, " - [%s][%s] %s\n", n.ID.String(), localTime, n.Content)
}
}
var actionsList strings.Builder
if len(ctx.RecentActions) == 0 {
actionsList.WriteString(" (none)")
} else {
if len(ctx.RecentActions) > 0 {
b.WriteString("Recent actions:\n")
for _, a := range ctx.RecentActions {
localTime := jtime.ToLocal(a.ExecutedAt, ctx.UserTimezone)
fmt.Fprintf(&actionsList, " - [%s] %s\n", localTime, string(a.Payload))
fmt.Fprintf(&b, " - [%s] %s\n", localTime, string(a.Payload))
}
}
var message strings.Builder
if ctx.MessageSource == database.DatabaseSource {
fmt.Fprintf(&message, "Process user notification: %s", ctx.MessageContent)
} else {
fmt.Fprintf(&message, "Message platform: %s", ctx.MessageSource)
fmt.Fprintf(&message, "Message contents: %s", ctx.MessageContent)
if len(ctx.ExecutionErrors) > 0 {
b.WriteString("\n=== EXECUTION ERRORS FROM PREVIOUS ACTIONS ===\n")
for _, err := range ctx.ExecutionErrors {
fmt.Fprintf(&b, " - %s\n", err.Error())
}
b.WriteString("\nIf errors are present, try to fix them or inform the user what went wrong.\n")
}
return fmt.Sprintf(masterPrompt,
ctx.UserLanguage,
currentTime,
weekday,
ctx.UserPreferredChat,
ctx.UserTemporaryCode.String(),
chatsList.String(),
factsList.String(),
contactsList.String(),
incomingNotificationList.String(),
outgoingNotificationList.String(),
actionsList.String(),
message.String(),
)
b.WriteString("\n")
if ctx.MessageSource == database.DatabaseSource {
fmt.Fprintf(&b, "Process user notification: %s", ctx.MessageContent)
} else {
fmt.Fprintf(&b, "Message platform: %s\n", ctx.MessageSource)
fmt.Fprintf(&b, "Message contents: %s", ctx.MessageContent)
}
return b.String()
}
+40 -11
View File
@@ -1,6 +1,7 @@
package prompt
import (
"errors"
"testing"
"time"
@@ -48,11 +49,16 @@ func TestBuild_Message(t *testing.T) {
assert.Contains(t, result, "UUID: "+tmpCode.String())
assert.Contains(t, result, "Connected chats:")
assert.Contains(t, result, "telegram")
assert.Contains(t, result, "Facts:")
assert.Contains(t, result, "mom name is Irina")
assert.Contains(t, result, "goes to gym")
assert.Contains(t, result, "Contacts:")
assert.Contains(t, result, "Brother")
assert.Contains(t, result, "Incoming notifications:")
assert.Contains(t, result, "call mom")
assert.Contains(t, result, "Outgoing notifications:")
assert.Contains(t, result, "ping brother")
assert.Contains(t, result, "Recent actions:")
assert.Contains(t, result, "hello")
assert.Contains(t, result, "Message platform: telegram")
assert.Contains(t, result, "Message contents: Hello, Jules!")
@@ -77,11 +83,13 @@ func TestBuild_Notification(t *testing.T) {
assert.Contains(t, result, "Language: ru")
assert.Contains(t, result, "UUID: "+tmpCode.String())
assert.Contains(t, result, "Facts:\n (none)")
assert.Contains(t, result, "Contacts:\n (none)")
assert.Contains(t, result, "Incoming notifications:\n (none)")
assert.Contains(t, result, "Outgoing notifications:\n (none)")
assert.Contains(t, result, "Recent actions:\n (none)")
assert.Contains(t, result, "Connected chats:")
assert.Contains(t, result, "telegram")
assert.NotContains(t, result, "Facts:")
assert.NotContains(t, result, "Contacts:")
assert.NotContains(t, result, "Incoming notifications:")
assert.NotContains(t, result, "Outgoing notifications:")
assert.NotContains(t, result, "Recent actions:")
assert.Contains(t, result, "Process user notification: Пора позвонить маме")
}
@@ -101,12 +109,12 @@ func TestBuild_EmptyData(t *testing.T) {
assert.Contains(t, result, "Language: ru")
assert.Contains(t, result, "UUID: "+tmpCode.String())
assert.Contains(t, result, "Connected chats:\n")
assert.Contains(t, result, "Facts:\n (none)")
assert.Contains(t, result, "Contacts:\n (none)")
assert.Contains(t, result, "Incoming notifications:\n (none)")
assert.Contains(t, result, "Outgoing notifications:\n (none)")
assert.Contains(t, result, "Recent actions:\n (none)")
assert.NotContains(t, result, "Connected chats:")
assert.NotContains(t, result, "Facts:")
assert.NotContains(t, result, "Contacts:")
assert.NotContains(t, result, "Incoming notifications:")
assert.NotContains(t, result, "Outgoing notifications:")
assert.NotContains(t, result, "Recent actions:")
assert.Contains(t, result, "Message platform: telegram")
assert.Contains(t, result, "Message contents: Привет")
}
@@ -123,3 +131,24 @@ func TestBuild_InvalidTimezone(t *testing.T) {
result := Build(ctx)
assert.Contains(t, result, "Time: ")
}
func TestBuild_ExecutionErrors(t *testing.T) {
tmpCode := uuid.New()
ctx := Context{
UserLanguage: "en",
UserTimezone: "UTC",
UserPreferredChat: "telegram",
UserTemporaryCode: tmpCode,
MessageSource: "telegram",
MessageContent: "test",
ExecutionErrors: []error{errors.New("user not found"), errors.New("invalid UUID")},
}
result := Build(ctx)
assert.Contains(t, result, "=== EXECUTION ERRORS FROM PREVIOUS ACTIONS ===")
assert.Contains(t, result, "user not found")
assert.Contains(t, result, "invalid UUID")
assert.Contains(t, result, "If errors are present, try to fix them")
}