769 lines
32 KiB
PHP
769 lines
32 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
|
|
close_map / close_poi / close_transit / close_status: Aktives Panel schließen
|
|
spotify_play / spotify_pause / spotify_next / spotify_prev: Spotify steuern
|
|
mail_open: Mail App öffnen
|
|
open_app: Mac App öffnen → app=App-Name
|
|
screenshot: Screenshot machen
|
|
save_desktop: Desktop speichern
|
|
volume_up / volume_down: Lautstärke anpassen
|
|
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
|
|
"spiel musik" → action=spotify_play
|
|
"nächster song" → action=spotify_next
|
|
"vorheriger song" → action=spotify_prev
|
|
"musik pause" → action=spotify_pause
|
|
"öffne mail" → action=mail_open
|
|
"öffne finder" → action=open_app, app="Finder"
|
|
"öffne safari" → action=open_app, app="Safari"
|
|
"mach einen screenshot" → action=screenshot
|
|
"lauter" → action=volume_up
|
|
"leiser" → action=volume_down
|
|
"schließe die karte" → action=close_map
|
|
"schließe die restaurants" → action=close_poi
|
|
|
|
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',
|
|
'spotify_play', 'spotify_pause', 'spotify_next', 'spotify_prev',
|
|
'mail_open', 'open_app', 'screenshot', 'save_desktop',
|
|
'volume_up', 'volume_down',
|
|
]),
|
|
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'),
|
|
new StringSchema('app', 'App Name bei action=open_app'),
|
|
],
|
|
requiredFields: ['action', 'text', 'city', 'category', 'place', 'to', 'url', 'query', 'code', 'language', 'result', 'description', 'app'],
|
|
);
|
|
|
|
// 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.',
|
|
]));
|
|
}
|
|
|
|
// Desktop-Actions (Spotify, Mail, App, System) — für Electron Mac Agent
|
|
if (in_array($action, [
|
|
'spotify_play', 'spotify_pause', 'spotify_next', 'spotify_prev',
|
|
'mail_open', 'open_app', 'screenshot', 'save_desktop',
|
|
'volume_up', 'volume_down',
|
|
], true)) {
|
|
broadcast(new FoxAction([
|
|
'action' => $action,
|
|
'text' => $reply['text'] ?? '',
|
|
'app' => $reply['app'] ?? null,
|
|
]));
|
|
}
|
|
|
|
$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(),
|
|
];
|
|
}
|
|
}
|