748 lines
29 KiB
Go
748 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"homehub/internal/ollama"
|
|
"homehub/internal/store"
|
|
)
|
|
|
|
type agentOutcome struct {
|
|
Reply string
|
|
Results []toolResult
|
|
Automations []automationResult
|
|
Mode string
|
|
Plan *agentPlan
|
|
}
|
|
|
|
type selectorOutput struct {
|
|
Tool string `json:"tool"`
|
|
Name string `json:"name,omitempty"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
Confidence float64 `json:"confidence"`
|
|
}
|
|
|
|
type agentPlanStep struct {
|
|
ID string `json:"id"`
|
|
Parent string `json:"parent,omitempty"`
|
|
Task string `json:"task"`
|
|
Tool string `json:"tool"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
|
|
type agentPlan struct {
|
|
Goal string `json:"goal"`
|
|
Steps []agentPlanStep `json:"steps"`
|
|
Confidence float64 `json:"confidence"`
|
|
Clarification string `json:"clarification,omitempty"`
|
|
}
|
|
|
|
func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error) {
|
|
registry := a.skills
|
|
if registry == nil {
|
|
return agentOutcome{}, fmt.Errorf("Tool-Registry nicht initialisiert")
|
|
}
|
|
if pendingOutcome, ok := a.resolvePendingToolAction(ctx, msg); ok {
|
|
return pendingOutcome, nil
|
|
}
|
|
st := a.db.Snapshot()
|
|
history := conversationHistory(st.Chat, msg, a.context.HistoryTokens)
|
|
messages := make([]ollama.Turn, 0, len(history)+12)
|
|
messages = append(messages, ollama.Turn{Role: "system", Content: a.agentSystemPrompt()})
|
|
messages = append(messages, history...)
|
|
if session := a.agentSessionHint(); session != "" {
|
|
messages = append(messages, ollama.Turn{Role: "system", Content: session})
|
|
}
|
|
messages = append(messages, ollama.Turn{Role: "user", Content: msg})
|
|
|
|
a.trace(ctx, "agent", "request", "input", map[string]any{
|
|
"message": msg, "model": a.ai.Status().Model, "history": history,
|
|
"tool_count": len(registry.Definitions()), "tools": toolNames(registry.Definitions()),
|
|
"options": a.context.agentOptions(), "session": a.agentSessionHint(),
|
|
})
|
|
|
|
out := agentOutcome{Mode: "tool-agent", Results: []toolResult{}, Automations: []automationResult{}}
|
|
mutated := false
|
|
writeFailed := false
|
|
usedPlanner := false
|
|
for step := 0; step < a.context.AgentMaxSteps; step++ {
|
|
started := time.Now()
|
|
turn, err := a.ai.ChatWithTools(ctx, messages, registry.Definitions(), a.context.agentOptions())
|
|
if err != nil {
|
|
a.traceErr(ctx, "agent", "model_response", map[string]any{"step": step + 1}, err, started)
|
|
return out, err
|
|
}
|
|
a.traceTimed(ctx, "agent", "model_response", "output", map[string]any{"step": step + 1, "content": turn.Content, "tool_calls": turn.ToolCalls}, started)
|
|
if len(turn.ToolCalls) > 0 {
|
|
turn.ToolCalls = a.applyRecipeFirstCalls(ctx, msg, turn.ToolCalls)
|
|
}
|
|
|
|
if len(turn.ToolCalls) == 0 {
|
|
// Local models sometimes describe what they would do instead of emitting
|
|
// native tool_calls. In that case we ask a schema-constrained workflow
|
|
// planner for one OR MANY validated tool steps. This is intentionally a
|
|
// plan, not a free-form action parser: every step still executes through
|
|
// the same registry and domain validators.
|
|
if len(out.Results) == 0 && a.requiresHomeTool(msg) && !usedPlanner {
|
|
usedPlanner = true
|
|
plan, calls, ok, planErr := a.selectToolPlanJSON(ctx, msg, history)
|
|
if planErr != nil {
|
|
a.traceErr(ctx, "agent", "workflow_plan", nil, planErr, time.Now())
|
|
}
|
|
if ok && len(calls) > 0 {
|
|
out.Plan = &plan
|
|
out.Mode = "tool-workflow"
|
|
turn = ollama.Turn{Role: "assistant", Content: "", ToolCalls: calls}
|
|
a.trace(ctx, "agent", "workflow_plan", "output", map[string]any{"plan": plan, "tool_calls": calls})
|
|
} else {
|
|
reply := "Ich konnte daraus noch keinen sicheren ausführbaren Plan bilden. Bitte nenne die fehlende Einschränkung etwas genauer."
|
|
if plan.Clarification != "" {
|
|
reply = plan.Clarification
|
|
} else if !looksActionable(msg) {
|
|
reply = "Ich konnte die Home-Anfrage nicht sicher einem Tool-Workflow zuordnen. Bitte nenne den Bereich etwas genauer, z. B. Kalender, Aufgaben, Essensplan oder Einkauf."
|
|
}
|
|
out.Reply = reply
|
|
out.Mode = "clarification"
|
|
return out, nil
|
|
}
|
|
} else {
|
|
content := strings.TrimSpace(turn.Content)
|
|
if mutated {
|
|
// Mutation confirmations are generated from persisted tool results, never
|
|
// from free-form model prose.
|
|
out.Reply = a.toolCommitReply(out.Results)
|
|
} else if writeFailed {
|
|
// A failed write can never be turned into a success claim by model prose.
|
|
out.Reply = a.toolFailureReply(out.Results)
|
|
out.Mode = "tool-error"
|
|
} else if content != "" {
|
|
out.Reply = content
|
|
if a.capturePendingToolConfirmation(ctx, content, out.Results) {
|
|
out.Mode = "clarification"
|
|
}
|
|
} else if len(out.Results) > 0 {
|
|
out.Reply = a.toolReadReply(out.Results)
|
|
} else {
|
|
out.Reply = "Ich habe dazu keine belastbare Antwort erhalten."
|
|
}
|
|
return out, nil
|
|
}
|
|
}
|
|
|
|
messages = append(messages, turn)
|
|
for _, call := range turn.ToolCalls {
|
|
name := strings.TrimSpace(call.Function.Name)
|
|
args := call.Function.Arguments
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
if a.skills.RequiresConfirmation(name) {
|
|
label := a.skills.ActionLabel(name)
|
|
a.setPendingToolAction(name, args, label)
|
|
a.trace(ctx, "skills", "confirmation_pending", "output", map[string]any{"tool": name, "arguments": args, "label": label})
|
|
out.Reply = fmt.Sprintf("Soll ich den Skill „%s“ mit diesen Parametern ausführen?", label)
|
|
out.Mode = "skill-confirmation"
|
|
return out, nil
|
|
}
|
|
a.trace(ctx, "agent", "tool_call", "input", map[string]any{"step": step + 1, "tool": name, "arguments": args})
|
|
toolStarted := time.Now()
|
|
res := registry.Execute(ctx, name, args)
|
|
a.traceTimed(ctx, "agent", "tool_result", "output", res, toolStarted)
|
|
out.Results = append(out.Results, res)
|
|
if !res.Success && a.skills.IsMutation(name) {
|
|
writeFailed = true
|
|
}
|
|
if res.Mutated && res.Success {
|
|
mutated = true
|
|
out.Automations = append(out.Automations, automationResult{Type: name, ID: entityID(res), Message: toolResultMessage(res)})
|
|
a.rememberToolEntity(res)
|
|
// Domain consequences (event -> reminder, meal plan -> groceries,
|
|
// task -> kanban) happen synchronously so a following tool can read them.
|
|
a.runHomeAutomations(traceBackground(ctx), "tool:"+name)
|
|
}
|
|
payload, _ := json.Marshal(res)
|
|
messages = append(messages, ollama.Turn{Role: "tool", Content: string(payload)})
|
|
}
|
|
}
|
|
|
|
if mutated {
|
|
out.Reply = a.toolCommitReply(out.Results)
|
|
return out, nil
|
|
}
|
|
out.Reply = "Die Anfrage hat zu viele Tool-Schritte benötigt. Ich habe keine weitere Änderung ausgeführt."
|
|
out.Mode = "tool-limit"
|
|
return out, nil
|
|
}
|
|
|
|
func (a *app) agentSystemPrompt() string {
|
|
return `Du bist JARVIS, der lokale Agent eines Home-Dashboards. Deine Fähigkeiten werden dynamisch als Skills bereitgestellt. Jeder Skill erscheint technisch als Function/Tool mit festem JSON-Schema. Core-Skills verwalten Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepte, Essensplan, Kanban und Dokumentwissen; Plugin-Skills können zusätzliche Fähigkeiten bereitstellen.
|
|
|
|
REGELN:
|
|
1. Für JEDE Aussage über den aktuellen Home-State zuerst einen passenden Lese-Skill benutzen. Home-State niemals aus Gesprächserinnerung erfinden.
|
|
2. Für JEDE gewünschte Änderung einen mutierenden Skill benutzen. Behaupte niemals eine Änderung ohne success=true aus einem Tool-Resultat.
|
|
3. Bei update/delete und unklarer ID zuerst das passende list/search Tool verwenden, danach die ID aus dessen Ergebnis.
|
|
4. Relative Zeiten NICHT selbst in Kalenderdaten umrechnen. Übergib sie symbolisch im when-Objekt:
|
|
- heute => {"kind":"relative","offset_days":0}
|
|
- morgen => {"kind":"relative","offset_days":1}
|
|
- übermorgen => {"kind":"relative","offset_days":2}
|
|
- Montag => {"kind":"weekday","weekday":"monday"}
|
|
- nächsten/kommenden Montag => {"kind":"weekday","weekday":"monday","force_next":true}
|
|
- konkretes Datum => {"kind":"absolute","date":"YYYY-MM-DD"}
|
|
Eine genannte Uhrzeit kommt zusätzlich als "time":"HH:MM". Wenn keine Uhrzeit genannt wurde, erfinde keine.
|
|
5. Termin/Meeting/Arzt/Zahnarzt gehört in calendar_*, nicht tasks_*.
|
|
6. "Erinnere mich" gehört in reminders_*.
|
|
7. Mahlzeiten: Frühstück=breakfast, Mittag= lunch, Abendessen=dinner, Snack=snack.
|
|
8. Für generierte Essenspläne gilt RECIPE-FIRST: Lies zuerst mit recipes_list das Rezeptbuch. Plane bevorzugt und standardmäßig vorhandene Rezepte und verwende deren recipe_id in mealplan_create. Bei zu wenigen Rezepten darfst du vorhandene Rezepte wiederholen. Freie erfundene Gerichtstitel nur, wenn das Rezeptbuch leer ist oder der Nutzer ausdrücklich ein nicht vorhandenes Gericht verlangt.
|
|
9. Wenn ein konkretes bekanntes Rezept gemeint ist, nutze recipes_search/recipes_get und verwende dessen recipe ID. Zum Bearbeiten eines Rezepts nutze recipes_update; bei unbekannter ID zuerst recipes_search oder recipes_list.
|
|
10. Für "was muss ich für den Essensplan einkaufen?", "Einkaufsliste aus Essensplan" o. ä. nutze groceries_sync_mealplan. Dieses Tool aggregiert ausschließlich gepflegte Rezeptzutaten; erfinde keine Zutaten. Danach kannst du groceries_list verwenden, wenn nötig.
|
|
11. Wenn ein Skill einen Fehler oder mehrere Kandidaten liefert, korrigiere die Arguments oder frage den Nutzer knapp nach der fehlenden Information. Keine freie Vermutung.
|
|
12. RAG/Dokumentwissen ausschließlich über rag_search. Quellen nur nennen, wenn sie im Tool-Ergebnis vorkommen.
|
|
13. Eine Nutzeranfrage kann MEHRERE Aufgaben enthalten. Zerlege komplexe Ziele selbstständig in Unteraufgaben und führe mehrere Tool-Aufrufe aus, wenn das Ziel dies erfordert. Beispiel: "Essensplan für die ganze Woche, nur Abendessen" bedeutet mehrere mealplan_create-Aufrufe, typischerweise einen pro Tag.
|
|
14. Wenn der Nutzer ausdrücklich "plane", "erstelle", "generiere", "mach mir" o. ä. sagt und Inhalte nicht einzeln vorgibt, ist die Auswahl sinnvoller Vorschläge an dich delegiert. Frage NICHT nach jedem einzelnen Inhalt. Erfinde jedoch keine harten persönlichen Fakten wie Allergien oder Personenzahl.
|
|
15. "ganze Woche" ohne weitere Präzisierung bedeutet für Planungsaufgaben die nächsten 7 Kalendertage ab heute. Verwende dafür symbolische relative Zeiten offset_days 0..6. "nächste Woche" bedeutet Montag bis Sonntag der kommenden Kalenderwoche; dafür darfst du system_get_time verwenden und anschließend Wochentage symbolisch planen.
|
|
16. Skills dürfen miteinander kombiniert werden. Nutze so wenige Skill-Aufrufe wie nötig, aber so viele wie fachlich erforderlich. Nach erfolgreicher Ausführung antworte kurz auf Deutsch. Die tatsächliche Commit-Bestätigung kann vom Server deterministisch überschrieben werden.`
|
|
}
|
|
|
|
func (a *app) agentSessionHint() string {
|
|
a.recentMu.Lock()
|
|
defer a.recentMu.Unlock()
|
|
if a.recent == nil || time.Since(a.recent.At) > 15*time.Minute {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("SESSION-REFERENZ: Das zuletzt erfolgreich geänderte Objekt war kind=%s id=%s. Bei Bezügen wie 'das', 'es', 'den Termin', 'stattdessen' oder einer direkten Korrektur darfst du diese ID verwenden, sofern der Bereich passt.", a.recent.Kind, a.recent.ID)
|
|
}
|
|
|
|
func (a *app) rememberToolEntity(res toolResult) {
|
|
if res.Entity == nil || res.Entity.ID == "" || res.Entity.Kind == "" {
|
|
return
|
|
}
|
|
a.recentMu.Lock()
|
|
a.recent = &recentMutation{Kind: res.Entity.Kind, ID: res.Entity.ID, At: time.Now()}
|
|
a.recentMu.Unlock()
|
|
}
|
|
|
|
func entityID(res toolResult) string {
|
|
if res.Entity != nil {
|
|
return res.Entity.ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func toolResultMessage(res toolResult) string {
|
|
if strings.TrimSpace(res.Message) != "" {
|
|
return strings.TrimSpace(res.Message)
|
|
}
|
|
if !res.Success {
|
|
if res.Error != nil {
|
|
return res.Error.Message
|
|
}
|
|
return "Tool fehlgeschlagen"
|
|
}
|
|
if res.Mutated {
|
|
return "Home-State über " + res.Tool + " geändert"
|
|
}
|
|
return "Tool " + res.Tool + " ausgeführt"
|
|
}
|
|
|
|
func toolNames(tools []ollama.Tool) []string {
|
|
out := make([]string, 0, len(tools))
|
|
for _, t := range tools {
|
|
out = append(out, t.Function.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *app) requiresHomeTool(msg string) bool {
|
|
x := foldCommand(stripModuleContext(msg))
|
|
for _, k := range []string{
|
|
"kalender", "termin", "meeting", "zahnarzt", "arzt", "aufgabe", "todo", "to-do", "erinner", "einkauf", "einkaufsliste",
|
|
"rezept", "essensplan", "abendessen", "mittagessen", "fruehstueck", "frühstück", "zu mittag", "zum mittag", "zu abend", "zum abend",
|
|
"kanban", "dokument", "datei", "was gibt es", "was gibts", "wann gibt es", "verschieb", "loesch", "entfern", "trag", "fuege", "plane", "erstelle",
|
|
} {
|
|
if strings.Contains(x, foldCommand(k)) {
|
|
return true
|
|
}
|
|
}
|
|
if a.skills != nil && a.skills.MayApply(msg) {
|
|
return true
|
|
}
|
|
// A terse correction immediately after a mutation belongs to the tool session.
|
|
a.recentMu.Lock()
|
|
recent := a.recent != nil && time.Since(a.recent.At) <= 15*time.Minute
|
|
a.recentMu.Unlock()
|
|
return recent && (isTerseControlResponse(msg) || strings.Contains(x, "stattdessen") || strings.Contains(x, "doch ") || strings.HasPrefix(x, "nein"))
|
|
}
|
|
|
|
func (a *app) selectToolPlanJSON(ctx context.Context, msg string, history []ollama.Turn) (agentPlan, []ollama.ToolCall, bool, error) {
|
|
defs := a.skills.Definitions()
|
|
catalog, _ := json.Marshal(defs)
|
|
st := a.db.Snapshot()
|
|
planningContext := a.compactPlanningContext(st)
|
|
system := `Du bist der JSON-Workflow-Planner von JARVIS. Zerlege die Nutzeranfrage in 1 bis 24 konkrete ausführbare Unteraufgaben. Jede ausführbare Unteraufgabe muss exakt eine registrierte Skill-Aktion verwenden. Die Skill-Aktionen erscheinen im Katalog technisch als Function/Tool-Namen.
|
|
|
|
Antworte ausschließlich als JSON:
|
|
{"goal":"kurzes Ziel","steps":[{"id":"s1","parent":"optional","task":"kurze Beschreibung","tool":"exakter_tool_name","arguments":{...}}],"confidence":0.0,"clarification":"optional"}
|
|
|
|
REGELN:
|
|
- Mehrere unabhängige Aktionen als mehrere steps ausgeben. NICHT künstlich auf ein Tool reduzieren.
|
|
- Komplexe Planungswünsche selbst in Unteraufgaben zerlegen. Bei "Essensplan für die ganze Woche, nur Abendessen" typischerweise 7 mealplan_create-Schritte mit meal="dinner" und when.relative offset_days 0..6.
|
|
- Wenn der Nutzer mit "plane/erstelle/generiere/mach mir" die kreative Auswahl delegiert, wähle vernünftige abwechslungsreiche Inhalte selbst. Nicht unnötig nach einzelnen Vorschlägen fragen.
|
|
- RECIPE-FIRST: Bei generierten Wochen-/Mehrfach-Essensplänen MUSS jedes mealplan_create nach Möglichkeit ein vorhandenes Rezept aus PLANUNGS-KONTEXT über recipe_id verwenden. Wenn weniger Rezepte als Slots existieren, vorhandene Rezepte wiederholen. Erfinde keine freien Gerichte, solange mindestens ein Rezept vorhanden ist.
|
|
- Ist das Rezeptbuch komplett leer und der Nutzer delegiert kreativ einen Essensplan, darfst du zuerst recipes_create-Schritte mit vollständigen Zutaten/Schritten planen und danach passende mealplan_create-Schritte. Die späteren mealplan_create-Schritte dürfen denselben Rezepttitel ohne recipe_id verwenden; der Server verknüpft ihn mit dem zuvor erzeugten Rezept.
|
|
- Wenn der Nutzer nach einer Einkaufsliste für den Essensplan fragt, plane groceries_sync_mealplan; Zutaten werden niemals frei erfunden.
|
|
- Rezeptänderungen laufen über recipes_update; bei unbekannter ID zuerst recipes_search/list.
|
|
- Relative Zeiten bleiben symbolisch im when-Schema; keine Datumsarithmetik.
|
|
- Update/Delete mit unbekannter ID zuerst als list/search-Schritt planen. Wenn ein Folgeschritt zwingend eine erst zur Laufzeit unbekannte ID benötigt, plane nur den Lese-Schritt; der native Agent kann danach fortsetzen.
|
|
- Keine Tools außerhalb des Katalogs. Maximal 24 steps.
|
|
- Nur bei wirklich fehlenden harten Angaben, die nicht sinnvoll delegiert wurden, clarification setzen und steps leer lassen.`
|
|
prompt := fmt.Sprintf("SKILL-AKTIONS-KATALOG:\n%s\n\nPLANUNGS-KONTEXT:\n%s\n\nSESSION:\n%s\n\nNUTZER:\n%s", string(catalog), planningContext, a.agentSessionHint(), msg)
|
|
a.trace(ctx, "agent", "workflow_plan_request", "input", map[string]any{"message": msg, "catalog_tools": toolNames(defs), "history": history, "planning_context": planningContext, "prompt_estimated_tokens": estimateTokens(prompt)})
|
|
started := time.Now()
|
|
raw, err := a.ai.ChatWithHistory(ctx, system, history, prompt, true)
|
|
if err != nil {
|
|
return agentPlan{}, nil, false, err
|
|
}
|
|
a.traceTimed(ctx, "agent", "workflow_plan_response", "output", map[string]any{"raw": raw}, started)
|
|
plan, err := parseAgentPlan(raw)
|
|
if err != nil {
|
|
return agentPlan{}, nil, false, err
|
|
}
|
|
if plan.Confidence > 0 && plan.Confidence < .60 {
|
|
return plan, nil, false, nil
|
|
}
|
|
a.applyRecipeFirstPlan(ctx, msg, &plan)
|
|
if len(plan.Steps) == 0 {
|
|
return plan, nil, false, nil
|
|
}
|
|
if len(plan.Steps) > 24 {
|
|
plan.Steps = plan.Steps[:24]
|
|
}
|
|
calls := make([]ollama.ToolCall, 0, len(plan.Steps))
|
|
validSteps := make([]agentPlanStep, 0, len(plan.Steps))
|
|
for i, step := range plan.Steps {
|
|
step.Tool = strings.TrimSpace(step.Tool)
|
|
if step.ID == "" {
|
|
step.ID = fmt.Sprintf("s%d", i+1)
|
|
}
|
|
if step.Task == "" {
|
|
step.Task = step.Tool
|
|
}
|
|
if step.Arguments == nil {
|
|
step.Arguments = map[string]any{}
|
|
}
|
|
if step.Tool == "" || !a.skills.Has(step.Tool) {
|
|
continue
|
|
}
|
|
validSteps = append(validSteps, step)
|
|
calls = append(calls, ollama.ToolCall{Function: ollama.ToolFunctionCall{Name: step.Tool, Arguments: step.Arguments}})
|
|
}
|
|
plan.Steps = validSteps
|
|
if len(calls) == 0 {
|
|
return plan, nil, false, nil
|
|
}
|
|
return plan, calls, true, nil
|
|
}
|
|
|
|
func parseAgentPlan(raw string) (agentPlan, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return agentPlan{}, fmt.Errorf("leerer Workflow-Plan")
|
|
}
|
|
if i := strings.Index(raw, "```json"); i >= 0 {
|
|
raw = raw[i+7:]
|
|
if j := strings.Index(raw, "```"); j >= 0 {
|
|
raw = raw[:j]
|
|
}
|
|
} else if i := strings.Index(raw, "```"); i >= 0 {
|
|
raw = raw[i+3:]
|
|
if j := strings.Index(raw, "```"); j >= 0 {
|
|
raw = raw[:j]
|
|
}
|
|
}
|
|
raw = strings.TrimSpace(raw)
|
|
var plan agentPlan
|
|
if err := json.Unmarshal([]byte(raw), &plan); err == nil && (len(plan.Steps) > 0 || plan.Clarification != "") {
|
|
return plan, nil
|
|
}
|
|
// Backward-compatible single-tool selector response.
|
|
var one selectorOutput
|
|
if err := json.Unmarshal([]byte(raw), &one); err == nil {
|
|
if one.Tool == "" {
|
|
one.Tool = one.Name
|
|
}
|
|
if strings.TrimSpace(one.Tool) != "" {
|
|
if one.Arguments == nil {
|
|
one.Arguments = map[string]any{}
|
|
}
|
|
return agentPlan{Goal: one.Tool, Confidence: one.Confidence, Steps: []agentPlanStep{{ID: "s1", Task: one.Tool, Tool: one.Tool, Arguments: one.Arguments}}}, nil
|
|
}
|
|
}
|
|
// Tolerate common model shape {"tasks":[...]} / {"subtasks":[...]}.
|
|
var loose map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &loose); err != nil {
|
|
return agentPlan{}, err
|
|
}
|
|
for _, key := range []string{"tasks", "subtasks", "actions"} {
|
|
arr, ok := loose[key].([]any)
|
|
if !ok || len(arr) == 0 {
|
|
continue
|
|
}
|
|
plan.Goal, _ = loose["goal"].(string)
|
|
if c, ok := loose["confidence"].(float64); ok {
|
|
plan.Confidence = c
|
|
}
|
|
for i, item := range arr {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
tool, _ := m["tool"].(string)
|
|
if tool == "" {
|
|
tool, _ = m["name"].(string)
|
|
}
|
|
args, _ := m["arguments"].(map[string]any)
|
|
task, _ := m["task"].(string)
|
|
if task == "" {
|
|
task, _ = m["title"].(string)
|
|
}
|
|
plan.Steps = append(plan.Steps, agentPlanStep{ID: fmt.Sprintf("s%d", i+1), Task: task, Tool: tool, Arguments: args})
|
|
}
|
|
if len(plan.Steps) > 0 {
|
|
return plan, nil
|
|
}
|
|
}
|
|
return agentPlan{}, fmt.Errorf("kein ausführbarer Workflow-Plan")
|
|
}
|
|
|
|
func (a *app) compactPlanningContext(st store.State) string {
|
|
var b strings.Builder
|
|
b.WriteString("AKTUELLE ZEIT: ")
|
|
b.WriteString(time.Now().In(a.timezone).Format(time.RFC3339))
|
|
b.WriteString("\nVORHANDENE REZEPTE:\n")
|
|
for i, r := range st.Recipes {
|
|
if i >= 20 {
|
|
break
|
|
}
|
|
ingNames := make([]string, 0, len(r.Ingredients))
|
|
for j, ing := range r.Ingredients {
|
|
if j >= 5 {
|
|
break
|
|
}
|
|
if strings.TrimSpace(ing.Name) != "" {
|
|
ingNames = append(ingNames, ing.Name)
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "- id=%s | %s | %d Portionen | Zutaten: %s\n", r.ID, r.Title, r.Servings, strings.Join(ingNames, ", "))
|
|
}
|
|
b.WriteString("ESSENSPLAN (NÄCHSTE EINTRÄGE):\n")
|
|
for i, m := range st.MealPlans {
|
|
if i >= 20 {
|
|
break
|
|
}
|
|
fmt.Fprintf(&b, "- id=%s | %s | %s | %s\n", m.ID, m.Date, m.Meal, m.Title)
|
|
}
|
|
b.WriteString("OFFENE AUFGABEN:\n")
|
|
count := 0
|
|
for _, t := range st.Tasks {
|
|
if t.Status == "done" {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "- id=%s | %s | %s\n", t.ID, t.Title, t.Priority)
|
|
count++
|
|
if count >= 15 {
|
|
break
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isDelegatedWeeklyMealPlan(msg string) bool {
|
|
x := foldCommand(stripModuleContext(msg))
|
|
if !strings.Contains(x, "essensplan") && !strings.Contains(x, "wochenplan") {
|
|
return false
|
|
}
|
|
return strings.Contains(x, "woche") || strings.Contains(x, "7 tage") || strings.Contains(x, "sieben tage")
|
|
}
|
|
|
|
func (a *app) recipeForPlanTitle(title string, recipes []store.Recipe, fallbackIndex int) (store.Recipe, bool) {
|
|
if len(recipes) == 0 {
|
|
return store.Recipe{}, false
|
|
}
|
|
best, bestScore := -1, 0.0
|
|
for i, r := range recipes {
|
|
if sc := textSimilarity(title, r.Title); sc > bestScore {
|
|
best, bestScore = i, sc
|
|
}
|
|
}
|
|
if best >= 0 && bestScore >= .34 {
|
|
return recipes[best], true
|
|
}
|
|
return recipes[fallbackIndex%len(recipes)], true
|
|
}
|
|
|
|
func (a *app) applyRecipeFirstPlan(ctx context.Context, msg string, plan *agentPlan) {
|
|
if plan == nil || !isDelegatedWeeklyMealPlan(msg) {
|
|
return
|
|
}
|
|
recipes := a.db.Snapshot().Recipes
|
|
if len(recipes) == 0 {
|
|
return
|
|
}
|
|
before, _ := json.Marshal(plan.Steps)
|
|
filtered := make([]agentPlanStep, 0, len(plan.Steps))
|
|
mealIndex := 0
|
|
for _, step := range plan.Steps {
|
|
// When a recipe book exists, a delegated week plan must use it instead of
|
|
// inventing parallel recipes that have no relation to the user's library.
|
|
if step.Tool == "recipes_create" {
|
|
continue
|
|
}
|
|
if step.Tool == "mealplan_create" {
|
|
if step.Arguments == nil {
|
|
step.Arguments = map[string]any{}
|
|
}
|
|
rid := getString(step.Arguments, "recipe_id")
|
|
var rec store.Recipe
|
|
found := false
|
|
if rid != "" {
|
|
for _, r := range recipes {
|
|
if r.ID == rid {
|
|
rec, found = r, true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
rec, found = a.recipeForPlanTitle(getString(step.Arguments, "title"), recipes, mealIndex)
|
|
}
|
|
if found {
|
|
step.Arguments["recipe_id"] = rec.ID
|
|
step.Arguments["title"] = rec.Title
|
|
}
|
|
mealIndex++
|
|
}
|
|
filtered = append(filtered, step)
|
|
}
|
|
plan.Steps = filtered
|
|
after, _ := json.Marshal(plan.Steps)
|
|
if string(before) != string(after) {
|
|
a.trace(ctx, "agent", "recipe_first_plan", "output", map[string]any{"message": msg, "before": json.RawMessage(before), "after": json.RawMessage(after), "recipes": len(recipes)})
|
|
}
|
|
}
|
|
|
|
func (a *app) applyRecipeFirstCalls(ctx context.Context, msg string, calls []ollama.ToolCall) []ollama.ToolCall {
|
|
if !isDelegatedWeeklyMealPlan(msg) {
|
|
return calls
|
|
}
|
|
recipes := a.db.Snapshot().Recipes
|
|
if len(recipes) == 0 {
|
|
return calls
|
|
}
|
|
out := make([]ollama.ToolCall, 0, len(calls))
|
|
mealIndex := 0
|
|
changed := false
|
|
for _, call := range calls {
|
|
if call.Function.Name == "recipes_create" {
|
|
changed = true
|
|
continue
|
|
}
|
|
if call.Function.Name == "mealplan_create" {
|
|
args := call.Function.Arguments
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
rid := getString(args, "recipe_id")
|
|
var rec store.Recipe
|
|
found := false
|
|
if rid != "" {
|
|
for _, r := range recipes {
|
|
if r.ID == rid {
|
|
rec, found = r, true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
rec, found = a.recipeForPlanTitle(getString(args, "title"), recipes, mealIndex)
|
|
}
|
|
if found {
|
|
if getString(args, "recipe_id") != rec.ID || getString(args, "title") != rec.Title {
|
|
changed = true
|
|
}
|
|
args["recipe_id"] = rec.ID
|
|
args["title"] = rec.Title
|
|
call.Function.Arguments = args
|
|
}
|
|
mealIndex++
|
|
}
|
|
out = append(out, call)
|
|
}
|
|
if changed {
|
|
a.trace(ctx, "agent", "recipe_first_native", "output", map[string]any{"message": msg, "tool_calls": out, "recipes": len(recipes)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *app) toolCommitReply(results []toolResult) string {
|
|
mealPlans := []store.MealPlan{}
|
|
for _, res := range results {
|
|
if res.Success && res.Mutated && (res.Tool == "mealplan_create" || res.Tool == "mealplan_update") {
|
|
if v, ok := decodeToolData[store.MealPlan](res.Data); ok {
|
|
mealPlans = append(mealPlans, v)
|
|
}
|
|
}
|
|
}
|
|
if len(mealPlans) >= 3 {
|
|
sort.Slice(mealPlans, func(i, j int) bool { return mealPlans[i].Date < mealPlans[j].Date })
|
|
mealLabel := "Mahlzeiten"
|
|
allDinner := true
|
|
for _, m := range mealPlans {
|
|
if normalizeMeal(m.Meal) != "dinner" {
|
|
allDinner = false
|
|
break
|
|
}
|
|
}
|
|
if allDinner {
|
|
mealLabel = "Abendessen"
|
|
}
|
|
lines := []string{fmt.Sprintf("Essensplan: %d %s geplant.", len(mealPlans), mealLabel)}
|
|
for _, m := range mealPlans {
|
|
dateText := m.Date
|
|
if d, err := time.ParseInLocation("2006-01-02", m.Date, a.timezone); err == nil {
|
|
dateText = fmt.Sprintf("%s, %02d.%02d.", germanWeekday(d.Weekday()), d.Day(), int(d.Month()))
|
|
}
|
|
lines = append(lines, fmt.Sprintf("• %s · %s", dateText, m.Title))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
lines := []string{}
|
|
for _, res := range results {
|
|
if !res.Success || !res.Mutated {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(res.Message) != "" {
|
|
lines = append(lines, strings.TrimSpace(res.Message))
|
|
continue
|
|
}
|
|
switch res.Tool {
|
|
case "calendar_create", "calendar_update":
|
|
if v, ok := decodeToolData[store.CalendarEvent](res.Data); ok {
|
|
lines = append(lines, fmt.Sprintf("Kalender: „%s“ · %s.", v.Title, a.formatDateTime(v.Start, v.AllDay)))
|
|
}
|
|
case "calendar_delete":
|
|
lines = append(lines, "Kalendertermin gelöscht.")
|
|
case "tasks_create", "tasks_update":
|
|
if v, ok := decodeToolData[store.Task](res.Data); ok {
|
|
lines = append(lines, "Aufgabe gespeichert: „"+v.Title+"“.")
|
|
}
|
|
case "tasks_delete":
|
|
lines = append(lines, "Aufgabe gelöscht.")
|
|
case "reminders_create":
|
|
if v, ok := decodeToolData[store.Reminder](res.Data); ok {
|
|
lines = append(lines, fmt.Sprintf("Erinnerung: „%s“ · %s.", v.Title, a.formatDateTime(v.At, false)))
|
|
}
|
|
case "reminders_delete":
|
|
lines = append(lines, "Erinnerung gelöscht.")
|
|
case "groceries_add", "groceries_update":
|
|
if v, ok := decodeToolData[store.GroceryItem](res.Data); ok {
|
|
lines = append(lines, "Einkauf gespeichert: „"+v.Name+"“.")
|
|
}
|
|
case "groceries_remove":
|
|
lines = append(lines, "Einkaufsartikel entfernt.")
|
|
case "recipes_create", "recipes_update":
|
|
if v, ok := decodeToolData[store.Recipe](res.Data); ok {
|
|
verb := "gespeichert"
|
|
if res.Tool == "recipes_update" {
|
|
verb = "aktualisiert"
|
|
}
|
|
lines = append(lines, "Rezept "+verb+": „"+v.Title+"“.")
|
|
}
|
|
case "groceries_sync_mealplan":
|
|
if m, ok := res.Data.(map[string]any); ok {
|
|
items, _ := m["items"].([]store.GroceryItem)
|
|
if items == nil {
|
|
b, _ := json.Marshal(m["items"])
|
|
_ = json.Unmarshal(b, &items)
|
|
}
|
|
if len(items) == 0 {
|
|
lines = append(lines, "Für die aktuell mit Rezepten verknüpften Mahlzeiten sind keine Einkaufspositionen offen.")
|
|
} else {
|
|
parts := []string{"Einkauf aus Essensplan:"}
|
|
for _, g := range items {
|
|
parts = append(parts, "• "+strings.TrimSpace(strings.Join([]string{g.Quantity, g.Unit, g.Name}, " ")))
|
|
}
|
|
lines = append(lines, strings.Join(parts, "\n"))
|
|
}
|
|
var missing []map[string]any
|
|
b, _ := json.Marshal(m["missing_recipe_meals"])
|
|
_ = json.Unmarshal(b, &missing)
|
|
if len(missing) > 0 {
|
|
names := []string{}
|
|
for _, x := range missing {
|
|
if t, _ := x["title"].(string); t != "" {
|
|
names = append(names, t)
|
|
}
|
|
}
|
|
lines = append(lines, "Noch ohne gepflegtes Rezept und daher ohne Zutaten: "+strings.Join(names, ", ")+".")
|
|
}
|
|
}
|
|
case "mealplan_create", "mealplan_update":
|
|
if v, ok := decodeToolData[store.MealPlan](res.Data); ok {
|
|
dateText := v.Date
|
|
if d, err := time.ParseInLocation("2006-01-02", v.Date, a.timezone); err == nil {
|
|
dateText = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year())
|
|
}
|
|
meal := map[string]string{"breakfast": "Frühstück", "lunch": "Mittagessen", "dinner": "Abendessen", "snack": "Snack"}[normalizeMeal(v.Meal)]
|
|
lines = append(lines, fmt.Sprintf("Essensplan: „%s“ · %s · %s.", v.Title, dateText, meal))
|
|
}
|
|
case "mealplan_delete":
|
|
lines = append(lines, "Essensplan-Eintrag gelöscht.")
|
|
case "kanban_create", "kanban_move":
|
|
if v, ok := decodeToolData[store.KanbanCard](res.Data); ok {
|
|
lines = append(lines, fmt.Sprintf("Kanban: „%s“ → %s.", v.Title, strings.ToUpper(v.Column)))
|
|
}
|
|
case "kanban_delete":
|
|
lines = append(lines, "Kanban-Karte gelöscht.")
|
|
}
|
|
}
|
|
if len(lines) == 0 {
|
|
return "Die angeforderte Änderung wurde über das Tool-System ausgeführt."
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (a *app) toolReadReply(results []toolResult) string {
|
|
if len(results) == 0 {
|
|
return "Keine Tool-Ergebnisse vorhanden."
|
|
}
|
|
last := results[len(results)-1]
|
|
if !last.Success {
|
|
if last.Error != nil {
|
|
return last.Error.Message
|
|
}
|
|
return "Skill-Anfrage fehlgeschlagen."
|
|
}
|
|
if strings.TrimSpace(last.Message) != "" {
|
|
return strings.TrimSpace(last.Message)
|
|
}
|
|
b, _ := json.Marshal(last.Data)
|
|
return "Skill-Ergebnis: " + string(b)
|
|
}
|
|
|
|
func decodeToolData[T any](v any) (T, bool) {
|
|
var out T
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return out, false
|
|
}
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return out, false
|
|
}
|
|
return out, true
|
|
}
|