Files
jbergner 112c13f9f0
release-tag / release-image (push) Successful in 3m44s
init
2026-08-28 11:29:58 +02:00

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)
}