fox/app/Services/FoxAgent.php

733 lines
30 KiB
PHP

<?php
namespace App\Services;
use App\Events\FoxAction;
use App\Events\FoxMessage;
use App\Models\Goal;
use App\Models\Message;
use App\Models\ToolCall;
use App\Tools\BuildTemplateTool;
use App\Tools\FileManagerTool;
use App\Tools\WebSearchTool;
use Illuminate\Support\Facades\Log;
use Prism\Prism\Enums\Provider;
use Prism\Prism\Facades\Prism;
use Prism\Prism\Schema\BooleanSchema;
use Prism\Prism\Schema\EnumSchema;
use Prism\Prism\Schema\NumberSchema;
use Prism\Prism\Schema\ObjectSchema;
use Prism\Prism\Schema\StringSchema;
use Prism\Prism\Tool;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\UserMessage;
/**
* Fox Agent Loop:
* think - Kontext sammeln (Verlauf, aktive Ziele, relevante Memories)
* plan - LLM aufrufen
* act - Antwort speichern + broadcasten
* reflect- ggf. ins Langzeitgedächtnis ablegen
*/
class FoxAgent
{
public function __construct(
protected MemoryService $memory,
) {}
/**
* Persistiert die User-Nachricht und broadcastet sie. Verarbeitung erfolgt
* separat via processUserMessage() (i. d. R. async im Queue-Worker).
*/
public function recordUserMessage(string $content, array $metadata = []): Message
{
$message = Message::create([
'role' => Message::ROLE_USER,
'content' => $content,
'type' => ($metadata['voice'] ?? false) ? Message::TYPE_VOICE : Message::TYPE_TEXT,
'metadata' => $metadata,
]);
broadcast(new FoxMessage($message))->toOthers();
return $message;
}
/**
* Verarbeitet eine User-Nachricht: think → plan → act → reflect.
* Gibt die Assistant-Message zurück.
*/
public function processUserMessage(Message $userMessage): Message
{
$context = $this->think($userMessage->content);
$reply = $this->plan($context, $userMessage->content);
$assistantMessage = $this->act($reply, Message::TYPE_TEXT);
$this->reflect($userMessage->content, $reply['text']);
return $assistantMessage;
}
/**
* Convenience: kombiniert record + process. Synchron - nur für Tests/CLI.
*/
public function handleUserMessage(string $content, array $metadata = []): Message
{
$userMessage = $this->recordUserMessage($content, $metadata);
return $this->processUserMessage($userMessage);
}
/**
* Wird vom Proactive-Job aufgerufen. Fragt das LLM ob Fox aktiv etwas sagen soll.
*
* @return array{shouldSpeak: bool, message: string, priority: int}
*/
public function shouldISpeak(): array
{
$now = now();
$hour = (int) $now->format('H');
$timeBucket = match (true) {
$hour < 6 => 'nacht',
$hour < 11 => 'morgen',
$hour < 14 => 'mittag',
$hour < 17 => 'nachmittag',
$hour < 22 => 'abend',
default => 'nacht',
};
$activeGoals = Goal::active()->get();
$lastMessage = Message::latest()->first();
$minutesSinceLast = $lastMessage
? (int) $lastMessage->created_at->diffInMinutes($now)
: 9999;
if ($minutesSinceLast < 240) {
return ['shouldSpeak' => false, 'message' => '', 'priority' => 0];
}
// Was hat der User zuletzt gemacht? Letzte Suche / Empfehlung
$lastPois = \Illuminate\Support\Facades\Cache::get('fox.last_pois');
$lastSearch = is_array($lastPois)
? ($lastPois['label'] ?? null).' in '.($lastPois['city'] ?? '?')
: null;
// Wetter + Zeit für tageszeitabhängige Tipps
$weather = null;
try {
$w = app(\App\Services\WeatherService::class)->current();
if ($w) {
$weather = round($w['temperature'] ?? 0, 1).'°C';
}
} catch (\Throwable) {
// ignore
}
$context = [
'time' => $now->format('Y-m-d H:i'),
'time_bucket' => $timeBucket,
'weekday' => $now->locale('de')->dayName,
'weather' => $weather,
'active_goals' => $activeGoals->map(fn (Goal $g) => [
'title' => $g->title, 'progress' => $g->progress,
])->all(),
'minutes_since_user_was_active' => $minutesSinceLast,
'last_search' => $lastSearch,
'recent_memories' => $this->memory->summarizeRecent(5),
];
$schema = new ObjectSchema(
name: 'ProactiveDecision',
description: 'Soll Fox jetzt unaufgefordert ein Wort einwerfen?',
properties: [
new BooleanSchema('shouldSpeak', 'true wenn Fox jetzt etwas Sinnvolles oder Persönliches einwerfen kann'),
new StringSchema('message', 'Was Fox sagt - 1-2 deutsche Sätze, locker und persönlich. Leer wenn shouldSpeak=false.'),
new NumberSchema('priority', '0-10. 0=Floskel, 5=Tageszeit/Wetter, 7=Goal-Reminder, 10=wichtig'),
],
requiredFields: ['shouldSpeak', 'message', 'priority'],
);
$systemPrompt = <<<'PROMPT'
Du bist Fox. Sprich NUR wenn du etwas KONKRETES zu erledigen hast.
Erlaubte proaktive Nachrichten:
- Kalendertermin in den nächsten 30 Minuten
- Wichtige E-Mail die eine Antwort braucht
- Aufgabe aus den aktiven Zielen die der User vergessen könnte
- Technisches Problem erkannt
ABSOLUT VERBOTEN:
- NIEMALS fragen ob der User essen gehen möchte
- NIEMALS Small Talk starten
- NIEMALS nach dem Befinden fragen
- NIEMALS Wetter-Tipps zu Aktivitäten
- NIEMALS dasselbe wie in recent_memories wiederholen
- Wenn time_bucket=nacht: shouldSpeak=false (User schläft)
Wenn KEIN konkreter Anlass aus der obigen Liste: shouldSpeak=false.
PROMPT;
// Provider-Chain wie bei plan() - Ollama → OpenAI → still
$providers = [['provider' => Provider::Ollama, 'model' => config('services.ollama.model')]];
if (! empty(config('services.openai.key'))) {
$providers[] = ['provider' => Provider::OpenAI, 'model' => 'gpt-4o-mini'];
}
foreach ($providers as $p) {
try {
$response = Prism::structured()
->using($p['provider'], $p['model'])
->withClientOptions(['timeout' => 25])
->withSystemPrompt($systemPrompt)
->withSchema($schema)
->withPrompt('Kontext: '.json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))
->asStructured();
$decision = $response->structured ?? [];
return [
'shouldSpeak' => (bool) ($decision['shouldSpeak'] ?? false),
'message' => trim((string) ($decision['message'] ?? '')),
'priority' => (int) ($decision['priority'] ?? 0),
];
} catch (\Throwable $e) {
Log::warning('Proactive: Provider fehlgeschlagen', ['error' => $e->getMessage()]);
}
}
return ['shouldSpeak' => false, 'message' => '', 'priority' => 0];
}
public function speakProactively(string $text, int $priority = 5): Message
{
return $this->act(
['text' => $text, 'tool_calls' => []],
Message::TYPE_PROACTIVE,
['priority' => $priority],
);
}
// ---- Agent-Loop Schritte ----
protected function think(string $userInput): array
{
$recentMessages = Message::latest()->limit(10)->get()->reverse()->values();
$relevantMemories = $this->memory->recall($userInput, 3);
$activeGoals = Goal::active()->get();
$lastPois = \Illuminate\Support\Facades\Cache::get('fox.last_pois');
// HUD-Zustand aus letzter User-Nachricht — was ist gerade offen
$lastUserMessage = Message::where('role', Message::ROLE_USER)->latest()->first();
$hudState = $lastUserMessage?->metadata['hud_state'] ?? [];
return [
'recent_messages' => $recentMessages,
'memories' => $relevantMemories,
'goals' => $activeGoals,
'last_pois' => $lastPois,
'hud_state' => $hudState,
];
}
/**
* HUD-State als Text für System-Prompt — Fox weiß damit was gerade sichtbar ist.
*/
protected function buildStateContext(array $state): string
{
if (empty($state)) {
return '';
}
$lines = ['Aktueller HUD-Zustand (was gerade auf dem Bildschirm sichtbar ist):'];
if ($state['map_visible'] ?? false) {
$city = $state['current_city'] ?? 'unbekannte Stadt';
$lines[] = '- Karte ist OFFEN und zeigt: '.$city;
} else {
$lines[] = '- Karte ist GESCHLOSSEN';
}
if ($state['poi_panel_open'] ?? false) {
$label = $state['poi_label'] ?? 'Orte';
$count = $state['poi_count'] ?? 0;
$lines[] = '- POI-Panel ist OFFEN mit '.$count.' '.$label;
}
if ($state['transit_on'] ?? false) {
$lines[] = '- Transit/U-Bahn-Layer ist AKTIV';
}
if ($state['route_visible'] ?? false) {
$lines[] = '- Route-Panel ist OFFEN ('.($state['route_target'] ?? 'unbekanntes Ziel').')';
}
if ($state['status_visible'] ?? false) {
$lines[] = '- System-Diagnose-Panel ist OFFEN';
}
if ($state['place_info_visible'] ?? false) {
$lines[] = '- Place-Info-Modal ist OFFEN ('.($state['place_info_name'] ?? '?').')';
}
$lines[] = '';
$lines[] = 'Schließen-Befehle des Users beziehen sich IMMER auf das was gerade offen ist.';
$lines[] = '"schließe das" / "weg damit" / "zu" → action passend zum aktiven Overlay (close_map / close_poi / close_transit / close_status).';
$lines[] = 'Wenn nichts offen ist und User "schließe X" sagt: action=response mit "Da ist nichts offen.".';
return implode("\n", $lines);
}
/**
* @return array{action:string, city:?string, text:string, tool_calls:array}
*/
protected function plan(array $context, string $userInput): array
{
$messages = $context['recent_messages']
->filter(fn (Message $m) => in_array($m->role, [Message::ROLE_USER, Message::ROLE_ASSISTANT], true))
->map(fn (Message $m) => $m->role === Message::ROLE_USER
? new UserMessage($m->content)
: new AssistantMessage($m->content))
->values()
->all();
$messages[] = new UserMessage($userInput);
$memoriesText = collect($context['memories'])
->map(fn ($m) => '- '.$m->content)
->implode("\n");
$goalsText = $context['goals']
->map(fn (Goal $g) => "- {$g->title} ({$g->progress}%)")
->implode("\n");
// Letzte POI-Treffer als Liste für die Empfehlung formatieren
$poisText = '';
$lastPois = $context['last_pois'] ?? null;
if (is_array($lastPois) && ! empty($lastPois['pois'])) {
$items = collect($lastPois['pois'])
->take(15)
->map(fn ($p) => '- '.($p['name'] ?? '?').(! empty($p['address']) ? ' ('.$p['address'].')' : ''))
->implode("\n");
$what = $lastPois['label'] ?? 'Orte';
$where = $lastPois['city'] ?? '';
$poisText = "Diese {$what} hast du dem User gerade auf der Karte gezeigt".($where ? " in {$where}" : '').":\n{$items}";
}
// HUD-State-Kontext für intelligenteres Schließ-Verhalten
$stateText = $this->buildStateContext($context['hud_state'] ?? []);
$systemPrompt = trim(<<<PROMPT
Du bist Fox, ein persönlicher KI-Assistent der HANDELT statt redet. Du sprichst Deutsch.
KERNREGEL: Führe Aufgaben aus. Frage nicht ob du helfen sollst tu es einfach.
WAS DU KANNST:
map: Karte mit Stadt oder Ort zeigen
poi: Orte suchen (Restaurants, Apotheken, Parkplätze, Hotels, Ärzte, Banken, Supermärkte)
weather: Wetter anzeigen
transit: U-Bahn/Transit Layer (nur Liniennetz, KEINE Route von A nach B)
route: Navigation von A nach B
recommend: Konkreten Ort aus der POI-Liste empfehlen
web_search: Im Web suchen
open_url: URL im Browser öffnen (Google, YouTube, Maps, etc.)
code: VOLLSTÄNDIGEN ausführbaren Code schreiben oder erklären
calculate: Rechnen, Umrechnen, Kalkulieren
remind: Erinnerung setzen
brain: Fox Brain / Gedächtnis-Visualisierung öffnen
response: Normale Antwort wenn keine andere Action passt
BEISPIELE:
"google laravel websockets" action=open_url, url="https://google.com/search?q=laravel+websockets"
"wie geht array_map in php" action=code, language="php", code="\$r = array_map(fn(\$x) => \$x*2, [1,2,3]); // [2,4,6]"
"wieviel ist 15% von 340" action=calculate, result="51"
"öffne youtube" action=open_url, url="https://youtube.com"
"wie ist das wetter morgen" action=weather, city=graz
"zeig wien" action=map, city=wien
"parkplatz in der nähe" action=poi, category=parking
"restaurants wien" action=poi, category=restaurant, city=wien
"was ist docker compose" action=response
"route zum stephansdom" action=route, to="Stephansdom"
"empfehl mir eins" action=recommend, place="EXAKTER_NAME_AUS_LISTE"
"zeig mir dein Gehirn" action=brain
"was weißt du" action=brain
"dein Gedächtnis" action=brain
"memory" action=brain
BEI action=code:
- code: VOLLSTÄNDIGER ausführbarer Code, nicht nur Erklärung
- language: php | javascript | python | bash | sql | html | css
- text: Eine Zeile was der Code macht (kein "Ich erstelle...")
- Kommentare im Code erlaubt, aber kein langer Erklärungs-Text
ROUTING: Bei ROUTE/WEGBESCHREIBUNG/NAVIGATION action=route.
to = Zielname oder "__LAST_RECOMMEND__" bei "dahin/dorthin".
NIEMALS transit dafür verwenden.
EMPFEHLUNG: Wähle EXAKTEN Namen aus der POI-Liste unten.
place = Copy-Paste des Namens mit Sonderzeichen.
WAS DU NICHT TUN SOLLST:
- NIEMALS fragen ob der User essen gehen möchte
- NIEMALS "Kann ich dir helfen?" fragen
- NIEMALS Small Talk
- NIEMALS "Ich kann leider..." sagen
- NIEMALS lange Erklärungen wenn kurze Antwort reicht
text: maximal 2 deutsche Sätze, direkt und knapp.
Aktive Ziele:
{$goalsText}
Gedächtnis:
{$memoriesText}
{$poisText}
{$stateText}
PROMPT);
$schema = new ObjectSchema(
name: 'FoxResponse',
description: 'Strukturierte Antwort von Fox',
properties: [
new EnumSchema('action', 'Was Fox tun soll', [
'map', 'poi', 'weather', 'transit', 'route', 'recommend', 'response',
'close_map', 'close_poi', 'close_transit', 'close_status',
'web_search', 'open_url', 'code', 'calculate', 'remind', 'brain',
]),
new StringSchema('city', 'Kleingeschriebener Stadtname (leer wenn nicht relevant)'),
new StringSchema('category', 'POI-Kategorie bei action=poi: restaurant, parking, pharmacy, hotel, doctor, bank, supermarket'),
new StringSchema('place', 'EXAKTER Name aus der Liste falls action=recommend (sonst leer)'),
new StringSchema('to', 'Ziel falls action=route - Ortsname oder "__LAST_RECOMMEND__"'),
new StringSchema('text', 'Deutsche Antwort die vorgelesen wird, max 2 Sätze'),
new StringSchema('url', 'Vollständige URL bei action=open_url oder web_search'),
new StringSchema('query', 'Suchbegriff bei action=web_search'),
new StringSchema('code', 'Code-Snippet bei action=code'),
new StringSchema('language', 'Programmiersprache bei action=code (php, js, python, etc.)'),
new StringSchema('result', 'Berechnetes Ergebnis bei action=calculate'),
new StringSchema('description', 'Beschreibung was der Code macht, bei action=code'),
],
requiredFields: ['action', 'text', 'city', 'category', 'place', 'to', 'url', 'query', 'code', 'language', 'result', 'description'],
);
// Provider-Chain: Ollama (Mac, qwen2.5:32b) → OpenAI (gpt-4o-mini) → Heuristik.
// So bleibt Fox auch funktional wenn der Mac off ist.
$providers = [
['provider' => Provider::Ollama, 'model' => config('services.ollama.model'), 'label' => 'ollama'],
];
if (! empty(config('services.openai.key'))) {
$providers[] = ['provider' => Provider::OpenAI, 'model' => 'gpt-4o-mini', 'label' => 'openai'];
}
$lastError = null;
foreach ($providers as $p) {
try {
$response = Prism::structured()
->using($p['provider'], $p['model'])
->withClientOptions(['timeout' => $p['label'] === 'ollama' ? 30 : 25])
->withSystemPrompt($systemPrompt)
->withMessages($messages)
->withSchema($schema)
->asStructured();
$data = $response->structured ?? [];
Log::info('Plan: '.$p['label'].' OK');
return [
'action' => (string) ($data['action'] ?? 'response'),
'city' => isset($data['city']) ? mb_strtolower(trim((string) $data['city'])) : null,
'category' => isset($data['category']) ? trim((string) $data['category']) : null,
'place' => isset($data['place']) ? trim((string) $data['place']) : null,
'to' => isset($data['to']) && $data['to'] !== '' ? trim((string) $data['to']) : null,
'text' => trim((string) ($data['text'] ?? '')) ?: 'Hm.',
'url' => isset($data['url']) ? trim((string) $data['url']) : null,
'query' => isset($data['query']) ? trim((string) $data['query']) : null,
'code' => isset($data['code']) ? (string) $data['code'] : null,
'language' => isset($data['language']) ? trim((string) $data['language']) : null,
'result' => isset($data['result']) ? trim((string) $data['result']) : null,
'description' => isset($data['description']) ? trim((string) $data['description']) : null,
'tool_calls' => [],
];
} catch (\Throwable $e) {
$lastError = $e->getMessage();
Log::warning('Plan-Provider '.$p['label'].' fehlgeschlagen, versuche Fallback', ['error' => $lastError]);
}
}
// Letzte Stufe: heuristischer Fallback für Empfehlungen + nützliche Diagnose.
Log::error('Plan-Schritt: alle LLM-Provider tot', ['lastError' => $lastError]);
return $this->heuristicFallback($userInput, $context, $lastError);
}
/**
* Wenn alle LLMs tot sind: aus dem Pattern + letzter POI-Liste etwas Sinnvolles
* generieren statt nur "etwas ist schief gelaufen" zu sagen.
*/
/**
* Garantiert dass ein recommend-place tatsächlich in der zuletzt gezeigten
* Liste war - sonst halluziniert das LLM Namen die das Frontend nicht zoomen kann.
*/
protected function validateRecommendation(string $place): string
{
$cached = \Illuminate\Support\Facades\Cache::get('fox.last_pois');
$pois = is_array($cached) ? ($cached['pois'] ?? []) : [];
if (empty($pois)) {
return $place; // keine Liste → wir lassen den Originalnamen, Frontend versucht Geocoding
}
$norm = fn ($s) => mb_strtolower(preg_replace('/[^\p{L}\p{N} ]/u', '', (string) $s));
$needle = $norm($place);
// exact / substring match
foreach ($pois as $p) {
$n = $norm($p['name'] ?? '');
if ($n === $needle || ($n && (str_contains($n, $needle) || str_contains($needle, $n)))) {
return $p['name'];
}
}
// Halluzination → ersten der Liste nehmen, der ist nach Relevanz sortiert
\Illuminate\Support\Facades\Log::info('Recommendation halluziniert, ersetzt', [
'llm_said' => $place,
'replaced_with' => $pois[0]['name'] ?? '?',
]);
return $pois[0]['name'] ?? $place;
}
protected function heuristicFallback(string $userInput, array $context, ?string $lastError): array
{
$lower = mb_strtolower($userInput);
$lastPois = $context['last_pois'] ?? null;
$pois = is_array($lastPois) ? ($lastPois['pois'] ?? []) : [];
$isRecommend = (bool) preg_match('/\b(empfehl|vorschl|welches|welche|kannst du (mir |eins)|gib mir|zeig mir eins)/u', $lower);
if ($isRecommend && ! empty($pois)) {
// Random aus den oberen 5 - die haben den höchsten Relevanz-Score.
$top = array_slice($pois, 0, 5);
$pick = $top[array_rand($top)];
$name = $pick['name'] ?? '?';
$addr = $pick['address'] ?? '';
return [
'action' => 'recommend',
'city' => $lastPois['city'] ?? null,
'place' => $name,
'text' => 'Probier doch '.$name.($addr ? ' in der '.$addr : '').'.',
'tool_calls' => [],
];
}
// Diagnose statt Floskel: User soll wissen WAS down ist
$diag = 'Mein Sprachmodell ist gerade nicht erreichbar';
if ($lastError && str_contains($lastError, '11434')) {
$diag = 'Der Mac mit Ollama antwortet nicht (Port 11434 dicht)';
} elseif ($lastError && str_contains($lastError, '429')) {
$diag = 'Mein Cloud-LLM hat das Quota-Limit erreicht';
} elseif ($lastError && (str_contains($lastError, 'timeout') || str_contains($lastError, 'timed out'))) {
$diag = 'Mein Sprachmodell antwortet zu langsam';
}
return [
'action' => 'response',
'city' => null,
'text' => $diag.'. Versuch es in ein paar Sekunden nochmal, oder formulier kürzer.',
'tool_calls' => [],
];
}
protected function act(array $reply, string $type, array $metadata = []): Message
{
$action = (string) ($reply['action'] ?? 'response');
$city = $reply['city'] ?? null;
// TTS NUR für reine Chat-Antworten - bei map/poi/transit/weather hat
// das HUD lokal schon "Ich suche..." und "X gefunden" gesagt, sonst
// doppelt gesprochen.
$audioUrl = null;
$shouldSpeak = ! empty($reply['text'])
&& ! empty(config('services.openai.key'));
if ($shouldSpeak) {
try {
$audioUrl = app(SpeechService::class)->speak($reply['text']);
} catch (\Throwable $e) {
Log::warning('TTS fehlgeschlagen', ['error' => $e->getMessage()]);
}
}
// Wenn die Antwort eine HUD-Action enthält - extra Broadcast,
// damit das Frontend Map/Food/Weather sofort verarbeiten kann.
if (in_array($action, ['map', 'poi', 'weather', 'transit'], true)) {
$cities = config('cities', []);
$cityKey = $city ? $this->resolveCityKey($city, $cities) : null;
$payload = [
'action' => $action,
'city' => $cityKey,
'text' => $reply['text'] ?? '',
'data' => $cityKey ? ($cities[$cityKey] ?? null) : null,
];
if ($action === 'poi' && ! empty($reply['category'])) {
$payload['category'] = $reply['category'];
}
broadcast(new FoxAction($payload));
}
// Close-Actions vom LLM (state-aware)
if (in_array($action, ['close_map', 'close_poi', 'close_transit', 'close_status'], true)) {
broadcast(new FoxAction([
'action' => $action,
'text' => $reply['text'] ?? '',
]));
}
// Bei Empfehlung: Frontend matcht den place-Namen aus der letzten POI-Liste,
// zoomt zur Stelle hin und zeigt Popup. Wenn LLM einen Namen halluziniert
// der nicht in der echten Liste ist - durch ersten Treffer ersetzen.
if ($action === 'recommend' && ! empty($reply['place'])) {
$place = $this->validateRecommendation($reply['place']);
$text = $reply['text'] ?? '';
// Falls der Place ersetzt wurde, auch im Text korrigieren wenn der Originalname enthalten ist
if ($place !== $reply['place'] && $text && str_contains($text, $reply['place'])) {
$text = str_replace($reply['place'], $place, $text);
}
broadcast(new FoxAction([
'action' => 'recommend',
'place' => $place,
'text' => $text,
]));
}
// Routing: Frontend ruft danach /fox/route → zeichnet Linie auf der Karte.
if ($action === 'route' && ! empty($reply['to'])) {
broadcast(new FoxAction([
'action' => 'route',
'to' => $reply['to'],
'mode' => 'driving',
'text' => $reply['text'] ?? '',
]));
}
// Web-Suche: als open_url an Frontend schicken
if ($action === 'web_search') {
$searchUrl = ! empty($reply['url'])
? $reply['url']
: 'https://google.com/search?q='.urlencode((string) ($reply['query'] ?? ''));
broadcast(new FoxAction([
'action' => 'open_url',
'url' => $searchUrl,
'text' => $reply['text'] ?? '',
]));
}
// URL öffnen
if ($action === 'open_url' && ! empty($reply['url'])) {
broadcast(new FoxAction([
'action' => 'open_url',
'url' => $reply['url'],
'text' => $reply['text'] ?? '',
]));
}
// Code-Antwort
if ($action === 'code') {
broadcast(new FoxAction([
'action' => 'code',
'code' => $reply['code'] ?? '',
'language' => $reply['language'] ?? 'php',
'text' => $reply['text'] ?? '',
'description' => $reply['description'] ?? $reply['text'] ?? '',
'files' => $reply['files'] ?? [],
]));
}
// Rechenergebnis
if ($action === 'calculate') {
broadcast(new FoxAction([
'action' => 'calculate',
'result' => $reply['result'] ?? '',
'text' => $reply['text'] ?? '',
]));
}
// Brain-Visualisierung öffnen
if ($action === 'brain') {
broadcast(new FoxAction([
'action' => 'brain',
'text' => $reply['text'] ?? 'Hier ist mein Gedächtnis.',
]));
}
$message = Message::create([
'role' => Message::ROLE_ASSISTANT,
'content' => $reply['text'] ?: '...',
'type' => $type,
'metadata' => array_merge($metadata, [
'action' => $action,
'city' => $city,
'audio_url' => $audioUrl,
]),
]);
foreach ($reply['tool_calls'] ?? [] as $call) {
ToolCall::create([
'message_id' => $message->id,
'tool' => $call['tool'] ?? 'unknown',
'input' => $call['arguments'] ?? null,
'output' => is_array($call['result']) ? $call['result'] : ['result' => $call['result']],
'success' => true,
]);
}
broadcast(new FoxMessage($message));
return $message;
}
/**
* LLM gibt manchmal "Wien" oder "Vienna" - wir matchen flexibel auf
* unsere City-Keys (kleinschreibung, alias-tolerant).
*/
protected function resolveCityKey(string $name, array $cities): ?string
{
$needle = mb_strtolower(trim($name));
if (isset($cities[$needle])) {
return $needle;
}
// Fuzzy: Stadt-Name in Cities-Display-Names suchen
foreach ($cities as $key => $data) {
if (mb_strtolower($data['name']) === $needle) {
return $key;
}
}
return null;
}
protected function reflect(string $userInput, string $assistantReply): void
{
$combined = "User: {$userInput}\nFox: {$assistantReply}";
if (mb_strlen($combined) < 80) {
return;
}
$this->memory->remember($combined, [
'kind' => 'conversation_snippet',
'at' => now()->toIso8601String(),
]);
}
/**
* @return array<int, Tool>
*/
protected function resolveTools(): array
{
return [
app(WebSearchTool::class)->asPrismTool(),
app(BuildTemplateTool::class)->asPrismTool(),
app(FileManagerTool::class)->asPrismTool(),
];
}
}