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
This commit is contained in:
Aska committed 2026-08-21 12:54:11 +08:00
1 parent 9f30cdd086
commit d80710d85a
7 files changed
+679 -2

No files matched your search

+56
View File
@@ -43,6 +43,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/engine"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/ws"
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
@@ -306,6 +307,35 @@ func main() {
}
}()
// === 12.7 Automation engine ===
autoEngine := engine.New(
automationSourceAdapter{gwDeps.Automation},
func(ctx context.Context, sessionID, userID, message string) {
tokenCh, _, err := orch.ProcessInput(ctx, sessionID, userID, message)
if err != nil {
logger.Infof("[automation] action failed: %v", err)
return
}
for range tokenCh {
}
},
iotQuerierAdapter{reg},
30*time.Second,
)
autoEngine.Start(context.Background())
gwDeps.AutomationEng = autoEngine
// === 12.8 Reminder scheduler ===
remSched := engine.NewReminderScheduler(
gwDeps.Reminders,
func(userID, title, body string) {
wsHub.Broadcast(map[string]any{"type": "reminder", "title": title, "content": body})
},
&memory.LLMAdapterBridge{Adapter: llmSelector.DefaultAdapter()},
30*time.Second,
)
remSched.Start(context.Background())
apiRouter := api.New(gwDeps)
wsMux := http.NewServeMux()
@@ -394,6 +424,32 @@ func (a *perfAdapter) History() []map[string]any {
return result
}
// automationSourceAdapter adapts api.AutomationStore to engine.RuleSource.
type automationSourceAdapter struct{ s *api.AutomationStore }
func (a automationSourceAdapter) List() []engine.Rule {
rules := a.s.List()
out := make([]engine.Rule, len(rules))
for i, r := range rules {
out[i] = engine.Rule{
ID: r.ID, Name: r.Name, Trigger: r.Trigger,
Condition: r.Condition, Action: r.Action, Enabled: r.Enabled,
}
}
return out
}
// iotQuerierAdapter adapts the plugin registry to engine.DeviceQuerier.
type iotQuerierAdapter struct{ reg *plugin.Registry }
func (a iotQuerierAdapter) ListDevices(ctx context.Context) ([]plugin.Device, error) {
iot := a.reg.GetIoTProvider()
if iot == nil {
return nil, fmt.Errorf("no iot provider")
}
return iot.ListDevices(ctx)
}
// chatPersisterAdapter adapts store.ChatStore to aiCoreCtx.Persister.
type chatPersisterAdapter struct{ s *store.ChatStore }