1265 lines
45 KiB
Go
1265 lines
45 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"homehub/internal/ollama"
|
|
"homehub/internal/store"
|
|
)
|
|
|
|
type toolError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Candidates any `json:"candidates,omitempty"`
|
|
}
|
|
|
|
type toolEntity struct {
|
|
Kind string `json:"kind"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
type toolResult struct {
|
|
Success bool `json:"success"`
|
|
Tool string `json:"tool"`
|
|
RequestID string `json:"request_id"`
|
|
Data any `json:"data,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Error *toolError `json:"error,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
Mutated bool `json:"mutated,omitempty"`
|
|
Entity *toolEntity `json:"entity,omitempty"`
|
|
}
|
|
|
|
type temporalExpr struct {
|
|
Kind string `json:"kind"`
|
|
Date string `json:"date,omitempty"`
|
|
Time string `json:"time,omitempty"`
|
|
OffsetDays int `json:"offset_days,omitempty"`
|
|
Weekday string `json:"weekday,omitempty"`
|
|
AllDay bool `json:"all_day,omitempty"`
|
|
ForceNext bool `json:"force_next,omitempty"`
|
|
}
|
|
|
|
type toolRegistry struct {
|
|
app *app
|
|
defs []ollama.Tool
|
|
known map[string]bool
|
|
}
|
|
|
|
func newToolRegistry(a *app) *toolRegistry {
|
|
r := &toolRegistry{app: a, known: map[string]bool{}}
|
|
r.defs = []ollama.Tool{
|
|
toolDef("system_get_time", "Liefert die aktuelle lokale Serverzeit und Zeitzone. Nutze dieses Tool, wenn du Zeitbezüge prüfen musst.", obj(nil, nil)),
|
|
toolDef("calendar_list", "Liest Kalendertermine. Nutze es vor update/delete, wenn die konkrete ID nicht sicher bekannt ist.", obj(map[string]any{
|
|
"query": str("Optionaler Titel-/Suchtext"), "when": temporalSchema(false), "limit": integer("Maximale Treffer, Standard 20", 1, 50),
|
|
}, nil)),
|
|
toolDef("calendar_create", "Erstellt genau einen Kalendertermin. Relative Tage/Wochentage NICHT selbst ausrechnen, sondern symbolisch in when übergeben.", obj(map[string]any{
|
|
"title": str("Kurzer Termintitel"), "when": temporalSchema(true), "duration_minutes": integer("Dauer in Minuten; bei ganztägig weglassen", 1, 1440), "location": str("Ort"), "notes": str("Notizen"),
|
|
}, []string{"title", "when"})),
|
|
toolDef("calendar_update", "Ändert einen vorhandenen Kalendertermin anhand seiner ID.", obj(map[string]any{
|
|
"id": str("Event-ID aus calendar_list/create"), "title": str("Neuer Titel"), "when": temporalSchema(false), "duration_minutes": integer("Neue Dauer in Minuten", 1, 1440), "location": str("Neuer Ort"), "notes": str("Neue Notizen"),
|
|
}, []string{"id"})),
|
|
toolDef("calendar_delete", "Löscht exakt einen Kalendertermin anhand seiner ID. Bei unklarem Ziel zuerst calendar_list verwenden.", obj(map[string]any{"id": str("Event-ID")}, []string{"id"})),
|
|
|
|
toolDef("tasks_list", "Liest Aufgaben.", obj(map[string]any{"status": enum("Filter", "open", "done", "all"), "query": str("Optionaler Suchtext"), "limit": integer("Maximale Treffer", 1, 50)}, nil)),
|
|
toolDef("tasks_create", "Erstellt eine Aufgabe, keinen Kalendertermin.", obj(map[string]any{
|
|
"title": str("Aufgabentitel"), "notes": str("Notizen"), "due": temporalSchema(false), "priority": enum("Priorität", "low", "normal", "high"),
|
|
}, []string{"title"})),
|
|
toolDef("tasks_update", "Ändert oder erledigt eine vorhandene Aufgabe anhand ihrer ID.", obj(map[string]any{
|
|
"id": str("Task-ID"), "title": str("Neuer Titel"), "notes": str("Neue Notizen"), "due": temporalSchema(false), "priority": enum("Priorität", "low", "normal", "high"), "status": enum("Status", "open", "done"),
|
|
}, []string{"id"})),
|
|
toolDef("tasks_delete", "Löscht exakt eine Aufgabe anhand ihrer ID.", obj(map[string]any{"id": str("Task-ID")}, []string{"id"})),
|
|
|
|
toolDef("reminders_list", "Liest offene oder erledigte Erinnerungen.", obj(map[string]any{"done": boolean("Optional: true nur erledigte, false nur offene"), "limit": integer("Maximale Treffer", 1, 50)}, nil)),
|
|
toolDef("reminders_create", "Erstellt eine explizite Erinnerung.", obj(map[string]any{"title": str("Titel"), "when": temporalSchema(true), "notes": str("Notizen")}, []string{"title", "when"})),
|
|
toolDef("reminders_delete", "Löscht eine Erinnerung anhand ihrer ID.", obj(map[string]any{"id": str("Reminder-ID")}, []string{"id"})),
|
|
|
|
toolDef("groceries_list", "Liest die Einkaufsliste.", obj(map[string]any{"checked": boolean("Optionaler Filter für erledigt/nicht erledigt")}, nil)),
|
|
toolDef("groceries_add", "Fügt einen Artikel zur Einkaufsliste hinzu.", obj(map[string]any{"name": str("Artikel"), "quantity": str("Menge als Text/Zahl"), "unit": str("Einheit"), "category": str("Kategorie")}, []string{"name"})),
|
|
toolDef("groceries_update", "Ändert oder hakt einen Einkaufsartikel ab.", obj(map[string]any{"id": str("Grocery-ID"), "name": str("Name"), "quantity": str("Menge"), "unit": str("Einheit"), "category": str("Kategorie"), "checked": boolean("Erledigt")}, []string{"id"})),
|
|
toolDef("groceries_remove", "Entfernt einen Einkaufsartikel anhand seiner ID.", obj(map[string]any{"id": str("Grocery-ID")}, []string{"id"})),
|
|
toolDef("groceries_sync_mealplan", "Erzeugt bzw. aktualisiert die automatische Einkaufsliste aus den Zutaten der im Essensplan verknüpften Rezepte. Liefert auch Mahlzeiten ohne Rezeptzuordnung zurück.", obj(map[string]any{"days": integer("Planungshorizont ab heute in Tagen, Standard 14", 1, 60)}, nil)),
|
|
|
|
toolDef("recipes_list", "Liest das Rezeptbuch. Für generierte Essenspläne zuerst verwenden, damit bevorzugt vorhandene Rezepte geplant werden.", obj(map[string]any{"limit": integer("Maximale Treffer", 1, 100)}, nil)),
|
|
toolDef("recipes_search", "Sucht vorhandene Rezepte. Nutze es vor mealplan_create, wenn der Nutzer ein bekanntes Rezept/Dish meint.", obj(map[string]any{"query": str("Rezept-/Gerichtname"), "limit": integer("Maximale Treffer", 1, 20)}, []string{"query"})),
|
|
toolDef("recipes_get", "Liest ein Rezept mit Zutaten und Schritten anhand seiner ID.", obj(map[string]any{"id": str("Recipe-ID")}, []string{"id"})),
|
|
toolDef("recipes_create", "Speichert ein neues Rezept.", obj(map[string]any{
|
|
"title": str("Rezeptname"), "description": str("Beschreibung"), "servings": integer("Portionen", 1, 100), "prep_minutes": integer("Zubereitungszeit", 0, 1440),
|
|
"ingredients": map[string]any{"type": "array", "items": obj(map[string]any{"name": str("Zutat"), "quantity": str("Menge"), "unit": str("Einheit")}, []string{"name"})},
|
|
"steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
}, []string{"title"})),
|
|
toolDef("recipes_update", "Ändert ein vorhandenes Rezept anhand seiner ID. Nur übergebene Felder werden geändert; Zutaten und Schritte können vollständig ersetzt werden.", obj(map[string]any{
|
|
"id": str("Recipe-ID"), "title": str("Neuer Rezeptname"), "description": str("Neue Beschreibung"), "servings": integer("Portionen", 1, 100), "prep_minutes": integer("Zubereitungszeit", 0, 1440),
|
|
"ingredients": map[string]any{"type": "array", "items": obj(map[string]any{"name": str("Zutat"), "quantity": str("Menge"), "unit": str("Einheit")}, []string{"name"})},
|
|
"steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
}, []string{"id"})),
|
|
|
|
toolDef("mealplan_list", "Liest die Essensplanung, optional für einen Tag oder Mahlzeit.", obj(map[string]any{"when": temporalSchema(false), "meal": enum("Mahlzeit", "breakfast", "lunch", "dinner", "snack"), "limit": integer("Maximale Treffer", 1, 50)}, nil)),
|
|
toolDef("mealplan_create", "Plant ein Essen. Relative Tage/Wochentage symbolisch in when übergeben. recipe_id nur verwenden, wenn es aus recipes_search/get stammt.", obj(map[string]any{
|
|
"title": str("Gerichtname"), "when": temporalSchema(true), "meal": enum("Mahlzeit", "breakfast", "lunch", "dinner", "snack"), "recipe_id": str("Optionale vorhandene Recipe-ID"), "servings": integer("Portionen", 1, 100), "notes": str("Notizen"),
|
|
}, []string{"title", "when", "meal"})),
|
|
toolDef("mealplan_update", "Verschiebt oder ändert einen vorhandenen Essensplan-Eintrag anhand seiner ID.", obj(map[string]any{
|
|
"id": str("MealPlan-ID"), "title": str("Neuer Gerichtname"), "when": temporalSchema(false), "meal": enum("Mahlzeit", "breakfast", "lunch", "dinner", "snack"), "recipe_id": str("Recipe-ID"), "servings": integer("Portionen", 1, 100), "notes": str("Notizen"),
|
|
}, []string{"id"})),
|
|
toolDef("mealplan_delete", "Löscht einen Essensplan-Eintrag anhand seiner ID.", obj(map[string]any{"id": str("MealPlan-ID")}, []string{"id"})),
|
|
|
|
toolDef("kanban_list", "Liest Kanban-Karten.", obj(map[string]any{"column": enum("Spalte", "inbox", "next", "doing", "waiting", "done", "all")}, nil)),
|
|
toolDef("kanban_create", "Erstellt eine manuelle Kanban-Karte.", obj(map[string]any{"title": str("Titel"), "notes": str("Notizen"), "column": enum("Spalte", "inbox", "next", "doing", "waiting", "done"), "priority": enum("Priorität", "low", "normal", "high")}, []string{"title"})),
|
|
toolDef("kanban_move", "Verschiebt eine Kanban-Karte anhand ihrer ID und schützt sie als manuellen Override vor KI-Umsortierung.", obj(map[string]any{"id": str("Kanban-ID"), "column": enum("Zielspalte", "inbox", "next", "doing", "waiting", "done")}, []string{"id", "column"})),
|
|
toolDef("kanban_delete", "Löscht eine Kanban-Karte anhand ihrer ID.", obj(map[string]any{"id": str("Kanban-ID")}, []string{"id"})),
|
|
|
|
toolDef("rag_search", "Sucht semantisch in lokal indexierten Dokumenten. Nur für Dokumentwissen verwenden.", obj(map[string]any{"query": str("Suchfrage"), "limit": integer("Maximale Treffer", 1, 10)}, []string{"query"})),
|
|
}
|
|
for _, d := range r.defs {
|
|
r.known[d.Function.Name] = true
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (r *toolRegistry) Definitions() []ollama.Tool { return append([]ollama.Tool(nil), r.defs...) }
|
|
func (r *toolRegistry) Has(name string) bool { return r.known[name] }
|
|
|
|
func toolDef(name, description string, params map[string]any) ollama.Tool {
|
|
return ollama.Tool{Type: "function", Function: ollama.ToolFunction{Name: name, Description: description, Parameters: params}}
|
|
}
|
|
func obj(props map[string]any, required []string) map[string]any {
|
|
if props == nil {
|
|
props = map[string]any{}
|
|
}
|
|
m := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
|
if len(required) > 0 {
|
|
m["required"] = required
|
|
}
|
|
return m
|
|
}
|
|
func str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
|
func boolean(desc string) map[string]any {
|
|
return map[string]any{"type": "boolean", "description": desc}
|
|
}
|
|
func integer(desc string, min, max int) map[string]any {
|
|
return map[string]any{"type": "integer", "description": desc, "minimum": min, "maximum": max}
|
|
}
|
|
func enum(desc string, vals ...string) map[string]any {
|
|
return map[string]any{"type": "string", "description": desc, "enum": vals}
|
|
}
|
|
func temporalSchema(required bool) map[string]any {
|
|
m := obj(map[string]any{
|
|
"kind": enum("absolute = konkretes Datum; relative = Tage ab heute; weekday = nächster genannter Wochentag", "absolute", "relative", "weekday"),
|
|
"date": str("Nur bei absolute: YYYY-MM-DD"),
|
|
"time": str("Lokale Uhrzeit HH:MM; weglassen, wenn keine Uhrzeit genannt wurde"),
|
|
"offset_days": map[string]any{"type": "integer", "description": "Nur bei relative: heute=0, morgen=1, übermorgen=2", "minimum": 0, "maximum": 3650},
|
|
"weekday": enum("Nur bei weekday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"),
|
|
"all_day": boolean("true, wenn ausdrücklich ganztägig oder keine Uhrzeit relevant ist"),
|
|
}, []string{"kind"})
|
|
if !required {
|
|
m["description"] = "Optionaler Zeitbezug"
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (r *toolRegistry) Execute(ctx context.Context, name string, args map[string]any) toolResult {
|
|
requestID := id("tool")
|
|
res := toolResult{Tool: name, RequestID: requestID, Warnings: []string{}}
|
|
if !r.Has(name) {
|
|
res.Error = &toolError{Code: "UNKNOWN_TOOL", Message: "Tool ist nicht registriert: " + name}
|
|
return res
|
|
}
|
|
|
|
switch name {
|
|
case "system_get_time":
|
|
now := time.Now().In(r.app.timezone)
|
|
res.Success = true
|
|
res.Data = map[string]any{"now": now.Format(time.RFC3339), "timezone": r.app.timezone.String(), "weekday": strings.ToLower(now.Weekday().String())}
|
|
case "calendar_list":
|
|
res = r.calendarList(requestID, args)
|
|
case "calendar_create":
|
|
res = r.calendarCreate(requestID, args)
|
|
case "calendar_update":
|
|
res = r.calendarUpdate(requestID, args)
|
|
case "calendar_delete":
|
|
res = r.calendarDelete(requestID, args)
|
|
case "tasks_list":
|
|
res = r.tasksList(requestID, args)
|
|
case "tasks_create":
|
|
res = r.tasksCreate(requestID, args)
|
|
case "tasks_update":
|
|
res = r.tasksUpdate(requestID, args)
|
|
case "tasks_delete":
|
|
res = r.tasksDelete(requestID, args)
|
|
case "reminders_list":
|
|
res = r.remindersList(requestID, args)
|
|
case "reminders_create":
|
|
res = r.remindersCreate(requestID, args)
|
|
case "reminders_delete":
|
|
res = r.remindersDelete(requestID, args)
|
|
case "groceries_list":
|
|
res = r.groceriesList(requestID, args)
|
|
case "groceries_add":
|
|
res = r.groceriesAdd(requestID, args)
|
|
case "groceries_update":
|
|
res = r.groceriesUpdate(requestID, args)
|
|
case "groceries_remove":
|
|
res = r.groceriesRemove(requestID, args)
|
|
case "groceries_sync_mealplan":
|
|
res = r.groceriesSyncMealplan(requestID, args)
|
|
case "recipes_list":
|
|
res = r.recipesList(requestID, args)
|
|
case "recipes_search":
|
|
res = r.recipesSearch(requestID, args)
|
|
case "recipes_get":
|
|
res = r.recipesGet(requestID, args)
|
|
case "recipes_create":
|
|
res = r.recipesCreate(requestID, args)
|
|
case "recipes_update":
|
|
res = r.recipesUpdate(requestID, args)
|
|
case "mealplan_list":
|
|
res = r.mealplanList(requestID, args)
|
|
case "mealplan_create":
|
|
res = r.mealplanCreate(requestID, args)
|
|
case "mealplan_update":
|
|
res = r.mealplanUpdate(requestID, args)
|
|
case "mealplan_delete":
|
|
res = r.mealplanDelete(requestID, args)
|
|
case "kanban_list":
|
|
res = r.kanbanList(requestID, args)
|
|
case "kanban_create":
|
|
res = r.kanbanCreate(requestID, args)
|
|
case "kanban_move":
|
|
res = r.kanbanMove(requestID, args)
|
|
case "kanban_delete":
|
|
res = r.kanbanDelete(requestID, args)
|
|
case "rag_search":
|
|
res = r.ragSearch(ctx, requestID, args)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func okTool(name, req string, data any) toolResult {
|
|
return toolResult{Success: true, Tool: name, RequestID: req, Data: data, Warnings: []string{}}
|
|
}
|
|
func failTool(name, req, code, msg string) toolResult {
|
|
return toolResult{Success: false, Tool: name, RequestID: req, Error: &toolError{Code: code, Message: msg}, Warnings: []string{}}
|
|
}
|
|
func decodeArgs(args map[string]any, out any) error {
|
|
b, err := json.Marshal(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(b, out)
|
|
}
|
|
|
|
func (r *toolRegistry) resolveWhen(w temporalExpr) (time.Time, bool, error) {
|
|
now := time.Now().In(r.app.timezone)
|
|
kind := strings.ToLower(strings.TrimSpace(w.Kind))
|
|
var day time.Time
|
|
switch kind {
|
|
case "absolute":
|
|
if strings.TrimSpace(w.Date) == "" {
|
|
return time.Time{}, false, fmt.Errorf("date fehlt")
|
|
}
|
|
t, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(w.Date), r.app.timezone)
|
|
if err != nil {
|
|
return time.Time{}, false, fmt.Errorf("date muss YYYY-MM-DD sein")
|
|
}
|
|
day = t
|
|
case "relative":
|
|
if w.OffsetDays < 0 {
|
|
return time.Time{}, false, fmt.Errorf("offset_days darf nicht negativ sein")
|
|
}
|
|
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, r.app.timezone).AddDate(0, 0, w.OffsetDays)
|
|
case "weekday":
|
|
wd, ok := parseWeekday(w.Weekday)
|
|
if !ok {
|
|
return time.Time{}, false, fmt.Errorf("weekday ungültig")
|
|
}
|
|
delta := (int(wd) - int(now.Weekday()) + 7) % 7
|
|
if delta == 0 && w.ForceNext {
|
|
delta = 7
|
|
}
|
|
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, r.app.timezone).AddDate(0, 0, delta)
|
|
default:
|
|
return time.Time{}, false, fmt.Errorf("kind muss absolute, relative oder weekday sein")
|
|
}
|
|
allDay := w.AllDay || strings.TrimSpace(w.Time) == ""
|
|
if allDay {
|
|
return day, true, nil
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(w.Time), ":")
|
|
if len(parts) != 2 {
|
|
return time.Time{}, false, fmt.Errorf("time muss HH:MM sein")
|
|
}
|
|
h, e1 := strconv.Atoi(parts[0])
|
|
m, e2 := strconv.Atoi(parts[1])
|
|
if e1 != nil || e2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
|
return time.Time{}, false, fmt.Errorf("time ungültig")
|
|
}
|
|
return time.Date(day.Year(), day.Month(), day.Day(), h, m, 0, 0, r.app.timezone), false, nil
|
|
}
|
|
func parseWeekday(v string) (time.Weekday, bool) {
|
|
switch foldCommand(v) {
|
|
case "monday", "montag":
|
|
return time.Monday, true
|
|
case "tuesday", "dienstag":
|
|
return time.Tuesday, true
|
|
case "wednesday", "mittwoch":
|
|
return time.Wednesday, true
|
|
case "thursday", "donnerstag":
|
|
return time.Thursday, true
|
|
case "friday", "freitag":
|
|
return time.Friday, true
|
|
case "saturday", "samstag":
|
|
return time.Saturday, true
|
|
case "sunday", "sonntag":
|
|
return time.Sunday, true
|
|
}
|
|
return time.Sunday, false
|
|
}
|
|
|
|
func getString(args map[string]any, key string) string {
|
|
if v, ok := args[key].(string); ok {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return ""
|
|
}
|
|
func getInt(args map[string]any, key string, def int) int {
|
|
switch v := args[key].(type) {
|
|
case float64:
|
|
return int(v)
|
|
case int:
|
|
return v
|
|
case json.Number:
|
|
if n, err := v.Int64(); err == nil {
|
|
return int(n)
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
func getBoolPtr(args map[string]any, key string) *bool {
|
|
if v, ok := args[key].(bool); ok {
|
|
return &v
|
|
}
|
|
return nil
|
|
}
|
|
func getTemporal(args map[string]any, key string) (temporalExpr, bool, error) {
|
|
v, ok := args[key]
|
|
if !ok || v == nil {
|
|
return temporalExpr{}, false, nil
|
|
}
|
|
var t temporalExpr
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return t, true, err
|
|
}
|
|
if err := json.Unmarshal(b, &t); err != nil {
|
|
return t, true, err
|
|
}
|
|
return t, true, nil
|
|
}
|
|
|
|
func (r *toolRegistry) calendarList(req string, args map[string]any) toolResult {
|
|
st := r.app.db.Snapshot()
|
|
xs := sortedEvents(st.Events)
|
|
query := getString(args, "query")
|
|
limit := getInt(args, "limit", 20)
|
|
var filterDay string
|
|
if w, ok, err := getTemporal(args, "when"); err != nil {
|
|
return failTool("calendar_list", req, "INVALID_ARGUMENTS", err.Error())
|
|
} else if ok {
|
|
t, _, err := r.resolveWhen(w)
|
|
if err != nil {
|
|
return failTool("calendar_list", req, "INVALID_TIME", err.Error())
|
|
}
|
|
filterDay = t.Format("2006-01-02")
|
|
}
|
|
out := make([]store.CalendarEvent, 0)
|
|
for _, e := range xs {
|
|
if filterDay != "" {
|
|
t, err := parseEventTime(e, r.app.timezone)
|
|
if err != nil || t.Format("2006-01-02") != filterDay {
|
|
continue
|
|
}
|
|
}
|
|
if query != "" && textSimilarity(query, e.Title+" "+e.Location+" "+e.Notes) < .2 {
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return okTool("calendar_list", req, out)
|
|
}
|
|
func (r *toolRegistry) calendarCreate(req string, args map[string]any) toolResult {
|
|
var p struct {
|
|
Title string `json:"title"`
|
|
When temporalExpr `json:"when"`
|
|
Duration int `json:"duration_minutes"`
|
|
Location string `json:"location"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
if err := decodeArgs(args, &p); err != nil || strings.TrimSpace(p.Title) == "" {
|
|
return failTool("calendar_create", req, "INVALID_ARGUMENTS", "title und when sind erforderlich")
|
|
}
|
|
t, allDay, err := r.resolveWhen(p.When)
|
|
if err != nil {
|
|
return failTool("calendar_create", req, "INVALID_TIME", err.Error())
|
|
}
|
|
start := t.Format(time.RFC3339)
|
|
end := ""
|
|
if p.Duration > 0 && !allDay {
|
|
end = t.Add(time.Duration(p.Duration) * time.Minute).Format(time.RFC3339)
|
|
}
|
|
v := store.CalendarEvent{ID: id("event_tool"), Title: strings.TrimSpace(p.Title), Start: start, End: end, AllDay: allDay, Location: strings.TrimSpace(p.Location), Notes: strings.TrimSpace(p.Notes), Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
|
|
for _, e := range r.app.db.Snapshot().Events {
|
|
if eventEquivalent(e, v, r.app.timezone) {
|
|
res := okTool("calendar_create", req, e)
|
|
res.Warnings = []string{"ALREADY_EXISTS"}
|
|
res.Entity = &toolEntity{Kind: "event", ID: e.ID}
|
|
return res
|
|
}
|
|
}
|
|
if err := r.app.db.Update(func(s *store.State) error { s.Events = append(s.Events, v); return nil }); err != nil {
|
|
return failTool("calendar_create", req, "STORE_ERROR", err.Error())
|
|
}
|
|
res := okTool("calendar_create", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "event", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) calendarUpdate(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
if idv == "" {
|
|
return failTool("calendar_update", req, "INVALID_ARGUMENTS", "id fehlt")
|
|
}
|
|
st := r.app.db.Snapshot()
|
|
var cur *store.CalendarEvent
|
|
for i := range st.Events {
|
|
if st.Events[i].ID == idv {
|
|
x := st.Events[i]
|
|
cur = &x
|
|
break
|
|
}
|
|
}
|
|
if cur == nil {
|
|
return failTool("calendar_update", req, "NOT_FOUND", "Kalendertermin nicht gefunden")
|
|
}
|
|
v := *cur
|
|
if x := getString(args, "title"); x != "" {
|
|
v.Title = x
|
|
}
|
|
if x := getString(args, "location"); x != "" {
|
|
v.Location = x
|
|
}
|
|
if x := getString(args, "notes"); x != "" {
|
|
v.Notes = x
|
|
}
|
|
if w, ok, err := getTemporal(args, "when"); err != nil {
|
|
return failTool("calendar_update", req, "INVALID_ARGUMENTS", err.Error())
|
|
} else if ok {
|
|
t, ad, e := r.resolveWhen(w)
|
|
if e != nil {
|
|
return failTool("calendar_update", req, "INVALID_TIME", e.Error())
|
|
}
|
|
v.Start = t.Format(time.RFC3339)
|
|
v.AllDay = ad
|
|
v.End = ""
|
|
}
|
|
if d := getInt(args, "duration_minutes", 0); d > 0 && !v.AllDay {
|
|
if t, e := time.Parse(time.RFC3339, v.Start); e == nil {
|
|
v.End = t.Add(time.Duration(d) * time.Minute).Format(time.RFC3339)
|
|
}
|
|
}
|
|
v.UpdatedAt = store.Now()
|
|
if err := r.app.db.Update(func(s *store.State) error { upsertEvent(s, v); return nil }); err != nil {
|
|
return failTool("calendar_update", req, "STORE_ERROR", err.Error())
|
|
}
|
|
res := okTool("calendar_update", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "event", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) calendarDelete(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
if idv == "" {
|
|
return failTool("calendar_delete", req, "INVALID_ARGUMENTS", "id fehlt")
|
|
}
|
|
st := r.app.db.Snapshot()
|
|
var found *store.CalendarEvent
|
|
for i := range st.Events {
|
|
if st.Events[i].ID == idv {
|
|
x := st.Events[i]
|
|
found = &x
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return failTool("calendar_delete", req, "NOT_FOUND", "Kalendertermin nicht gefunden")
|
|
}
|
|
_ = r.app.db.Update(func(s *store.State) error {
|
|
s.Events = filterEvents(s.Events, idv)
|
|
rem := s.Reminders[:0]
|
|
for _, x := range s.Reminders {
|
|
if !(x.LinkedType == "event" && x.LinkedID == idv) {
|
|
rem = append(rem, x)
|
|
}
|
|
}
|
|
s.Reminders = append([]store.Reminder(nil), rem...)
|
|
return nil
|
|
})
|
|
res := okTool("calendar_delete", req, map[string]any{"deleted": found})
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "event", ID: idv}
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) tasksList(req string, args map[string]any) toolResult {
|
|
st := r.app.db.Snapshot()
|
|
status := getString(args, "status")
|
|
query := getString(args, "query")
|
|
limit := getInt(args, "limit", 20)
|
|
out := []store.Task{}
|
|
for _, t := range st.Tasks {
|
|
if status == "open" && t.Status == "done" {
|
|
continue
|
|
}
|
|
if status == "done" && t.Status != "done" {
|
|
continue
|
|
}
|
|
if query != "" && textSimilarity(query, t.Title+" "+t.Notes) < .2 {
|
|
continue
|
|
}
|
|
out = append(out, t)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return okTool("tasks_list", req, out)
|
|
}
|
|
func (r *toolRegistry) tasksCreate(req string, args map[string]any) toolResult {
|
|
title := getString(args, "title")
|
|
if title == "" {
|
|
return failTool("tasks_create", req, "INVALID_ARGUMENTS", "title fehlt")
|
|
}
|
|
due := ""
|
|
if w, ok, err := getTemporal(args, "due"); err != nil {
|
|
return failTool("tasks_create", req, "INVALID_ARGUMENTS", err.Error())
|
|
} else if ok {
|
|
t, _, e := r.resolveWhen(w)
|
|
if e != nil {
|
|
return failTool("tasks_create", req, "INVALID_TIME", e.Error())
|
|
}
|
|
due = t.Format(time.RFC3339)
|
|
}
|
|
st := r.app.db.Snapshot()
|
|
for _, t := range st.Tasks {
|
|
if t.Status != "done" && normalizeTask(t.Title) == normalizeTask(title) {
|
|
res := okTool("tasks_create", req, t)
|
|
res.Warnings = []string{"ALREADY_EXISTS"}
|
|
res.Entity = &toolEntity{Kind: "task", ID: t.ID}
|
|
return res
|
|
}
|
|
}
|
|
source := getString(args, "source")
|
|
if source == "" {
|
|
source = "jarvis-tool"
|
|
}
|
|
confidence := 0.0
|
|
if v, ok := args["confidence"].(float64); ok {
|
|
confidence = v
|
|
}
|
|
v := store.Task{ID: id("task_tool"), Title: title, Notes: getString(args, "notes"), Due: due, Status: "open", Priority: normalizePriority(getString(args, "priority")), Source: source, Confidence: confidence, Fingerprint: getString(args, "fingerprint"), AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
|
|
_ = r.app.db.Update(func(s *store.State) error { s.Tasks = append([]store.Task{v}, s.Tasks...); return nil })
|
|
res := okTool("tasks_create", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "task", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) tasksUpdate(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var cur *store.Task
|
|
for i := range st.Tasks {
|
|
if st.Tasks[i].ID == idv {
|
|
x := st.Tasks[i]
|
|
cur = &x
|
|
break
|
|
}
|
|
}
|
|
if cur == nil {
|
|
return failTool("tasks_update", req, "NOT_FOUND", "Aufgabe nicht gefunden")
|
|
}
|
|
v := *cur
|
|
if x := getString(args, "title"); x != "" {
|
|
v.Title = x
|
|
}
|
|
if x := getString(args, "notes"); x != "" {
|
|
v.Notes = x
|
|
}
|
|
if x := getString(args, "priority"); x != "" {
|
|
v.Priority = normalizePriority(x)
|
|
}
|
|
if x := getString(args, "status"); x == "open" || x == "done" {
|
|
v.Status = x
|
|
}
|
|
if w, ok, _ := getTemporal(args, "due"); ok {
|
|
t, _, e := r.resolveWhen(w)
|
|
if e != nil {
|
|
return failTool("tasks_update", req, "INVALID_TIME", e.Error())
|
|
}
|
|
v.Due = t.Format(time.RFC3339)
|
|
}
|
|
v.UpdatedAt = store.Now()
|
|
_ = r.app.db.Update(func(s *store.State) error {
|
|
for i := range s.Tasks {
|
|
if s.Tasks[i].ID == v.ID {
|
|
s.Tasks[i] = v
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
res := okTool("tasks_update", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "task", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) tasksDelete(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var found *store.Task
|
|
for i := range st.Tasks {
|
|
if st.Tasks[i].ID == idv {
|
|
x := st.Tasks[i]
|
|
found = &x
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return failTool("tasks_delete", req, "NOT_FOUND", "Aufgabe nicht gefunden")
|
|
}
|
|
_ = r.app.db.Update(func(s *store.State) error {
|
|
out := s.Tasks[:0]
|
|
for _, x := range s.Tasks {
|
|
if x.ID != idv {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
s.Tasks = append([]store.Task(nil), out...)
|
|
cards := s.Kanban[:0]
|
|
for _, c := range s.Kanban {
|
|
if c.TaskID != idv {
|
|
cards = append(cards, c)
|
|
}
|
|
}
|
|
s.Kanban = append([]store.KanbanCard(nil), cards...)
|
|
return nil
|
|
})
|
|
res := okTool("tasks_delete", req, map[string]any{"deleted": found})
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "task", ID: idv}
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) remindersList(req string, args map[string]any) toolResult {
|
|
st := r.app.db.Snapshot()
|
|
f := getBoolPtr(args, "done")
|
|
limit := getInt(args, "limit", 20)
|
|
out := []store.Reminder{}
|
|
for _, x := range st.Reminders {
|
|
if f != nil && x.Done != *f {
|
|
continue
|
|
}
|
|
out = append(out, x)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At })
|
|
return okTool("reminders_list", req, out)
|
|
}
|
|
func (r *toolRegistry) remindersCreate(req string, args map[string]any) toolResult {
|
|
title := getString(args, "title")
|
|
w, ok, err := getTemporal(args, "when")
|
|
if title == "" || err != nil || !ok {
|
|
return failTool("reminders_create", req, "INVALID_ARGUMENTS", "title und when erforderlich")
|
|
}
|
|
t, _, e := r.resolveWhen(w)
|
|
if e != nil {
|
|
return failTool("reminders_create", req, "INVALID_TIME", e.Error())
|
|
}
|
|
v := store.Reminder{ID: id("rem_tool"), Title: title, At: t.Format(time.RFC3339), Notes: getString(args, "notes"), Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
|
|
_ = r.app.db.Update(func(s *store.State) error { s.Reminders = append(s.Reminders, v); return nil })
|
|
res := okTool("reminders_create", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "reminder", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) remindersDelete(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var found *store.Reminder
|
|
for i := range st.Reminders {
|
|
if st.Reminders[i].ID == idv {
|
|
x := st.Reminders[i]
|
|
found = &x
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return failTool("reminders_delete", req, "NOT_FOUND", "Erinnerung nicht gefunden")
|
|
}
|
|
_ = r.app.db.Update(func(s *store.State) error {
|
|
out := s.Reminders[:0]
|
|
for _, x := range s.Reminders {
|
|
if x.ID != idv {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
s.Reminders = append([]store.Reminder(nil), out...)
|
|
return nil
|
|
})
|
|
res := okTool("reminders_delete", req, map[string]any{"deleted": found})
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "reminder", ID: idv}
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) groceriesList(req string, args map[string]any) toolResult {
|
|
st := r.app.db.Snapshot()
|
|
f := getBoolPtr(args, "checked")
|
|
out := []store.GroceryItem{}
|
|
for _, x := range st.Groceries {
|
|
if f != nil && x.Checked != *f {
|
|
continue
|
|
}
|
|
out = append(out, x)
|
|
}
|
|
return okTool("groceries_list", req, out)
|
|
}
|
|
func (r *toolRegistry) groceriesAdd(req string, args map[string]any) toolResult {
|
|
name := getString(args, "name")
|
|
if name == "" {
|
|
return failTool("groceries_add", req, "INVALID_ARGUMENTS", "name fehlt")
|
|
}
|
|
v := store.GroceryItem{ID: id("grocery_tool"), Name: name, Quantity: getString(args, "quantity"), Unit: getString(args, "unit"), Category: getString(args, "category"), Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
|
|
_ = r.app.db.Update(func(s *store.State) error { s.Groceries = append(s.Groceries, v); return nil })
|
|
res := okTool("groceries_add", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "grocery", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) groceriesUpdate(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var cur *store.GroceryItem
|
|
for i := range st.Groceries {
|
|
if st.Groceries[i].ID == idv {
|
|
x := st.Groceries[i]
|
|
cur = &x
|
|
break
|
|
}
|
|
}
|
|
if cur == nil {
|
|
return failTool("groceries_update", req, "NOT_FOUND", "Einkaufsartikel nicht gefunden")
|
|
}
|
|
v := *cur
|
|
if x := getString(args, "name"); x != "" {
|
|
v.Name = x
|
|
}
|
|
if x := getString(args, "quantity"); x != "" {
|
|
v.Quantity = x
|
|
}
|
|
if x := getString(args, "unit"); x != "" {
|
|
v.Unit = x
|
|
}
|
|
if x := getString(args, "category"); x != "" {
|
|
v.Category = x
|
|
}
|
|
if b := getBoolPtr(args, "checked"); b != nil {
|
|
v.Checked = *b
|
|
}
|
|
v.UpdatedAt = store.Now()
|
|
_ = r.app.db.Update(func(s *store.State) error { upsertGrocery(s, v); return nil })
|
|
res := okTool("groceries_update", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "grocery", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) groceriesRemove(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var found *store.GroceryItem
|
|
for i := range st.Groceries {
|
|
if st.Groceries[i].ID == idv {
|
|
x := st.Groceries[i]
|
|
found = &x
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return failTool("groceries_remove", req, "NOT_FOUND", "Einkaufsartikel nicht gefunden")
|
|
}
|
|
_ = r.app.db.Update(func(s *store.State) error {
|
|
out := s.Groceries[:0]
|
|
for _, x := range s.Groceries {
|
|
if x.ID != idv {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
s.Groceries = append([]store.GroceryItem(nil), out...)
|
|
return nil
|
|
})
|
|
res := okTool("groceries_remove", req, map[string]any{"deleted": found})
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "grocery", ID: idv}
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) recipesList(req string, args map[string]any) toolResult {
|
|
limit := getInt(args, "limit", 50)
|
|
rs := append([]store.Recipe(nil), r.app.db.Snapshot().Recipes...)
|
|
sort.Slice(rs, func(i, j int) bool { return strings.ToLower(rs[i].Title) < strings.ToLower(rs[j].Title) })
|
|
if limit > 0 && len(rs) > limit {
|
|
rs = rs[:limit]
|
|
}
|
|
return okTool("recipes_list", req, rs)
|
|
}
|
|
|
|
func (r *toolRegistry) recipesSearch(req string, args map[string]any) toolResult {
|
|
q := getString(args, "query")
|
|
if q == "" {
|
|
return failTool("recipes_search", req, "INVALID_ARGUMENTS", "query fehlt")
|
|
}
|
|
limit := getInt(args, "limit", 10)
|
|
type scored struct {
|
|
R store.Recipe
|
|
S float64
|
|
}
|
|
ss := []scored{}
|
|
for _, x := range r.app.db.Snapshot().Recipes {
|
|
s := textSimilarity(q, x.Title+" "+strings.Join(x.Tags, " ")+" "+x.Description)
|
|
if s > .08 {
|
|
ss = append(ss, scored{x, s})
|
|
}
|
|
}
|
|
sort.Slice(ss, func(i, j int) bool { return ss[i].S > ss[j].S })
|
|
out := []map[string]any{}
|
|
for i, x := range ss {
|
|
if i >= limit {
|
|
break
|
|
}
|
|
out = append(out, map[string]any{"id": x.R.ID, "title": x.R.Title, "servings": x.R.Servings, "prepMinutes": x.R.PrepMinutes, "score": x.S})
|
|
}
|
|
return okTool("recipes_search", req, out)
|
|
}
|
|
func (r *toolRegistry) recipesGet(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
for _, x := range r.app.db.Snapshot().Recipes {
|
|
if x.ID == idv {
|
|
return okTool("recipes_get", req, x)
|
|
}
|
|
}
|
|
return failTool("recipes_get", req, "NOT_FOUND", "Rezept nicht gefunden")
|
|
}
|
|
func (r *toolRegistry) recipesCreate(req string, args map[string]any) toolResult {
|
|
var p struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Servings int `json:"servings"`
|
|
Prep int `json:"prep_minutes"`
|
|
Ingredients []store.Ingredient `json:"ingredients"`
|
|
Steps []string `json:"steps"`
|
|
}
|
|
if err := decodeArgs(args, &p); err != nil || strings.TrimSpace(p.Title) == "" {
|
|
return failTool("recipes_create", req, "INVALID_ARGUMENTS", "title fehlt")
|
|
}
|
|
if p.Servings <= 0 {
|
|
p.Servings = 2
|
|
}
|
|
v := store.Recipe{ID: id("recipe_tool"), Title: strings.TrimSpace(p.Title), Description: p.Description, Servings: p.Servings, PrepMinutes: p.Prep, Ingredients: p.Ingredients, Steps: p.Steps, Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
|
|
_ = r.app.db.Update(func(s *store.State) error { s.Recipes = append(s.Recipes, v); return nil })
|
|
res := okTool("recipes_create", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "recipe", ID: v.ID}
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) recipesUpdate(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
if idv == "" {
|
|
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "id fehlt")
|
|
}
|
|
st := r.app.db.Snapshot()
|
|
var cur *store.Recipe
|
|
for i := range st.Recipes {
|
|
if st.Recipes[i].ID == idv {
|
|
x := st.Recipes[i]
|
|
cur = &x
|
|
break
|
|
}
|
|
}
|
|
if cur == nil {
|
|
return failTool("recipes_update", req, "NOT_FOUND", "Rezept nicht gefunden")
|
|
}
|
|
v := *cur
|
|
if _, ok := args["title"]; ok {
|
|
if x := strings.TrimSpace(getString(args, "title")); x != "" {
|
|
v.Title = x
|
|
}
|
|
}
|
|
if _, ok := args["description"]; ok {
|
|
v.Description = getString(args, "description")
|
|
}
|
|
if _, ok := args["servings"]; ok {
|
|
if n := getInt(args, "servings", v.Servings); n > 0 {
|
|
v.Servings = n
|
|
}
|
|
}
|
|
if _, ok := args["prep_minutes"]; ok {
|
|
v.PrepMinutes = getInt(args, "prep_minutes", v.PrepMinutes)
|
|
}
|
|
if raw, ok := args["ingredients"]; ok {
|
|
b, _ := json.Marshal(raw)
|
|
var xs []store.Ingredient
|
|
if err := json.Unmarshal(b, &xs); err != nil {
|
|
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "ingredients ungültig")
|
|
}
|
|
v.Ingredients = xs
|
|
}
|
|
if raw, ok := args["steps"]; ok {
|
|
b, _ := json.Marshal(raw)
|
|
var xs []string
|
|
if err := json.Unmarshal(b, &xs); err != nil {
|
|
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "steps ungültig")
|
|
}
|
|
v.Steps = xs
|
|
}
|
|
if raw, ok := args["tags"]; ok {
|
|
b, _ := json.Marshal(raw)
|
|
var xs []string
|
|
if err := json.Unmarshal(b, &xs); err != nil {
|
|
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "tags ungültig")
|
|
}
|
|
v.Tags = xs
|
|
}
|
|
v.UpdatedAt = store.Now()
|
|
_ = r.app.db.Update(func(s *store.State) error { upsertRecipe(s, v); return nil })
|
|
res := okTool("recipes_update", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "recipe", ID: v.ID}
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) groceriesSyncMealplan(req string, args map[string]any) toolResult {
|
|
days := getInt(args, "days", 14)
|
|
if days < 1 {
|
|
days = 14
|
|
}
|
|
if days > 60 {
|
|
days = 60
|
|
}
|
|
changed := r.app.syncMealPlanGroceriesForDays(days)
|
|
st := r.app.db.Snapshot()
|
|
now := time.Now().In(r.app.timezone)
|
|
today := now.Format("2006-01-02")
|
|
end := now.AddDate(0, 0, days).Format("2006-01-02")
|
|
missing := []map[string]any{}
|
|
for _, m := range st.MealPlans {
|
|
if m.Date < today || m.Date > end || m.RecipeID != "" {
|
|
continue
|
|
}
|
|
missing = append(missing, map[string]any{"id": m.ID, "date": m.Date, "meal": m.Meal, "title": m.Title})
|
|
}
|
|
items := []store.GroceryItem{}
|
|
for _, g := range st.Groceries {
|
|
if g.AIManaged && strings.HasPrefix(g.Source, "mealplan:auto:") {
|
|
items = append(items, g)
|
|
}
|
|
}
|
|
res := okTool("groceries_sync_mealplan", req, map[string]any{"changed": changed, "days": days, "items": items, "missing_recipe_meals": missing})
|
|
res.Mutated = changed > 0
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) mealplanList(req string, args map[string]any) toolResult {
|
|
st := r.app.db.Snapshot()
|
|
meal := normalizeMeal(getString(args, "meal"))
|
|
limit := getInt(args, "limit", 20)
|
|
day := ""
|
|
if w, ok, err := getTemporal(args, "when"); err != nil {
|
|
return failTool("mealplan_list", req, "INVALID_ARGUMENTS", err.Error())
|
|
} else if ok {
|
|
t, _, e := r.resolveWhen(w)
|
|
if e != nil {
|
|
return failTool("mealplan_list", req, "INVALID_TIME", e.Error())
|
|
}
|
|
day = t.Format("2006-01-02")
|
|
}
|
|
out := []store.MealPlan{}
|
|
for _, m := range st.MealPlans {
|
|
if day != "" && m.Date != day {
|
|
continue
|
|
}
|
|
if meal != "" && normalizeMeal(m.Meal) != meal {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Date == out[j].Date {
|
|
return out[i].Meal < out[j].Meal
|
|
}
|
|
return out[i].Date < out[j].Date
|
|
})
|
|
return okTool("mealplan_list", req, out)
|
|
}
|
|
func (r *toolRegistry) mealplanCreate(req string, args map[string]any) toolResult {
|
|
title := getString(args, "title")
|
|
meal := normalizeMeal(getString(args, "meal"))
|
|
w, ok, err := getTemporal(args, "when")
|
|
if title == "" || meal == "" || err != nil || !ok {
|
|
return failTool("mealplan_create", req, "INVALID_ARGUMENTS", "title, when und meal erforderlich")
|
|
}
|
|
t, _, e := r.resolveWhen(w)
|
|
if e != nil {
|
|
return failTool("mealplan_create", req, "INVALID_TIME", e.Error())
|
|
}
|
|
rid := getString(args, "recipe_id")
|
|
st := r.app.db.Snapshot()
|
|
if rid == "" {
|
|
best := ""
|
|
score := 0.0
|
|
for _, rx := range st.Recipes {
|
|
if s := textSimilarity(title, rx.Title); s > score {
|
|
score = s
|
|
best = rx.ID
|
|
}
|
|
}
|
|
if score >= .34 {
|
|
rid = best
|
|
}
|
|
}
|
|
if rid != "" {
|
|
for _, rx := range st.Recipes {
|
|
if rx.ID == rid && textSimilarity(title, rx.Title) >= .20 {
|
|
title = rx.Title
|
|
break
|
|
}
|
|
}
|
|
}
|
|
serv := getInt(args, "servings", 2)
|
|
if serv <= 0 {
|
|
serv = 2
|
|
}
|
|
v := store.MealPlan{ID: id("meal_tool"), Date: t.Format("2006-01-02"), Meal: meal, RecipeID: rid, Title: title, Servings: serv, Notes: getString(args, "notes"), AIManaged: true, Source: "jarvis-tool", CreatedAt: store.Now(), UpdatedAt: store.Now()}
|
|
for _, existing := range st.MealPlans {
|
|
if existing.Date == v.Date && normalizeMeal(existing.Meal) == v.Meal {
|
|
v.ID = existing.ID
|
|
v.CreatedAt = existing.CreatedAt
|
|
_ = r.app.db.Update(func(s *store.State) error { upsertMealPlan(s, v); return nil })
|
|
res := okTool("mealplan_create", req, v)
|
|
res.Mutated = true
|
|
res.Warnings = []string{"SLOT_UPDATED"}
|
|
res.Entity = &toolEntity{Kind: "meal_plan", ID: v.ID}
|
|
return res
|
|
}
|
|
}
|
|
_ = r.app.db.Update(func(s *store.State) error { s.MealPlans = append(s.MealPlans, v); return nil })
|
|
res := okTool("mealplan_create", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "meal_plan", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) mealplanUpdate(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var cur *store.MealPlan
|
|
for i := range st.MealPlans {
|
|
if st.MealPlans[i].ID == idv {
|
|
x := st.MealPlans[i]
|
|
cur = &x
|
|
break
|
|
}
|
|
}
|
|
if cur == nil {
|
|
return failTool("mealplan_update", req, "NOT_FOUND", "Essensplan-Eintrag nicht gefunden")
|
|
}
|
|
v := *cur
|
|
if x := getString(args, "title"); x != "" {
|
|
v.Title = x
|
|
}
|
|
if x := normalizeMeal(getString(args, "meal")); x != "" {
|
|
v.Meal = x
|
|
}
|
|
if x := getString(args, "recipe_id"); x != "" {
|
|
v.RecipeID = x
|
|
}
|
|
if n := getInt(args, "servings", 0); n > 0 {
|
|
v.Servings = n
|
|
}
|
|
if x := getString(args, "notes"); x != "" {
|
|
v.Notes = x
|
|
}
|
|
if w, ok, _ := getTemporal(args, "when"); ok {
|
|
t, _, e := r.resolveWhen(w)
|
|
if e != nil {
|
|
return failTool("mealplan_update", req, "INVALID_TIME", e.Error())
|
|
}
|
|
v.Date = t.Format("2006-01-02")
|
|
}
|
|
v.UpdatedAt = store.Now()
|
|
_ = r.app.db.Update(func(s *store.State) error { upsertMealPlan(s, v); return nil })
|
|
res := okTool("mealplan_update", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "meal_plan", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) mealplanDelete(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var found *store.MealPlan
|
|
for i := range st.MealPlans {
|
|
if st.MealPlans[i].ID == idv {
|
|
x := st.MealPlans[i]
|
|
found = &x
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return failTool("mealplan_delete", req, "NOT_FOUND", "Essensplan-Eintrag nicht gefunden")
|
|
}
|
|
_ = r.app.db.Update(func(s *store.State) error {
|
|
out := s.MealPlans[:0]
|
|
for _, x := range s.MealPlans {
|
|
if x.ID != idv {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
s.MealPlans = append([]store.MealPlan(nil), out...)
|
|
return nil
|
|
})
|
|
res := okTool("mealplan_delete", req, map[string]any{"deleted": found})
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "meal_plan", ID: idv}
|
|
return res
|
|
}
|
|
|
|
func toolNormalizeColumn(v string) string {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "inbox", "next", "doing", "waiting", "done":
|
|
return strings.ToLower(strings.TrimSpace(v))
|
|
}
|
|
return ""
|
|
}
|
|
func (r *toolRegistry) kanbanList(req string, args map[string]any) toolResult {
|
|
col := getString(args, "column")
|
|
out := []store.KanbanCard{}
|
|
for _, x := range r.app.db.Snapshot().Kanban {
|
|
if col != "" && col != "all" && x.Column != col {
|
|
continue
|
|
}
|
|
out = append(out, x)
|
|
}
|
|
return okTool("kanban_list", req, out)
|
|
}
|
|
func (r *toolRegistry) kanbanCreate(req string, args map[string]any) toolResult {
|
|
title := getString(args, "title")
|
|
if title == "" {
|
|
return failTool("kanban_create", req, "INVALID_ARGUMENTS", "title fehlt")
|
|
}
|
|
col := toolNormalizeColumn(getString(args, "column"))
|
|
if col == "" {
|
|
col = "inbox"
|
|
}
|
|
v := store.KanbanCard{ID: id("kanban_tool"), Title: title, Notes: getString(args, "notes"), Column: col, Priority: normalizePriority(getString(args, "priority")), Source: "jarvis-tool", AIManaged: false, CreatedAt: store.Now(), UpdatedAt: store.Now()}
|
|
_ = r.app.db.Update(func(s *store.State) error { s.Kanban = append(s.Kanban, v); return nil })
|
|
res := okTool("kanban_create", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "kanban", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) kanbanMove(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
col := toolNormalizeColumn(getString(args, "column"))
|
|
if col == "" {
|
|
return failTool("kanban_move", req, "INVALID_ARGUMENTS", "column ungültig")
|
|
}
|
|
st := r.app.db.Snapshot()
|
|
var cur *store.KanbanCard
|
|
for i := range st.Kanban {
|
|
if st.Kanban[i].ID == idv {
|
|
x := st.Kanban[i]
|
|
cur = &x
|
|
break
|
|
}
|
|
}
|
|
if cur == nil {
|
|
return failTool("kanban_move", req, "NOT_FOUND", "Kanban-Karte nicht gefunden")
|
|
}
|
|
v := *cur
|
|
v.Column = col
|
|
v.AIManaged = false
|
|
v.Source = "manual-override"
|
|
v.UpdatedAt = store.Now()
|
|
_ = r.app.db.Update(func(s *store.State) error { upsertKanban(s, v); return nil })
|
|
res := okTool("kanban_move", req, v)
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "kanban", ID: v.ID}
|
|
return res
|
|
}
|
|
func (r *toolRegistry) kanbanDelete(req string, args map[string]any) toolResult {
|
|
idv := getString(args, "id")
|
|
st := r.app.db.Snapshot()
|
|
var found *store.KanbanCard
|
|
for i := range st.Kanban {
|
|
if st.Kanban[i].ID == idv {
|
|
x := st.Kanban[i]
|
|
found = &x
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
return failTool("kanban_delete", req, "NOT_FOUND", "Kanban-Karte nicht gefunden")
|
|
}
|
|
_ = r.app.db.Update(func(s *store.State) error {
|
|
out := s.Kanban[:0]
|
|
for _, x := range s.Kanban {
|
|
if x.ID != idv {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
s.Kanban = append([]store.KanbanCard(nil), out...)
|
|
return nil
|
|
})
|
|
res := okTool("kanban_delete", req, map[string]any{"deleted": found})
|
|
res.Mutated = true
|
|
res.Entity = &toolEntity{Kind: "kanban", ID: idv}
|
|
return res
|
|
}
|
|
|
|
func (r *toolRegistry) ragSearch(ctx context.Context, req string, args map[string]any) toolResult {
|
|
q := getString(args, "query")
|
|
if q == "" {
|
|
return failTool("rag_search", req, "INVALID_ARGUMENTS", "query fehlt")
|
|
}
|
|
limit := getInt(args, "limit", 5)
|
|
vecs, err := r.app.ai.Embed(ctx, []string{q})
|
|
if err != nil || len(vecs) == 0 {
|
|
if err == nil {
|
|
err = fmt.Errorf("kein Embedding erzeugt")
|
|
}
|
|
return failTool("rag_search", req, "RAG_UNAVAILABLE", err.Error())
|
|
}
|
|
hits := r.app.rag.Search(vecs[0], limit)
|
|
return okTool("rag_search", req, hits)
|
|
}
|