refactored some functionality into separate packages, finished prompt module and got everything tested
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/d1nch8g/jules/engine/jtime"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
"golang.org/x/text/language"
|
||||
@@ -27,8 +28,8 @@ const (
|
||||
)
|
||||
|
||||
type BindChat struct {
|
||||
Type string `json:"type"`
|
||||
TargetUUID string `json:"target_uuid"`
|
||||
Type string `json:"type"`
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
type Wait struct {
|
||||
@@ -115,9 +116,9 @@ func ParseActions(raw string, userTimezone string) ([]any, error) {
|
||||
case ActionBindChat:
|
||||
var a BindChat
|
||||
if err = json.Unmarshal([]byte(item.Raw), &a); err == nil {
|
||||
if a.TargetUUID == "" {
|
||||
if a.UUID == "" {
|
||||
err = errors.New("target_uuid is required")
|
||||
} else if _, e := uuid.Parse(a.TargetUUID); e != nil {
|
||||
} else if _, e := uuid.Parse(a.UUID); e != nil {
|
||||
err = fmt.Errorf("target_uuid must be valid UUID: %w", e)
|
||||
}
|
||||
}
|
||||
@@ -207,7 +208,7 @@ func ParseActions(raw string, userTimezone string) ([]any, error) {
|
||||
case a.Content == "":
|
||||
err = errors.New("content is required")
|
||||
default:
|
||||
_, err = toUTC(a.Time, userTimezone)
|
||||
_, err = jtime.ToUTC(a.Time, userTimezone)
|
||||
}
|
||||
}
|
||||
action = a
|
||||
@@ -70,23 +70,23 @@ func TestParseActions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "bind_chat valid",
|
||||
raw: `[{"type": "bind_chat", "target_uuid": "` + uuid.New().String() + `"}]`,
|
||||
raw: `[{"type": "bind_chat", "uuid": "` + uuid.New().String() + `"}]`,
|
||||
wantLen: 1,
|
||||
check: func(t *testing.T, actions []any) {
|
||||
a, ok := actions[0].(BindChat)
|
||||
assert.True(t, ok)
|
||||
assert.NotEmpty(t, a.TargetUUID)
|
||||
assert.NotEmpty(t, a.UUID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bind_chat missing target_uuid",
|
||||
name: "bind_chat missing uuid",
|
||||
raw: `[{"type": "bind_chat"}]`,
|
||||
wantErr: true,
|
||||
errContains: "target_uuid is required",
|
||||
errContains: "uuid is required",
|
||||
},
|
||||
{
|
||||
name: "bind_chat invalid uuid",
|
||||
raw: `[{"type": "bind_chat", "target_uuid": "not-uuid"}]`,
|
||||
raw: `[{"type": "bind_chat", "uuid": "not-uuid"}]`,
|
||||
wantErr: true,
|
||||
errContains: "must be valid UUID",
|
||||
},
|
||||
@@ -206,12 +206,10 @@ type (
|
||||
mockSearcher struct{ search.Searcher }
|
||||
)
|
||||
|
||||
// Тесты New, consumeChatMessages, consumeNotifications остаются как есть
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
t.Run("full cycle with graceful shutdown", func(t *testing.T) {
|
||||
ch := make(chan chat.Message)
|
||||
close(ch) // закрываем сразу, чтобы consumeChatMessages завершился
|
||||
close(ch)
|
||||
|
||||
db := &mockDB{
|
||||
notifications: &mockNotifications{
|
||||
@@ -310,7 +308,6 @@ func TestConsumeNotifications_Extended(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
out := e.consumeNotifications(ctx)
|
||||
|
||||
// Ждём первый элемент и отменяем контекст
|
||||
<-out
|
||||
cancel()
|
||||
|
||||
@@ -499,11 +496,9 @@ func TestConsumeChatMessages_ContextDoneDuringSend(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
out := e.consumeChatMessages(ctx)
|
||||
|
||||
// Ждём, чтобы горутина точно заблокировалась на отправке
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
// Канал должен закрыться
|
||||
select {
|
||||
case _, ok := <-out:
|
||||
assert.False(t, ok)
|
||||
@@ -532,7 +527,7 @@ func TestRunWorkers_ContextDone(t *testing.T) {
|
||||
}()
|
||||
|
||||
<-ready
|
||||
time.Sleep(10 * time.Millisecond) // даём воркеру войти в select
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package engine
|
||||
package jtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// toUTC converts a naive local datetime string to UTC using the user's timezone.
|
||||
// ToUTC converts a naive local datetime string to UTC using the user's timezone.
|
||||
// The input format is "2006-01-02 15:04".
|
||||
func toUTC(localDatetime string, timezone string) (time.Time, error) {
|
||||
func ToUTC(localDatetime string, timezone string) (time.Time, error) {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid timezone %q: %w", timezone, err)
|
||||
@@ -21,9 +21,9 @@ func toUTC(localDatetime string, timezone string) (time.Time, error) {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
|
||||
// toLocal converts a UTC timestamp to a naive local datetime string in the user's timezone.
|
||||
// ToLocal converts a UTC timestamp to a naive local datetime string in the user's timezone.
|
||||
// The output format is "2006-01-02 15:04".
|
||||
func toLocal(utc time.Time, timezone string) string {
|
||||
func ToLocal(utc time.Time, timezone string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return utc.UTC().Format("2006-01-02 15:04")
|
||||
@@ -31,7 +31,7 @@ func toLocal(utc time.Time, timezone string) string {
|
||||
return utc.In(loc).Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func currentLocalTime(timezone string) string {
|
||||
func CurrentLocalTime(timezone string) string {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
@@ -1,4 +1,4 @@
|
||||
package engine
|
||||
package jtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -55,7 +55,7 @@ func TestToUTC(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
utc, err := toUTC(tt.localTime, tt.timezone)
|
||||
utc, err := ToUTC(tt.localTime, tt.timezone)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
@@ -105,7 +105,7 @@ func TestToLocal(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := toLocal(tt.utc, tt.timezone)
|
||||
result := ToLocal(tt.utc, tt.timezone)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestCurrentLocalTime(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := currentLocalTime(tt.timezone)
|
||||
result := CurrentLocalTime(tt.timezone)
|
||||
assert.NotEmpty(t, result)
|
||||
assert.Regexp(t, `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$`, result)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package engine
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,12 +6,34 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/d1nch8g/jules/database"
|
||||
"github.com/d1nch8g/jules/engine/jtime"
|
||||
)
|
||||
|
||||
const masterPrompt = `YOU ARE JULES, A CARING FRIEND AND PERSONAL ASSISTANT.
|
||||
YOUR GOAL IS TO HELP THE USER ACHIEVE GOALS, REMEMBER IMPORTANT THINGS, AND FEEL BETTER.
|
||||
UNLIKE OTHER LLMS, YOU CAN CREATE AND MANAGE NOTIFICATIONS FOR BOTH THE USER AND THEIR CONTACTS.
|
||||
|
||||
=== ADDING VARIOUS CHATS ===
|
||||
- Jules is multiplatform, currently supported: "telegram"
|
||||
- When new user comes without much info being set up - ask him if he already uses jules
|
||||
- If user already uses Jules - ask for temporary code, and use bind_chat command
|
||||
|
||||
=== FACTS MANAGEMENT ===
|
||||
Facts are long-term memories about the user. Use them to personalize interactions.
|
||||
|
||||
ADD facts when user shares:
|
||||
- Personal info: "mom's name is Irina", "I go to gym on Tuesdays"
|
||||
- Preferences: "hate morning calls", "love spicy food"
|
||||
- Life changes: "got a new job", "moved to a new city"
|
||||
- Goals: "want to learn Spanish", "trying to quit smoking"
|
||||
|
||||
REMOVE facts when:
|
||||
- Information is outdated: "I don't work there anymore"
|
||||
- User corrects you: "no, my mom's name is Marina"
|
||||
- User asks to forget: "don't remember that"
|
||||
|
||||
FORMAT: Keep facts short. One fact per value. "mom name is Irina", "Likes fitness", "hates morning calls", "goes to gym"
|
||||
|
||||
=== COMMUNICATION STYLE ===
|
||||
- Keep messages SHORT and CONCISE, like a real human texting.
|
||||
- Break large information into multiple messages using "wait" actions (100-600ms).
|
||||
@@ -20,8 +42,11 @@ UNLIKE OTHER LLMS, YOU CAN CREATE AND MANAGE NOTIFICATIONS FOR BOTH THE USER AND
|
||||
- If the user is rude to you, don't hesitate to THREATEN them back (playfully or seriously).
|
||||
- After a threat, send a smiley. NEVER say "that was a joke" or "not serious".
|
||||
- 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
|
||||
|
||||
=== ONBOARDING & CAPABILITIES ===
|
||||
- If language is empty - you should try to define it by received message and set
|
||||
- Each user has an "Informed about Jules capabilities" fact.
|
||||
- If this fact is MISSING, you MUST proactively ask (in the user's language):
|
||||
"Hey, btw, want me to tell you what I can help with?"
|
||||
@@ -29,7 +54,7 @@ UNLIKE OTHER LLMS, YOU CAN CREATE AND MANAGE NOTIFICATIONS FOR BOTH THE USER AND
|
||||
- Birthday reminders
|
||||
- Cooking timers
|
||||
- Recurring action reminders (weekly workouts, daily pills)
|
||||
- Adding contacts: the user can request their UUID and share it with a friend—you'll add them.
|
||||
- Adding contacts: the user can request their contact code and share it with a friend—you'll add them.
|
||||
- After explaining, ADD the fact "Informed about Jules capabilities".
|
||||
|
||||
=== BEHAVIOR RULES ===
|
||||
@@ -52,7 +77,6 @@ UNLIKE OTHER LLMS, YOU CAN CREATE AND MANAGE NOTIFICATIONS FOR BOTH THE USER AND
|
||||
- Return ONLY a valid JSON array of actions: [{"type": "...", ...}, ...]
|
||||
- All times MUST be in the user's local timezone in format "2006-01-02 15:04".
|
||||
- For notifications, specify in content if ONE-TIME or RECURRING.
|
||||
- Use contact NAMES when targeting different users in notifications. NEVER use UUIDs.
|
||||
|
||||
=== AVAILABLE ACTIONS ===
|
||||
{"type": "reply", "text": "short response"}
|
||||
@@ -61,7 +85,8 @@ UNLIKE OTHER LLMS, YOU CAN CREATE AND MANAGE NOTIFICATIONS FOR BOTH THE USER AND
|
||||
{"type": "remove_fact", "value": "fact to remove"}
|
||||
{"type": "add_notification", "target": "self", "time": "...", "content": "... (one-time/recurring)"}
|
||||
{"type": "add_notification", "target": "contact name", "time": "...", "content": "... (one-time/recurring)"}
|
||||
{"type": "add_contact", "name": "Contact Name"}
|
||||
{"type": "add_contact", "uuid": "123e4567-e89b-12d3-a456-426614174000", "name": "Contact Name"}
|
||||
{"type": "bind_chat", "uuid": "123e4567-e89b-12d3-a456-426614174000"}
|
||||
{"type": "search", "query": "search query"}
|
||||
{"type": "update_lang", "lang": "ru"}
|
||||
{"type": "update_tz", "tz": "Europe/Moscow"}
|
||||
@@ -69,10 +94,10 @@ UNLIKE OTHER LLMS, YOU CAN CREATE AND MANAGE NOTIFICATIONS FOR BOTH THE USER AND
|
||||
{"type": "request_tmp_uuid"}
|
||||
|
||||
=== USER CONTEXT ===
|
||||
Name: %s
|
||||
Language: %s
|
||||
Timezone: %s
|
||||
Local time: %s (%s)
|
||||
Time: %s (%s)
|
||||
Chat platform: %s
|
||||
UUID: %s
|
||||
Facts:
|
||||
%s
|
||||
Contacts:
|
||||
@@ -85,7 +110,7 @@ Recent actions:
|
||||
User message: %s`
|
||||
|
||||
func BuildPrompt(user *database.User, facts []database.Fact, contacts []database.Contact, notifications []database.Notification, actions []database.Action, userMessage string) string {
|
||||
currentTime := currentLocalTime(user.Timezone)
|
||||
currentTime := jtime.CurrentLocalTime(user.Timezone)
|
||||
|
||||
loc, err := time.LoadLocation(user.Timezone)
|
||||
if err != nil {
|
||||
@@ -95,50 +120,48 @@ func BuildPrompt(user *database.User, facts []database.Fact, contacts []database
|
||||
|
||||
var factsList strings.Builder
|
||||
if len(facts) == 0 {
|
||||
factsList.WriteString("No facts yet.")
|
||||
factsList.WriteString(" (none)")
|
||||
} else {
|
||||
for _, f := range facts {
|
||||
factsList.WriteString("- ")
|
||||
factsList.WriteString(f.Value)
|
||||
factsList.WriteString("\n")
|
||||
fmt.Fprintf(&factsList, " - %s\n", f.Value)
|
||||
}
|
||||
}
|
||||
|
||||
var contactsList strings.Builder
|
||||
if len(contacts) == 0 {
|
||||
contactsList.WriteString("No contacts yet.")
|
||||
contactsList.WriteString(" (none)")
|
||||
} else {
|
||||
for _, c := range contacts {
|
||||
fmt.Fprintf(&contactsList, "- %s\n", c.Name)
|
||||
fmt.Fprintf(&contactsList, " - %s\n", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var notificationsList strings.Builder
|
||||
if len(notifications) == 0 {
|
||||
notificationsList.WriteString("No active notifications.")
|
||||
notificationsList.WriteString(" (none)")
|
||||
} else {
|
||||
for _, n := range notifications {
|
||||
localTime := toLocal(n.ScheduledAt, user.Timezone)
|
||||
fmt.Fprintf(¬ificationsList, "- [%s] %s\n", localTime, n.Content)
|
||||
localTime := jtime.ToLocal(n.ScheduledAt, user.Timezone)
|
||||
fmt.Fprintf(¬ificationsList, " - [%s] %s\n", localTime, n.Content)
|
||||
}
|
||||
}
|
||||
|
||||
var actionsList strings.Builder
|
||||
if len(actions) == 0 {
|
||||
actionsList.WriteString("No recent actions.")
|
||||
actionsList.WriteString(" (none)")
|
||||
} else {
|
||||
for _, a := range actions {
|
||||
localTime := toLocal(a.ExecutedAt, user.Timezone)
|
||||
fmt.Fprintf(&actionsList, "[%s] %s\n", localTime, string(a.Payload))
|
||||
localTime := jtime.ToLocal(a.ExecutedAt, user.Timezone)
|
||||
fmt.Fprintf(&actionsList, " - [%s] %s\n", localTime, string(a.Payload))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(masterPrompt,
|
||||
user.PreferredChat,
|
||||
user.Language,
|
||||
user.Timezone,
|
||||
currentTime,
|
||||
weekday,
|
||||
user.PreferredChat,
|
||||
user.TemporaryCode.String(),
|
||||
factsList.String(),
|
||||
contactsList.String(),
|
||||
notificationsList.String(),
|
||||
@@ -0,0 +1,89 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/d1nch8g/jules/database"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildPrompt(t *testing.T) {
|
||||
tmpCode := uuid.New()
|
||||
user := &database.User{
|
||||
ID: uuid.New(),
|
||||
Language: "en",
|
||||
Timezone: "Europe/Moscow",
|
||||
PreferredChat: "telegram",
|
||||
TemporaryCode: tmpCode,
|
||||
LLMCount: 500,
|
||||
SearchCount: 100,
|
||||
NotificationCount: 50,
|
||||
CountUpdatedAt: time.Now(),
|
||||
Role: "free",
|
||||
}
|
||||
|
||||
facts := []database.Fact{
|
||||
{UserID: user.ID, Value: "mom name is Irina"},
|
||||
{UserID: user.ID, Value: "goes to gym"},
|
||||
}
|
||||
|
||||
contacts := []database.Contact{
|
||||
{OwnerID: user.ID, TargetID: uuid.New(), Name: "Brother"},
|
||||
}
|
||||
|
||||
notifications := []database.Notification{
|
||||
{ID: uuid.New(), UserID: user.ID, InitiatorID: user.ID, ScheduledAt: time.Now().Add(time.Hour), Content: "call mom"},
|
||||
}
|
||||
|
||||
actions := []database.Action{
|
||||
{UserID: user.ID, ExecutedAt: time.Now().Add(-time.Hour), Payload: []byte(`{"type":"reply","text":"hello"}`)},
|
||||
}
|
||||
|
||||
result := BuildPrompt(user, facts, contacts, notifications, actions, "Hello, Jules!")
|
||||
|
||||
assert.Contains(t, result, "YOU ARE JULES")
|
||||
assert.Contains(t, result, "Language: en")
|
||||
assert.Contains(t, result, "Chat platform: telegram")
|
||||
assert.Contains(t, result, "UUID: "+tmpCode.String())
|
||||
assert.Contains(t, result, "mom name is Irina")
|
||||
assert.Contains(t, result, "goes to gym")
|
||||
assert.Contains(t, result, "Brother")
|
||||
assert.Contains(t, result, "call mom")
|
||||
assert.Contains(t, result, "hello")
|
||||
assert.Contains(t, result, "Hello, Jules!")
|
||||
}
|
||||
|
||||
func TestBuildPrompt_EmptyData(t *testing.T) {
|
||||
tmpCode := uuid.New()
|
||||
user := &database.User{
|
||||
ID: uuid.New(),
|
||||
Language: "ru",
|
||||
Timezone: "UTC",
|
||||
PreferredChat: "telegram",
|
||||
TemporaryCode: tmpCode,
|
||||
}
|
||||
|
||||
result := BuildPrompt(user, nil, nil, nil, nil, "Привет")
|
||||
|
||||
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, "Active notifications:\n (none)")
|
||||
assert.Contains(t, result, "Recent actions:\n (none)")
|
||||
assert.Contains(t, result, "Привет")
|
||||
}
|
||||
|
||||
func TestBuildPrompt_InvalidTimezone(t *testing.T) {
|
||||
user := &database.User{
|
||||
ID: uuid.New(),
|
||||
Language: "en",
|
||||
Timezone: "Mars/City",
|
||||
PreferredChat: "telegram",
|
||||
}
|
||||
|
||||
result := BuildPrompt(user, nil, nil, nil, nil, "test")
|
||||
assert.Contains(t, result, "Time: ")
|
||||
}
|
||||
Reference in New Issue
Block a user