Files
Cyrene_v2/backend/services/gateway/engine/reminders.go
T
Aska d80710d85a feat(gateway): automation rule engine and reminder scheduler
- 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
2026-08-21 12:54:11 +08:00

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
}