- engine: 5-field cron parser (*/n, ranges, lists) firing at most once per minute; device-state triggers with edge detection; manual trigger via POST /automation/rules/:id/trigger; actions run through the AI pipeline so rules can call any tool (IoT control, search, ...) - reminders: due-scan scheduler delivering via WebSocket broadcast, optional LLM-polished reminder text with plain-title fallback; Reminder gains Done flag with ListDue/MarkDone - tests: cron matching and validation, once-per-minute dedup, device edge triggering, manual trigger, reminder delivery
108 lines
2.9 KiB
Go
108 lines
2.9 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
|
)
|
|
|
|
// Reminder is a due notification for a user.
|
|
type Reminder struct {
|
|
ID string
|
|
Title string
|
|
DueAt time.Time
|
|
UserID string
|
|
}
|
|
|
|
// ReminderSource provides due reminders and marks them delivered.
|
|
type ReminderSource interface {
|
|
// ListDue returns reminders that are due and not yet delivered.
|
|
ListDue(now time.Time) []Reminder
|
|
// MarkDone marks a reminder as delivered.
|
|
MarkDone(id string)
|
|
}
|
|
|
|
// ReminderNotifier delivers a reminder to the user (e.g. via WebSocket).
|
|
type ReminderNotifier func(userID, title, body string)
|
|
|
|
// Copywriter polishes reminder text via the LLM (optional).
|
|
type Copywriter interface {
|
|
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
|
|
}
|
|
|
|
// ReminderScheduler scans for due reminders and delivers them.
|
|
type ReminderScheduler struct {
|
|
src ReminderSource
|
|
notify ReminderNotifier
|
|
llm Copywriter
|
|
interval time.Duration
|
|
stop chan struct{}
|
|
stopOnce sync.Once
|
|
}
|
|
|
|
// NewReminderScheduler creates a scheduler scanning at the given interval.
|
|
func NewReminderScheduler(src ReminderSource, notify ReminderNotifier, llm Copywriter, interval time.Duration) *ReminderScheduler {
|
|
if interval <= 0 {
|
|
interval = 30 * time.Second
|
|
}
|
|
return &ReminderScheduler{src: src, notify: notify, llm: llm, interval: interval, stop: make(chan struct{})}
|
|
}
|
|
|
|
// Start begins the scan loop.
|
|
func (s *ReminderScheduler) Start(ctx context.Context) {
|
|
go func() {
|
|
ticker := time.NewTicker(s.interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-s.stop:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
case t := <-ticker.C:
|
|
s.deliver(ctx, t)
|
|
}
|
|
}
|
|
}()
|
|
logger.Infof("[reminder] scheduler started (interval=%v)", s.interval)
|
|
}
|
|
|
|
// Stop shuts down the scheduler.
|
|
func (s *ReminderScheduler) Stop() { s.stopOnce.Do(func() { close(s.stop) }) }
|
|
|
|
func (s *ReminderScheduler) deliver(ctx context.Context, now time.Time) {
|
|
for _, r := range s.src.ListDue(now) {
|
|
body := s.polish(ctx, r.Title)
|
|
logger.Infof("[reminder] due: %q for %s", r.Title, r.UserID)
|
|
if s.notify != nil {
|
|
s.notify(r.UserID, r.Title, body)
|
|
}
|
|
s.src.MarkDone(r.ID)
|
|
}
|
|
}
|
|
|
|
// polish asks the LLM for a warmer reminder text; falls back to the title.
|
|
func (s *ReminderScheduler) polish(ctx context.Context, title string) string {
|
|
if s.llm == nil {
|
|
return title
|
|
}
|
|
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
resp, err := s.llm.Chat(c, []model.LLMMessage{
|
|
{Role: "system", Content: "你是昔涟。用一两句温柔自然的话提醒主人一件事,不要解释,直接输出提醒内容。"},
|
|
{Role: "user", Content: fmt.Sprintf("请提醒我:%s", title)},
|
|
})
|
|
if err != nil {
|
|
return title
|
|
}
|
|
if text := strings.TrimSpace(resp.Content); text != "" && len([]rune(text)) <= 120 {
|
|
return text
|
|
}
|
|
return title
|
|
}
|