1257 lines
40 KiB
Go
1257 lines
40 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"homehub/internal/store"
|
|
)
|
|
|
|
type pendingItem struct {
|
|
ID string
|
|
Label string
|
|
}
|
|
|
|
type pendingCommand struct {
|
|
Kind string
|
|
Items []pendingItem
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
var (
|
|
clockColonRE = regexp.MustCompile(`(?i)\b([01]?\d|2[0-3]):([0-5]\d)\b`)
|
|
clockUhrRE = regexp.MustCompile(`(?i)\b([01]?\d|2[0-3])(?:[.:]([0-5]\d))?\s*uhr\b`)
|
|
germanDateRE = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?\b`)
|
|
isoDateRE = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`)
|
|
)
|
|
|
|
func stripModuleContext(v string) string {
|
|
s := strings.TrimSpace(v)
|
|
lower := strings.ToLower(s)
|
|
if strings.HasPrefix(lower, "im kontext des moduls ") {
|
|
if i := strings.Index(s, ":"); i >= 0 {
|
|
return strings.TrimSpace(s[i+1:])
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func foldCommand(v string) string {
|
|
s := strings.ToLower(strings.TrimSpace(v))
|
|
r := strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss")
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func isDeleteCommand(v string) bool {
|
|
x := foldCommand(stripModuleContext(v))
|
|
return strings.Contains(x, "loesch") || strings.Contains(x, "entfern") || strings.Contains(x, "delete")
|
|
}
|
|
|
|
func isTerseControlResponse(v string) bool {
|
|
x := strings.TrimSpace(foldCommand(stripModuleContext(v)))
|
|
if x == "" {
|
|
return true
|
|
}
|
|
if n, err := strconv.Atoi(x); err == nil && n >= 0 && n <= 99 {
|
|
return true
|
|
}
|
|
switch x {
|
|
case "ja", "nein", "ok", "okay", "beide", "alle", "abbrechen", "abbruch":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *app) tryHandleControlCommand(msg string) (string, []automationResult, string, bool) {
|
|
raw := stripModuleContext(msg)
|
|
if reply, results, mode, ok := a.resolvePendingCommand(raw); ok {
|
|
return reply, results, mode, true
|
|
}
|
|
if reply, results, mode, ok := a.tryHandleRecentCorrection(raw); ok {
|
|
return reply, results, mode, true
|
|
}
|
|
if !isDeleteCommand(raw) {
|
|
if reply, ok := a.tryHandleStateQuery(raw); ok {
|
|
return reply, nil, "state", true
|
|
}
|
|
return "", nil, "", false
|
|
}
|
|
|
|
x := foldCommand(raw)
|
|
switch {
|
|
case containsFoldedAny(x, "aufgabe", "aufgaben", "task", "tasks", "todo", "to-do"):
|
|
return a.handleTaskDeleteCommand(raw)
|
|
case containsFoldedAny(x, "termin", "termine", "kalender", "event", "events"):
|
|
return a.handleEventDeleteCommand(raw)
|
|
default:
|
|
return "Ich kann den Löschbefehl noch nicht eindeutig einem Bereich zuordnen. Nenne bitte z. B. „Termin“ oder „Aufgabe“.", nil, "clarification", true
|
|
}
|
|
}
|
|
|
|
func containsFoldedAny(s string, words ...string) bool {
|
|
for _, w := range words {
|
|
if strings.Contains(s, foldCommand(w)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func asksForAll(v string) bool {
|
|
x := foldCommand(v)
|
|
return strings.Contains(x, " alle ") || strings.HasPrefix(x, "alle ") || strings.HasSuffix(x, " alle") || x == "alle" || strings.Contains(x, "saemtlich") || strings.Contains(x, "beide")
|
|
}
|
|
|
|
func (a *app) tryHandleStateQuery(raw string) (string, bool) {
|
|
x := foldCommand(raw)
|
|
if containsFoldedAny(x, "anleg", "eintrag", "trage", "trag ", "plane", "planen", "erstelle", "fuege", "füge", "erinnere mich", "verschieb", "aendere", "ändere") {
|
|
return "", false
|
|
}
|
|
question := containsFoldedAny(x, "welche", "welcher", "zeige", "was steht", "was habe", "habe ich", "wann", "naechste", "nächste", "uebersicht", "übersicht", "wie viele") || strings.HasSuffix(strings.TrimSpace(raw), "?")
|
|
if !question {
|
|
return "", false
|
|
}
|
|
st := a.db.Snapshot()
|
|
if containsFoldedAny(x, "termin", "termine", "kalender", "meeting", "event") {
|
|
events := upcomingEvents(st.Events, a.timezone)
|
|
now := time.Now().In(a.timezone)
|
|
if d, ok := explicitOrRelativeDate(raw, now); ok {
|
|
filtered := events[:0]
|
|
for _, e := range events {
|
|
et, err := parseEventTime(e, a.timezone)
|
|
if err == nil && et.Year() == d.Year() && et.YearDay() == d.YearDay() {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
events = append([]store.CalendarEvent(nil), filtered...)
|
|
}
|
|
// Bei einer konkreten Titelfrage nur den besten Treffer zeigen.
|
|
best, bestScore := -1, 0.0
|
|
for i, e := range events {
|
|
if sc := textSimilarity(raw, e.Title); sc > bestScore {
|
|
best, bestScore = i, sc
|
|
}
|
|
}
|
|
if best >= 0 && bestScore >= .34 && !containsFoldedAny(x, "welche termine", "termine habe", "kalender uebersicht", "kalenderübersicht", "naechste termine", "nächste termine") {
|
|
events = []store.CalendarEvent{events[best]}
|
|
}
|
|
if len(events) == 0 {
|
|
return "Im passenden Kalender-Zeitraum sind keine Termine eingetragen.", true
|
|
}
|
|
var b strings.Builder
|
|
if len(events) == 1 {
|
|
b.WriteString("Kalender: ")
|
|
} else {
|
|
fmt.Fprintf(&b, "Kalender · %d Termine:\n", len(events))
|
|
}
|
|
for i, e := range events {
|
|
if len(events) > 1 {
|
|
fmt.Fprintf(&b, "%d. ", i+1)
|
|
}
|
|
fmt.Fprintf(&b, "%s · %s", e.Title, a.formatDateTime(e.Start, e.AllDay))
|
|
if e.Location != "" {
|
|
fmt.Fprintf(&b, " · %s", e.Location)
|
|
}
|
|
if i < len(events)-1 {
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String(), true
|
|
}
|
|
if containsFoldedAny(x, "aufgabe", "aufgaben", "task", "tasks", "todo", "to-do") {
|
|
open := make([]store.Task, 0, len(st.Tasks))
|
|
for _, t := range st.Tasks {
|
|
if t.Status != "done" {
|
|
open = append(open, t)
|
|
}
|
|
}
|
|
if strings.Contains(x, "wie viele") {
|
|
return fmt.Sprintf("Es sind %d offene Aufgabe(n) vorhanden.", len(open)), true
|
|
}
|
|
if len(open) == 0 {
|
|
return "Die Aufgabenliste ist leer.", true
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Offene Aufgaben · %d:\n", len(open))
|
|
for i, t := range open {
|
|
fmt.Fprintf(&b, "%d. %s", i+1, t.Title)
|
|
if t.Due != "" {
|
|
fmt.Fprintf(&b, " · fällig %s", a.formatDateTime(t.Due, false))
|
|
}
|
|
if i < len(open)-1 {
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String(), true
|
|
}
|
|
if containsFoldedAny(x, "erinnerung", "erinnerungen", "reminder") {
|
|
open := make([]store.Reminder, 0, len(st.Reminders))
|
|
for _, r := range st.Reminders {
|
|
if !r.Done {
|
|
open = append(open, r)
|
|
}
|
|
}
|
|
if len(open) == 0 {
|
|
return "Es sind keine offenen Erinnerungen vorhanden.", true
|
|
}
|
|
sort.Slice(open, func(i, j int) bool { return open[i].At < open[j].At })
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Offene Erinnerungen · %d:\n", len(open))
|
|
for i, r := range open {
|
|
fmt.Fprintf(&b, "%d. %s · %s", i+1, r.Title, a.formatDateTime(r.At, false))
|
|
if i < len(open)-1 {
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String(), true
|
|
}
|
|
if containsFoldedAny(x, "essensplan", "mahlzeit", "abendessen", "mittagessen", "fruehstueck", "frühstück", "zu mittag", "zum mittag", "zu abend", "zum abend") {
|
|
plans := append([]store.MealPlan(nil), st.MealPlans...)
|
|
now := time.Now().In(a.timezone)
|
|
if d, ok := explicitOrRelativeDate(raw, now); ok {
|
|
filtered := plans[:0]
|
|
for _, m := range plans {
|
|
if m.Date == d.Format("2006-01-02") {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
plans = append([]store.MealPlan(nil), filtered...)
|
|
}
|
|
if meal, ok := explicitMealFromMessage(raw); ok {
|
|
filtered := plans[:0]
|
|
for _, m := range plans {
|
|
if normalizeMeal(m.Meal) == meal {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
plans = append([]store.MealPlan(nil), filtered...)
|
|
}
|
|
if len(plans) == 0 {
|
|
return "Im passenden Essensplan-Zeitraum ist nichts eingetragen.", true
|
|
}
|
|
sort.Slice(plans, func(i, j int) bool {
|
|
if plans[i].Date == plans[j].Date {
|
|
return normalizeMeal(plans[i].Meal) < normalizeMeal(plans[j].Meal)
|
|
}
|
|
return plans[i].Date < plans[j].Date
|
|
})
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Essensplan · %d Eintrag/Einträge:\n", len(plans))
|
|
for i, m := range plans {
|
|
dateText := m.Date
|
|
if d, err := time.ParseInLocation("2006-01-02", m.Date, a.timezone); err == nil {
|
|
dateText = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year())
|
|
}
|
|
mealText := map[string]string{"breakfast": "Frühstück", "lunch": "Mittagessen", "dinner": "Abendessen", "snack": "Snack"}[normalizeMeal(m.Meal)]
|
|
if mealText == "" {
|
|
mealText = "Mahlzeit"
|
|
}
|
|
fmt.Fprintf(&b, "%d. %s · %s · %s", i+1, m.Title, dateText, mealText)
|
|
if i < len(plans)-1 {
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String(), true
|
|
}
|
|
if containsFoldedAny(x, "einkauf", "einkaufsliste") {
|
|
open := make([]store.GroceryItem, 0, len(st.Groceries))
|
|
for _, g := range st.Groceries {
|
|
if !g.Checked {
|
|
open = append(open, g)
|
|
}
|
|
}
|
|
if len(open) == 0 {
|
|
return "Die Einkaufsliste ist leer.", true
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Einkaufsliste · %d Position(en):\n", len(open))
|
|
for i, g := range open {
|
|
fmt.Fprintf(&b, "%d. %s", i+1, g.Name)
|
|
if strings.TrimSpace(g.Quantity+g.Unit) != "" {
|
|
fmt.Fprintf(&b, " · %s %s", g.Quantity, g.Unit)
|
|
}
|
|
if i < len(open)-1 {
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String(), true
|
|
}
|
|
if containsFoldedAny(x, "rezept", "rezepte") {
|
|
if len(st.Recipes) == 0 {
|
|
return "Es sind keine Rezepte gespeichert.", true
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Rezepte · %d:\n", len(st.Recipes))
|
|
for i, r := range st.Recipes {
|
|
fmt.Fprintf(&b, "%d. %s", i+1, r.Title)
|
|
if r.PrepMinutes > 0 {
|
|
fmt.Fprintf(&b, " · %d min", r.PrepMinutes)
|
|
}
|
|
if i < len(st.Recipes)-1 {
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String(), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (a *app) handleTaskDeleteCommand(raw string) (string, []automationResult, string, bool) {
|
|
st := a.db.Snapshot()
|
|
if asksForAll(" " + raw + " ") {
|
|
ids := make(map[string]bool, len(st.Tasks))
|
|
for _, t := range st.Tasks {
|
|
ids[t.ID] = true
|
|
}
|
|
n := len(ids)
|
|
_ = a.db.Update(func(s *store.State) error {
|
|
s.Tasks = []store.Task{}
|
|
kept := s.Kanban[:0]
|
|
for _, c := range s.Kanban {
|
|
if c.TaskID != "" && ids[c.TaskID] {
|
|
continue
|
|
}
|
|
kept = append(kept, c)
|
|
}
|
|
s.Kanban = append([]store.KanbanCard(nil), kept...)
|
|
return nil
|
|
})
|
|
a.clearPending()
|
|
if n == 0 {
|
|
return "Die Aufgabenliste ist bereits leer. Kalendertermine und Erinnerungen wurden nicht verändert.", nil, "action", true
|
|
}
|
|
a.activity("automation", fmt.Sprintf("%d Aufgabe(n) gelöscht; Kalender unverändert", n))
|
|
return fmt.Sprintf("%d Aufgabe(n) gelöscht. Zugehörige Task-Karten wurden entfernt; Kalendertermine und Kalender-Erinnerungen bleiben unverändert.", n), []automationResult{{Type: "delete_tasks", Message: fmt.Sprintf("%d Aufgaben gelöscht", n)}}, "action", true
|
|
}
|
|
|
|
matches := matchTasks(st.Tasks, raw)
|
|
if len(matches) == 0 {
|
|
return "Ich finde keine passende Aufgabe. Nenne den Aufgabentitel genauer oder sage „lösche alle Aufgaben“.", nil, "clarification", true
|
|
}
|
|
if len(matches) > 1 {
|
|
items := make([]pendingItem, 0, len(matches))
|
|
var b strings.Builder
|
|
b.WriteString("Mehrere Aufgaben passen dazu. Welche soll ich löschen?\n")
|
|
for i, t := range matches {
|
|
items = append(items, pendingItem{ID: t.ID, Label: t.Title})
|
|
fmt.Fprintf(&b, "%d. %s", i+1, t.Title)
|
|
if t.Due != "" {
|
|
fmt.Fprintf(&b, " · %s", a.formatDateTime(t.Due, false))
|
|
}
|
|
b.WriteByte('\n')
|
|
}
|
|
a.setPending("delete_task", items)
|
|
b.WriteString("Antworte mit der Nummer, dem Namen, „alle“ oder „abbrechen“.")
|
|
return strings.TrimSpace(b.String()), nil, "clarification", true
|
|
}
|
|
res := a.deleteTaskByID(matches[0].ID)
|
|
return res.Message + ". Kalendertermine wurden nicht verändert.", []automationResult{res}, "action", true
|
|
}
|
|
|
|
func (a *app) handleEventDeleteCommand(raw string) (string, []automationResult, string, bool) {
|
|
st := a.db.Snapshot()
|
|
future := upcomingEvents(st.Events, a.timezone)
|
|
if asksForAll(" " + raw + " ") {
|
|
targets := future
|
|
if strings.Contains(foldCommand(raw), "alle") && len(targets) == 0 {
|
|
targets = st.Events
|
|
}
|
|
if len(targets) == 0 {
|
|
return "Es gibt keine passenden Kalendertermine zum Löschen.", nil, "action", true
|
|
}
|
|
results := make([]automationResult, 0, len(targets))
|
|
for _, e := range targets {
|
|
results = append(results, a.deleteEventByID(e.ID))
|
|
}
|
|
a.clearPending()
|
|
return fmt.Sprintf("%d Kalendertermin(e) gelöscht. Verknüpfte automatische Kalender-Erinnerungen wurden ebenfalls entfernt; Aufgaben bleiben unverändert.", len(results)), results, "action", true
|
|
}
|
|
|
|
matches := matchEvents(future, raw)
|
|
if len(matches) == 0 && genericEventDelete(raw) {
|
|
matches = future
|
|
}
|
|
if len(matches) == 0 {
|
|
return "Ich finde keinen passenden zukünftigen Kalendertermin. Nenne den Titel genauer.", nil, "clarification", true
|
|
}
|
|
if len(matches) > 1 {
|
|
items := make([]pendingItem, 0, len(matches))
|
|
var b strings.Builder
|
|
b.WriteString("Welchen Kalendertermin soll ich löschen?\n")
|
|
for i, e := range matches {
|
|
items = append(items, pendingItem{ID: e.ID, Label: e.Title})
|
|
fmt.Fprintf(&b, "%d. %s · %s\n", i+1, e.Title, a.formatDateTime(e.Start, e.AllDay))
|
|
}
|
|
a.setPending("delete_event", items)
|
|
b.WriteString("Antworte mit der Nummer, dem Namen, „alle“ oder „abbrechen“.")
|
|
return strings.TrimSpace(b.String()), nil, "clarification", true
|
|
}
|
|
res := a.deleteEventByID(matches[0].ID)
|
|
return res.Message + ". Die zugehörige automatische Kalender-Erinnerung wurde entfernt; Aufgaben bleiben unverändert.", []automationResult{res}, "action", true
|
|
}
|
|
|
|
func genericEventDelete(raw string) bool {
|
|
x := foldCommand(raw)
|
|
for _, filler := range []string{"loesche", "loesch", "entferne", "entfern", "bitte", "den", "die", "das", "einen", "termin", "kalender", "kalendereintrag", "event", "aus", "meinem"} {
|
|
x = strings.ReplaceAll(x, filler, " ")
|
|
}
|
|
return len(strings.Fields(x)) == 0
|
|
}
|
|
|
|
func (a *app) setPending(kind string, items []pendingItem) {
|
|
a.pendingMu.Lock()
|
|
defer a.pendingMu.Unlock()
|
|
a.pending = &pendingCommand{Kind: kind, Items: items, ExpiresAt: time.Now().Add(3 * time.Minute)}
|
|
}
|
|
func (a *app) clearPending() { a.pendingMu.Lock(); a.pending = nil; a.pendingMu.Unlock() }
|
|
|
|
func (a *app) resolvePendingCommand(raw string) (string, []automationResult, string, bool) {
|
|
a.pendingMu.Lock()
|
|
p := a.pending
|
|
if p == nil || time.Now().After(p.ExpiresAt) {
|
|
a.pending = nil
|
|
a.pendingMu.Unlock()
|
|
return "", nil, "", false
|
|
}
|
|
// Snapshot while still locked, then release before mutating the DB.
|
|
cp := *p
|
|
cp.Items = append([]pendingItem(nil), p.Items...)
|
|
a.pendingMu.Unlock()
|
|
|
|
x := foldCommand(strings.TrimSpace(raw))
|
|
if x == "abbrechen" || x == "abbruch" || x == "nein" {
|
|
a.clearPending()
|
|
return "Okay, die Löschaktion wurde abgebrochen.", nil, "action", true
|
|
}
|
|
|
|
chosen := []pendingItem{}
|
|
if asksForAll(" " + x + " ") {
|
|
chosen = cp.Items
|
|
} else if n, err := strconv.Atoi(strings.TrimSpace(x)); err == nil && n >= 1 && n <= len(cp.Items) {
|
|
chosen = []pendingItem{cp.Items[n-1]}
|
|
} else {
|
|
for _, it := range cp.Items {
|
|
if textSimilarity(raw, it.Label) >= .45 || strings.Contains(foldCommand(raw), foldCommand(it.Label)) {
|
|
chosen = append(chosen, it)
|
|
}
|
|
}
|
|
}
|
|
if len(chosen) == 0 {
|
|
return "Die Auswahl ist noch nicht eindeutig. Antworte mit einer Nummer, dem Namen, „alle“ oder „abbrechen“.", nil, "clarification", true
|
|
}
|
|
|
|
a.clearPending()
|
|
results := make([]automationResult, 0, len(chosen))
|
|
for _, it := range chosen {
|
|
if cp.Kind == "delete_event" {
|
|
results = append(results, a.deleteEventByID(it.ID))
|
|
} else {
|
|
results = append(results, a.deleteTaskByID(it.ID))
|
|
}
|
|
}
|
|
if cp.Kind == "delete_event" {
|
|
return fmt.Sprintf("%d Kalendertermin(e) gelöscht. Aufgaben bleiben unverändert.", len(results)), results, "action", true
|
|
}
|
|
return fmt.Sprintf("%d Aufgabe(n) gelöscht. Kalendertermine bleiben unverändert.", len(results)), results, "action", true
|
|
}
|
|
|
|
func (a *app) deleteEventByID(idv string) automationResult {
|
|
title := idv
|
|
_ = a.db.Update(func(s *store.State) error {
|
|
for _, e := range s.Events {
|
|
if e.ID == idv {
|
|
title = e.Title
|
|
break
|
|
}
|
|
}
|
|
s.Events = filterEvents(s.Events, idv)
|
|
s.Reminders = filterLinkedReminders(s.Reminders, "event", idv)
|
|
return nil
|
|
})
|
|
a.activity("calendar", "Termin gelöscht: "+title)
|
|
return automationResult{Type: "delete_event", ID: idv, Message: "Termin gelöscht: " + title}
|
|
}
|
|
func (a *app) deleteTaskByID(idv string) automationResult {
|
|
title := idv
|
|
_ = a.db.Update(func(s *store.State) error {
|
|
for _, t := range s.Tasks {
|
|
if t.ID == idv {
|
|
title = t.Title
|
|
break
|
|
}
|
|
}
|
|
s.Tasks = filterTasksByID(s.Tasks, idv)
|
|
kept := s.Kanban[:0]
|
|
for _, c := range s.Kanban {
|
|
if c.TaskID != idv {
|
|
kept = append(kept, c)
|
|
}
|
|
}
|
|
s.Kanban = append([]store.KanbanCard(nil), kept...)
|
|
return nil
|
|
})
|
|
a.activity("task", "Aufgabe gelöscht: "+title)
|
|
return automationResult{Type: "delete_task", ID: idv, Message: "Aufgabe gelöscht: " + title}
|
|
}
|
|
|
|
func filterTasksByID(xs []store.Task, idv string) []store.Task {
|
|
out := xs[:0]
|
|
for _, x := range xs {
|
|
if x.ID != idv {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return append([]store.Task(nil), out...)
|
|
}
|
|
|
|
func upcomingEvents(xs []store.CalendarEvent, loc *time.Location) []store.CalendarEvent {
|
|
if loc == nil {
|
|
loc = time.Local
|
|
}
|
|
cutoff := time.Now().In(loc).Add(-15 * time.Minute)
|
|
out := make([]store.CalendarEvent, 0, len(xs))
|
|
for _, e := range xs {
|
|
t, err := parseEventTime(e, loc)
|
|
if err == nil && !t.Before(cutoff) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start })
|
|
return out
|
|
}
|
|
|
|
func matchEvents(xs []store.CalendarEvent, raw string) []store.CalendarEvent {
|
|
q := removeCommandWords(raw, []string{"lösche", "loesche", "lösch", "loesch", "entferne", "entfern", "bitte", "den", "die", "das", "einen", "termin", "termine", "kalender", "kalendereintrag", "event", "events"})
|
|
if strings.TrimSpace(q) == "" {
|
|
return nil
|
|
}
|
|
out := []store.CalendarEvent{}
|
|
for _, e := range xs {
|
|
if textSimilarity(q, e.Title) >= .34 || strings.Contains(foldCommand(e.Title), foldCommand(q)) || strings.Contains(foldCommand(q), foldCommand(e.Title)) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func matchTasks(xs []store.Task, raw string) []store.Task {
|
|
q := removeCommandWords(raw, []string{"lösche", "loesche", "lösch", "loesch", "entferne", "entfern", "bitte", "den", "die", "das", "eine", "einen", "aufgabe", "aufgaben", "task", "tasks", "todo", "to-do"})
|
|
if strings.TrimSpace(q) == "" {
|
|
return xs
|
|
}
|
|
out := []store.Task{}
|
|
for _, t := range xs {
|
|
if textSimilarity(q, t.Title) >= .34 || strings.Contains(foldCommand(t.Title), foldCommand(q)) || strings.Contains(foldCommand(q), foldCommand(t.Title)) {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func removeCommandWords(raw string, words []string) string {
|
|
x := foldCommand(raw)
|
|
for _, w := range words {
|
|
x = strings.ReplaceAll(x, foldCommand(w), " ")
|
|
}
|
|
return strings.Join(strings.Fields(x), " ")
|
|
}
|
|
|
|
func tokenSet(v string) map[string]bool {
|
|
f := foldCommand(v)
|
|
replacer := strings.NewReplacer("-", " ", "_", " ", "/", " ", ".", " ", ",", " ", ":", " ", ";", " ", "(", " ", ")", " ", "\"", " ", "'", " ")
|
|
f = replacer.Replace(f)
|
|
out := map[string]bool{}
|
|
stop := map[string]bool{"der": true, "die": true, "das": true, "den": true, "dem": true, "ein": true, "eine": true, "einen": true, "und": true, "am": true, "um": true, "im": true, "in": true, "fuer": true, "für": true, "termin": true, "kalendereintrag": true, "wann": true, "ist": true, "mein": true, "meine": true, "welche": true, "welcher": true, "zeige": true, "was": true, "steht": true, "habe": true, "ich": true, "naechste": true, "nächste": true}
|
|
for _, w := range strings.Fields(f) {
|
|
if stop[w] || len(w) < 3 {
|
|
continue
|
|
}
|
|
w = simpleStem(w)
|
|
out[w] = true
|
|
}
|
|
return out
|
|
}
|
|
func simpleStem(w string) string {
|
|
for _, s := range []string{"ern", "en", "er", "es", "e", "n", "s"} {
|
|
if len(w) > 5 && strings.HasSuffix(w, s) {
|
|
return strings.TrimSuffix(w, s)
|
|
}
|
|
}
|
|
return w
|
|
}
|
|
func textSimilarity(a, b string) float64 {
|
|
aa, bb := tokenSet(a), tokenSet(b)
|
|
if len(aa) == 0 || len(bb) == 0 {
|
|
if foldCommand(a) == foldCommand(b) {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
inter := 0
|
|
for k := range aa {
|
|
if bb[k] {
|
|
inter++
|
|
}
|
|
}
|
|
den := len(aa) + len(bb) - inter
|
|
if den == 0 {
|
|
return 0
|
|
}
|
|
return float64(inter) / float64(den)
|
|
}
|
|
|
|
func (a *app) correctTemporalFromMessage(ac commandAction, msg string) commandAction {
|
|
return a.correctTemporalFromMessageAt(ac, msg, time.Now().In(a.timezone))
|
|
}
|
|
|
|
func (a *app) correctTemporalFromMessageAt(ac commandAction, msg string, now time.Time) commandAction {
|
|
raw := stripModuleContext(msg)
|
|
if a.timezone != nil {
|
|
now = now.In(a.timezone)
|
|
}
|
|
targetDate, hasDate := explicitOrRelativeDate(raw, now)
|
|
hour, minute, hasClock := explicitClock(raw)
|
|
apply := func(v string) string {
|
|
var base time.Time
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
base = t.In(a.timezone)
|
|
} else {
|
|
base = now
|
|
}
|
|
if hasDate {
|
|
base = time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), base.Hour(), base.Minute(), 0, 0, a.timezone)
|
|
}
|
|
if hasClock {
|
|
base = time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, a.timezone)
|
|
}
|
|
if hasDate || hasClock {
|
|
return base.Format(time.RFC3339)
|
|
}
|
|
return v
|
|
}
|
|
switch ac.Type {
|
|
case "add_event":
|
|
if ac.Start != "" && (hasDate || hasClock) {
|
|
ac.Start = apply(ac.Start)
|
|
} else if ac.Start == "" && hasDate {
|
|
base := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, a.timezone)
|
|
if hasClock {
|
|
base = time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, a.timezone)
|
|
}
|
|
ac.Start = base.Format(time.RFC3339)
|
|
}
|
|
if hasClock {
|
|
ac.AllDay = false
|
|
} else if hasDate && ac.Start != "" {
|
|
ac.AllDay = true
|
|
}
|
|
case "add_reminder":
|
|
if ac.At != "" && (hasDate || hasClock) {
|
|
ac.At = apply(ac.At)
|
|
} else if ac.At == "" && hasDate && hasClock {
|
|
ac.At = time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), hour, minute, 0, 0, a.timezone).Format(time.RFC3339)
|
|
}
|
|
case "add_task":
|
|
if ac.Due != "" && (hasDate || hasClock) {
|
|
ac.Due = apply(ac.Due)
|
|
}
|
|
case "add_meal_plan":
|
|
// Essensplanung muss dieselbe deterministische Datumslogik wie der
|
|
// Kalender verwenden. Ein kleines Modell darf "Montag" nicht als
|
|
// Sonntag/Montag falsch ausrechnen und ungeprüft persistieren.
|
|
if hasDate {
|
|
ac.Date = targetDate.Format("2006-01-02")
|
|
}
|
|
if meal, ok := explicitMealFromMessage(raw); ok {
|
|
ac.Meal = meal
|
|
}
|
|
}
|
|
return ac
|
|
}
|
|
|
|
func explicitMealFromMessage(raw string) (string, bool) {
|
|
x := foldCommand(raw)
|
|
switch {
|
|
case strings.Contains(x, "fruehstueck") || strings.Contains(x, "fruehstück") || strings.Contains(x, "breakfast"):
|
|
return "breakfast", true
|
|
case strings.Contains(x, "mittagessen") || strings.Contains(x, "zum mittag") || strings.Contains(x, "zu mittag") || strings.Contains(x, "lunch"):
|
|
return "lunch", true
|
|
case strings.Contains(x, "abendessen") || strings.Contains(x, "zum abend") || strings.Contains(x, "zu abend") || strings.Contains(x, "dinner"):
|
|
return "dinner", true
|
|
case strings.Contains(x, "snack"):
|
|
return "snack", true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func explicitClock(raw string) (int, int, bool) {
|
|
if m := clockColonRE.FindStringSubmatch(raw); len(m) > 0 {
|
|
h, _ := strconv.Atoi(m[1])
|
|
min, _ := strconv.Atoi(m[2])
|
|
return h, min, true
|
|
}
|
|
if m := clockUhrRE.FindStringSubmatch(raw); len(m) > 0 {
|
|
h, _ := strconv.Atoi(m[1])
|
|
min := 0
|
|
if m[2] != "" {
|
|
min, _ = strconv.Atoi(m[2])
|
|
}
|
|
return h, min, true
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
func explicitOrRelativeDate(raw string, now time.Time) (time.Time, bool) {
|
|
x := foldCommand(raw)
|
|
|
|
// Explizite Kalenderdaten haben höchste Priorität. Damit kann ein Modell oder
|
|
// ein längerer Satz mit relativen Begriffen ein klar genanntes Datum nicht
|
|
// versehentlich überschreiben.
|
|
if m := isoDateRE.FindStringSubmatch(raw); len(m) > 0 {
|
|
y, _ := strconv.Atoi(m[1])
|
|
mo, _ := strconv.Atoi(m[2])
|
|
d, _ := strconv.Atoi(m[3])
|
|
return time.Date(y, time.Month(mo), d, 0, 0, 0, 0, now.Location()), true
|
|
}
|
|
if m := germanDateRE.FindStringSubmatch(raw); len(m) > 0 {
|
|
d, _ := strconv.Atoi(m[1])
|
|
mo, _ := strconv.Atoi(m[2])
|
|
y := now.Year()
|
|
if m[3] != "" {
|
|
y, _ = strconv.Atoi(m[3])
|
|
if y < 100 {
|
|
y += 2000
|
|
}
|
|
}
|
|
if mo >= 1 && mo <= 12 && d >= 1 && d <= 31 {
|
|
t := time.Date(y, time.Month(mo), d, 0, 0, 0, 0, now.Location())
|
|
if t.Day() == d && int(t.Month()) == mo {
|
|
return t, true
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.Contains(x, "uebermorgen") {
|
|
return now.AddDate(0, 0, 2), true
|
|
}
|
|
if strings.Contains(x, "morgen") {
|
|
return now.AddDate(0, 0, 1), true
|
|
}
|
|
if strings.Contains(x, "heute") {
|
|
return now, true
|
|
}
|
|
|
|
// Wochentage werden absichtlich serverseitig aufgelöst. Das LLM darf den
|
|
// Kalendertag nicht mehr ausrechnen. Aus Do. 27.08.2026 + "Montag" wird so
|
|
// garantiert Mo. 31.08.2026 und niemals Di. 01.09.2026.
|
|
weekdays := []struct {
|
|
name string
|
|
day time.Weekday
|
|
}{
|
|
{"sonntag", time.Sunday}, {"montag", time.Monday}, {"dienstag", time.Tuesday},
|
|
{"mittwoch", time.Wednesday}, {"donnerstag", time.Thursday}, {"freitag", time.Friday},
|
|
{"samstag", time.Saturday},
|
|
}
|
|
for _, wd := range weekdays {
|
|
if !strings.Contains(x, wd.name) {
|
|
continue
|
|
}
|
|
delta := (int(wd.day) - int(now.Weekday()) + 7) % 7
|
|
forceNext := strings.Contains(x, "naechsten "+wd.name) || strings.Contains(x, "kommenden "+wd.name)
|
|
if delta == 0 && forceNext {
|
|
delta = 7
|
|
}
|
|
return now.AddDate(0, 0, delta), true
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func parseEventTime(e store.CalendarEvent, loc *time.Location) (time.Time, error) {
|
|
if loc == nil {
|
|
loc = time.Local
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, e.Start); err == nil {
|
|
return t.In(loc), nil
|
|
}
|
|
return time.ParseInLocation("2006-01-02", e.Start, loc)
|
|
}
|
|
|
|
func (a *app) formatDateTime(v string, allDay bool) string {
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
t = t.In(a.timezone)
|
|
if allDay {
|
|
return fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(t.Weekday()), t.Day(), int(t.Month()), t.Year())
|
|
}
|
|
return fmt.Sprintf("%s, %02d.%02d.%04d um %02d:%02d Uhr", germanWeekday(t.Weekday()), t.Day(), int(t.Month()), t.Year(), t.Hour(), t.Minute())
|
|
}
|
|
if t, err := time.ParseInLocation("2006-01-02", v, a.timezone); err == nil {
|
|
return fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(t.Weekday()), t.Day(), int(t.Month()), t.Year())
|
|
}
|
|
return v
|
|
}
|
|
func germanWeekday(w time.Weekday) string {
|
|
return []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}[int(w)]
|
|
}
|
|
|
|
func (a *app) deterministicActionReply(results []automationResult) string {
|
|
if len(results) == 0 {
|
|
return ""
|
|
}
|
|
st := a.db.Snapshot()
|
|
lines := make([]string, 0, len(results))
|
|
for _, r := range results {
|
|
switch r.Type {
|
|
case "add_event", "event_exists":
|
|
for _, e := range st.Events {
|
|
if e.ID == r.ID {
|
|
prefix := "Kalendertermin eingetragen"
|
|
if r.Type == "event_exists" {
|
|
prefix = "Kalendertermin bereits vorhanden"
|
|
}
|
|
line := fmt.Sprintf("%s: „%s“ · %s", prefix, e.Title, a.formatDateTime(e.Start, e.AllDay))
|
|
for _, rem := range st.Reminders {
|
|
if rem.LinkedType == "event" && rem.LinkedID == e.ID && !rem.Done {
|
|
line += fmt.Sprintf(". Automatische Erinnerung: %s", a.formatDateTime(rem.At, false))
|
|
break
|
|
}
|
|
}
|
|
lines = append(lines, line)
|
|
break
|
|
}
|
|
}
|
|
case "add_task", "task_exists":
|
|
for _, t := range st.Tasks {
|
|
if t.ID == r.ID {
|
|
p := "Aufgabe angelegt"
|
|
if r.Type == "task_exists" {
|
|
p = "Aufgabe bereits vorhanden"
|
|
}
|
|
line := p + ": „" + t.Title + "“"
|
|
if t.Due != "" {
|
|
line += " · fällig " + a.formatDateTime(t.Due, false)
|
|
}
|
|
lines = append(lines, line)
|
|
break
|
|
}
|
|
}
|
|
case "add_reminder", "reminder_exists":
|
|
for _, v := range st.Reminders {
|
|
if v.ID == r.ID {
|
|
p := "Erinnerung angelegt"
|
|
if r.Type == "reminder_exists" {
|
|
p = "Erinnerung bereits vorhanden"
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s: „%s“ · %s", p, v.Title, a.formatDateTime(v.At, false)))
|
|
break
|
|
}
|
|
}
|
|
case "add_meal_plan", "meal_updated":
|
|
for _, v := range st.MealPlans {
|
|
if v.ID == r.ID {
|
|
p := "Essensplan eingetragen"
|
|
if r.Type == "meal_updated" {
|
|
p = "Essensplan aktualisiert"
|
|
}
|
|
meal := map[string]string{"breakfast": "Frühstück", "lunch": "Mittagessen", "dinner": "Abendessen", "snack": "Snack"}[normalizeMeal(v.Meal)]
|
|
if meal == "" {
|
|
meal = "Mahlzeit"
|
|
}
|
|
date := v.Date
|
|
if d, err := time.ParseInLocation("2006-01-02", v.Date, a.timezone); err == nil {
|
|
date = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year())
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s: „%s“ · %s · %s · %d Portionen", p, v.Title, date, meal, v.Servings))
|
|
break
|
|
}
|
|
}
|
|
default:
|
|
lines = append(lines, r.Message)
|
|
}
|
|
}
|
|
if len(lines) == 1 {
|
|
return lines[0] + "."
|
|
}
|
|
return "Ausgeführt:\n- " + strings.Join(lines, "\n- ")
|
|
}
|
|
|
|
func eventEquivalent(ae, be store.CalendarEvent, loc *time.Location) bool {
|
|
if textSimilarity(ae.Title, be.Title) < .75 {
|
|
return false
|
|
}
|
|
at, ea := parseEventTime(ae, loc)
|
|
bt, eb := parseEventTime(be, loc)
|
|
if ea != nil || eb != nil {
|
|
return ae.Start == be.Start
|
|
}
|
|
d := at.Sub(bt)
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
return d <= 5*time.Minute
|
|
}
|
|
|
|
func candidateLooksCalendarDerived(c scoutTask, st store.State) bool {
|
|
src := foldCommand(c.Source)
|
|
if strings.Contains(src, "kalender") || strings.Contains(src, "calendar") || strings.Contains(src, "reminder") || strings.Contains(src, "erinnerung") {
|
|
return true
|
|
}
|
|
for _, e := range st.Events {
|
|
sim := textSimilarity(c.Title, e.Title)
|
|
if sim >= .42 {
|
|
return true
|
|
}
|
|
if c.Due != "" {
|
|
ct, ce := time.Parse(time.RFC3339, c.Due)
|
|
et, ee := time.Parse(time.RFC3339, e.Start)
|
|
if ce == nil && ee == nil {
|
|
d := ct.Sub(et)
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
if d < 90*time.Minute && sim >= .25 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, r := range st.Reminders {
|
|
if r.LinkedType == "event" && textSimilarity(c.Title, r.Title) >= .35 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *app) repairLegacyCalendarTasks() int {
|
|
removed := 0
|
|
_ = a.db.Update(func(s *store.State) error {
|
|
removedIDs := map[string]bool{}
|
|
kept := s.Tasks[:0]
|
|
for _, t := range s.Tasks {
|
|
if t.AIManaged && strings.HasPrefix(t.ID, "ai_task") && candidateLooksCalendarDerived(scoutTask{Title: t.Title, Due: t.Due, Source: t.Source}, *s) {
|
|
removedIDs[t.ID] = true
|
|
removed++
|
|
continue
|
|
}
|
|
kept = append(kept, t)
|
|
}
|
|
s.Tasks = append([]store.Task(nil), kept...)
|
|
if len(removedIDs) > 0 {
|
|
cards := s.Kanban[:0]
|
|
for _, c := range s.Kanban {
|
|
if c.TaskID != "" && removedIDs[c.TaskID] {
|
|
continue
|
|
}
|
|
cards = append(cards, c)
|
|
}
|
|
s.Kanban = append([]store.KanbanCard(nil), cards...)
|
|
}
|
|
return nil
|
|
})
|
|
if removed > 0 {
|
|
a.activity("repair", fmt.Sprintf("Legacy-Reparatur: %d kalenderabgeleitete KI-Aufgabe(n) entfernt", removed))
|
|
}
|
|
return removed
|
|
}
|
|
|
|
var (
|
|
eventThemeRE = regexp.MustCompile(`(?i)\b(?:mit\s+dem\s+thema|thema|betreff)\s*[:\-]?\s*(.+)$`)
|
|
eventWithRE = regexp.MustCompile(`(?i)\bmit\s+(.+)$`)
|
|
eventWordRE = regexp.MustCompile(`(?i)\btermin\b|\bkalendereintrag\b|\bevent\b`)
|
|
weekdayTextRE = regexp.MustCompile(`(?i)\b(?:am|fuer|für|diesen|diese[nm]?|naechsten|nächsten|kommenden)?\s*(?:montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)\b`)
|
|
relativeTextRE = regexp.MustCompile(`(?i)\b(?:heute|morgen|uebermorgen|übermorgen)\b`)
|
|
)
|
|
|
|
// deterministicCalendarFallback hält elementare Kalenderbefehle auch dann
|
|
// funktionsfähig, wenn Ollama gerade kein Chat-Modell bereitstellt. Die KI wird
|
|
// weiterhin bevorzugt; dieser Parser greift nur als Sicherheitsnetz.
|
|
func (a *app) deterministicCalendarFallback(msg string) (commandAction, bool) {
|
|
if actionIntentHint(msg) != "event" {
|
|
return commandAction{}, false
|
|
}
|
|
raw := stripModuleContext(msg)
|
|
now := time.Now().In(a.timezone)
|
|
day, hasDate := explicitOrRelativeDate(raw, now)
|
|
if !hasDate {
|
|
return commandAction{}, false
|
|
}
|
|
h, m, hasClock := explicitClock(raw)
|
|
title := extractFallbackEventTitle(raw)
|
|
if title == "" {
|
|
return commandAction{}, false
|
|
}
|
|
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, a.timezone)
|
|
if hasClock {
|
|
start = time.Date(day.Year(), day.Month(), day.Day(), h, m, 0, 0, a.timezone)
|
|
}
|
|
return commandAction{Type: "add_event", Title: title, Start: start.Format(time.RFC3339), AllDay: !hasClock, Confidence: 1}, true
|
|
}
|
|
|
|
func extractFallbackEventTitle(raw string) string {
|
|
s := strings.TrimSpace(raw)
|
|
if m := eventThemeRE.FindStringSubmatch(s); len(m) > 1 {
|
|
if v := cleanFallbackEventTitle(m[1]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
// "Termin für Montag mit Urlaub" / "Termin mit Max am Montag".
|
|
if m := eventWithRE.FindStringSubmatch(s); len(m) > 1 {
|
|
if v := cleanFallbackEventTitle(m[1]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
loc := eventWordRE.FindStringIndex(s)
|
|
if loc != nil {
|
|
before := cleanFallbackEventTitle(s[:loc[0]])
|
|
after := cleanFallbackEventTitle(s[loc[1]:])
|
|
// Bei "Zahnarzt Termin" ist der Teil vor Termin meist der eigentliche Titel;
|
|
// bei "Termin Urlaub" dagegen der Teil danach.
|
|
if after != "" && !strings.EqualFold(after, "an") {
|
|
return after
|
|
}
|
|
if before != "" {
|
|
return before
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func cleanFallbackEventTitle(v string) string {
|
|
s := strings.TrimSpace(v)
|
|
s = clockColonRE.ReplaceAllString(s, " ")
|
|
s = clockUhrRE.ReplaceAllString(s, " ")
|
|
s = germanDateRE.ReplaceAllString(s, " ")
|
|
s = isoDateRE.ReplaceAllString(s, " ")
|
|
s = weekdayTextRE.ReplaceAllString(s, " ")
|
|
s = relativeTextRE.ReplaceAllString(s, " ")
|
|
// Nur Steuer-/Füllwörter entfernen; Inhaltswörter bleiben unangetastet.
|
|
filler := regexp.MustCompile(`(?i)\b(?:ich|habe|erstell(?:e|en)?|leg(?:e)?|anlegen|trage|trag|ein|bitte|einen|eine|den|die|das|fuer|für|am|um|uhr|kalender|kalendereintrag|termin|event|mit|dem|thema|zum|zur|einem)\b`)
|
|
s = filler.ReplaceAllString(s, " ")
|
|
s = strings.Trim(s, " \t\r\n,.:;!?-–—()[]{}\"")
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len([]rune(s)) < 2 {
|
|
return ""
|
|
}
|
|
r := []rune(s)
|
|
if r[0] >= 'a' && r[0] <= 'z' {
|
|
r[0] = r[0] - ('a' - 'A')
|
|
}
|
|
return string(r)
|
|
}
|
|
|
|
type recentMutation struct {
|
|
Kind string
|
|
ID string
|
|
At time.Time
|
|
}
|
|
|
|
func (a *app) rememberRecentMutation(results []automationResult) {
|
|
for i := len(results) - 1; i >= 0; i-- {
|
|
r := results[i]
|
|
kind := ""
|
|
switch r.Type {
|
|
case "add_event":
|
|
kind = "event"
|
|
case "add_meal_plan", "meal_updated":
|
|
kind = "meal_plan"
|
|
case "add_task":
|
|
kind = "task"
|
|
case "add_reminder":
|
|
kind = "reminder"
|
|
}
|
|
if kind == "" || r.ID == "" {
|
|
continue
|
|
}
|
|
a.recentMu.Lock()
|
|
a.recent = &recentMutation{Kind: kind, ID: r.ID, At: time.Now()}
|
|
a.recentMu.Unlock()
|
|
return
|
|
}
|
|
}
|
|
|
|
func (a *app) clearRecentMutation() {
|
|
a.recentMu.Lock()
|
|
a.recent = nil
|
|
a.recentMu.Unlock()
|
|
}
|
|
|
|
func correctionCue(raw string) bool {
|
|
x := foldCommand(strings.TrimSpace(raw))
|
|
for _, cue := range []string{"nein", "doch", "korrig", "statt", "gemeint", "aendere", "ändere", "verschieb"} {
|
|
if strings.Contains(x, foldCommand(cue)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// tryHandleRecentCorrection löst kurze Korrekturen gegen das zuletzt wirklich
|
|
// persistierte Objekt auf. Der Chatverlauf hilft dem LLM beim Verstehen; die
|
|
// tatsächliche Mutation bleibt aber deterministisch in Go.
|
|
func (a *app) tryHandleRecentCorrection(raw string) (string, []automationResult, string, bool) {
|
|
if !correctionCue(raw) {
|
|
return "", nil, "", false
|
|
}
|
|
now := time.Now().In(a.timezone)
|
|
date, hasDate := explicitOrRelativeDate(raw, now)
|
|
hour, minute, hasClock := explicitClock(raw)
|
|
meal, hasMeal := explicitMealFromMessage(raw)
|
|
if !hasDate && !hasClock && !hasMeal {
|
|
return "", nil, "", false
|
|
}
|
|
|
|
a.recentMu.Lock()
|
|
var rm recentMutation
|
|
if a.recent != nil {
|
|
rm = *a.recent
|
|
}
|
|
a.recentMu.Unlock()
|
|
if rm.ID == "" || time.Since(rm.At) > 10*time.Minute {
|
|
return "", nil, "", false
|
|
}
|
|
|
|
switch rm.Kind {
|
|
case "event":
|
|
var updated store.CalendarEvent
|
|
found := false
|
|
err := a.db.Update(func(s *store.State) error {
|
|
for i := range s.Events {
|
|
if s.Events[i].ID != rm.ID {
|
|
continue
|
|
}
|
|
v := s.Events[i]
|
|
base, err := parseEventTime(v, a.timezone)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if hasDate {
|
|
base = time.Date(date.Year(), date.Month(), date.Day(), base.Hour(), base.Minute(), 0, 0, a.timezone)
|
|
}
|
|
if hasClock {
|
|
base = time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, a.timezone)
|
|
v.AllDay = false
|
|
}
|
|
v.Start = base.Format(time.RFC3339)
|
|
v.UpdatedAt = store.Now()
|
|
s.Events[i] = v
|
|
updated, found = v, true
|
|
break
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil || !found {
|
|
return "", nil, "", false
|
|
}
|
|
a.runHomeAutomations(context.Background(), "conversation-correction")
|
|
res := automationResult{Type: "event_updated", ID: updated.ID, Message: "Termin korrigiert: " + updated.Title}
|
|
return fmt.Sprintf("Kalendertermin korrigiert: „%s“ · %s.", updated.Title, a.formatDateTime(updated.Start, updated.AllDay)), []automationResult{res}, "action", true
|
|
case "meal_plan":
|
|
var updated store.MealPlan
|
|
found := false
|
|
err := a.db.Update(func(s *store.State) error {
|
|
for i := range s.MealPlans {
|
|
if s.MealPlans[i].ID != rm.ID {
|
|
continue
|
|
}
|
|
v := s.MealPlans[i]
|
|
if hasDate {
|
|
v.Date = date.Format("2006-01-02")
|
|
}
|
|
if hasMeal {
|
|
v.Meal = meal
|
|
}
|
|
v.UpdatedAt = store.Now()
|
|
s.MealPlans[i] = v
|
|
updated, found = v, true
|
|
break
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil || !found {
|
|
return "", nil, "", false
|
|
}
|
|
a.runHomeAutomations(context.Background(), "conversation-correction")
|
|
dateText := updated.Date
|
|
if d, err := time.ParseInLocation("2006-01-02", updated.Date, a.timezone); err == nil {
|
|
dateText = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year())
|
|
}
|
|
res := automationResult{Type: "meal_updated", ID: updated.ID, Message: "Essensplan korrigiert: " + updated.Title}
|
|
return fmt.Sprintf("Essensplan korrigiert: „%s“ · %s.", updated.Title, dateText), []automationResult{res}, "action", true
|
|
}
|
|
return "", nil, "", false
|
|
}
|
|
|
|
func (a *app) deterministicMealPlanFallback(msg string) (commandAction, bool) {
|
|
if actionIntentHint(msg) != "meal_plan" {
|
|
return commandAction{}, false
|
|
}
|
|
raw := stripModuleContext(msg)
|
|
day, hasDate := explicitOrRelativeDate(raw, time.Now().In(a.timezone))
|
|
if !hasDate {
|
|
return commandAction{}, false
|
|
}
|
|
meal, hasMeal := explicitMealFromMessage(raw)
|
|
if !hasMeal {
|
|
meal = "dinner"
|
|
}
|
|
title := extractFallbackMealTitle(raw)
|
|
if title == "" {
|
|
return commandAction{}, false
|
|
}
|
|
// Preserve recipe linkage even when the LLM is unavailable or returns an
|
|
// unusable action. This keeps meal-plan -> grocery automation working in the
|
|
// deterministic fallback path as well.
|
|
recipeID := ""
|
|
bestTitle := title
|
|
bestScore := 0.0
|
|
for _, r := range a.db.Snapshot().Recipes {
|
|
score := textSimilarity(title, r.Title)
|
|
if score > bestScore {
|
|
bestScore = score
|
|
recipeID = r.ID
|
|
bestTitle = r.Title
|
|
}
|
|
}
|
|
if bestScore < .50 {
|
|
recipeID = ""
|
|
bestTitle = title
|
|
}
|
|
return commandAction{Type: "add_meal_plan", Date: day.Format("2006-01-02"), Meal: meal, RecipeID: recipeID, RecipeTitle: bestTitle, Title: bestTitle, Servings: 2, Confidence: 1}, true
|
|
}
|
|
|
|
func extractFallbackMealTitle(raw string) string {
|
|
s := stripModuleContext(raw)
|
|
s = isoDateRE.ReplaceAllString(s, " ")
|
|
s = germanDateRE.ReplaceAllString(s, " ")
|
|
s = clockColonRE.ReplaceAllString(s, " ")
|
|
s = clockUhrRE.ReplaceAllString(s, " ")
|
|
s = weekdayTextRE.ReplaceAllString(s, " ")
|
|
s = relativeTextRE.ReplaceAllString(s, " ")
|
|
lowerReplacements := []string{
|
|
"zum abendessen", "zum mittagessen", "zum frühstück", "zum fruehstueck",
|
|
"zu abend", "zu mittag", "zum abend", "zum mittag",
|
|
"abendessen", "mittagessen", "frühstück", "fruehstueck",
|
|
"in den essensplan", "auf den essensplan", "essensplan",
|
|
"bitte", "eintragen", "eintragen.", "trage", "trag", "plane", "planen", "erstelle", "füge", "fuege",
|
|
"kannst du", "koenntest du", "könntest du", "ich moechte", "ich möchte",
|
|
"waeren", "wären", "waere", "wäre", "toll", "gerne", "gern", "also",
|
|
"für", "fuer", "am", "an", "zum", "zu", "essen",
|
|
}
|
|
for _, r := range lowerReplacements {
|
|
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(r) + `\b`)
|
|
s = re.ReplaceAllString(s, " ")
|
|
}
|
|
s = strings.Trim(strings.Join(strings.Fields(s), " "), " ,;:-.!?")
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
// Kurze natürliche Gerichtsnamen behalten; keine künstliche Titel-Logik.
|
|
r := []rune(s)
|
|
if len(r) > 0 {
|
|
r[0] = []rune(strings.ToUpper(string(r[0])))[0]
|
|
}
|
|
return string(r)
|
|
}
|