refactored some functionality into separate packages, finished prompt module and got everything tested

This commit is contained in:
d1nch8g
2026-04-19 09:43:36 +03:00
parent bf84a4b902
commit 1bc5301154
7 changed files with 157 additions and 49 deletions
@@ -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",
},
+2 -7
View File
@@ -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)
+6 -6
View File
@@ -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)
})
+45 -22
View File
@@ -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 friendyou'll add them.
- Adding contacts: the user can request their contact code and share it with a friendyou'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(&notificationsList, "- [%s] %s\n", localTime, n.Content)
localTime := jtime.ToLocal(n.ScheduledAt, user.Timezone)
fmt.Fprintf(&notificationsList, " - [%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(),
+89
View File
@@ -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: ")
}