'italian', 'italienisches' => 'italian', 'pizza' => 'pizza', 'pizzeria' => 'pizza', 'pasta' => 'italian', 'sushi' => 'sushi', 'japanisch' => 'japanese', 'japanisches' => 'japanese', 'ramen' => 'ramen', 'chinesisch' => 'chinese', 'chinesisches' => 'chinese', 'asiatisch' => 'asian', 'asiatisches' => 'asian', 'thai' => 'thai', 'thailändisch' => 'thai', 'vietnamesisch' => 'vietnamese', 'koreanisch' => 'korean', 'indisch' => 'indian', 'indisches' => 'indian', 'curry' => 'indian', 'türkisch' => 'turkish', 'türkisches' => 'turkish', 'kebab' => 'kebab', 'döner' => 'kebab', 'griechisch' => 'greek', 'griechisches' => 'greek', 'mexikanisch' => 'mexican', 'mexikanisches' => 'mexican', 'tacos' => 'mexican', 'französisch' => 'french', 'französisches' => 'french', 'spanisch' => 'spanish', 'spanisches' => 'spanish', 'tapas' => 'tapas', 'amerikanisch' => 'american','amerikanisches' => 'american', 'steak' => 'steak_house', 'steakhouse' => 'steak_house','steaks' => 'steak_house', 'burger' => 'burger', 'burgers' => 'burger', 'fisch' => 'seafood', 'seafood' => 'seafood', 'vegan' => 'vegan', 'vegetarisch' => 'vegetarian', 'sushi' => 'sushi', 'bbq' => 'bbq', 'barbecue' => 'bbq', ]; /** * POI-Kategorien → Trigger-Wörter und Overpass-Filter. */ protected const CATEGORIES = [ 'restaurant' => [ 'keywords' => ['restaurant', 'restaurants', 'restorant', 'restorants', 'restorante', 'restorantes', 'lokal', 'lokale', 'gasthaus', 'gasthof', 'wirtshaus', 'beisl', 'essen', 'food', 'speisen'], 'label' => 'Restaurants', ], 'cafe' => [ 'keywords' => ['café', 'cafe', 'cafés', 'cafes', 'kaffee', 'kaffeehaus', 'coffee'], 'label' => 'Cafés', ], 'bar' => [ 'keywords' => ['bar', 'bars', 'kneipe', 'kneipen', 'pub', 'pubs', 'lounge', 'club', 'clubs'], 'label' => 'Bars', ], 'fast_food' => [ 'keywords' => ['fastfood', 'fast-food', 'burger', 'pizza', 'döner', 'kebab', 'imbiss'], 'label' => 'Fast Food', ], 'pharmacy' => [ 'keywords' => ['apotheke', 'apotheken', 'pharmacy'], 'label' => 'Apotheken', ], 'hospital' => [ 'keywords' => ['krankenhaus', 'krankenhäuser', 'spital', 'spitäler', 'klinik', 'kliniken', 'hospital', 'hospitals', 'notaufnahme'], 'label' => 'Krankenhäuser', ], 'doctor' => [ 'keywords' => ['arzt', 'ärzte', 'doktor', 'praxis', 'doctor'], 'label' => 'Ärzte', ], 'bank' => [ 'keywords' => ['bank', 'banken', 'atm', 'bankomat', 'geldautomat'], 'label' => 'Banken', ], 'fuel' => [ 'keywords' => ['tankstelle', 'tankstellen', 'tanken', 'benzin', 'gas station'], 'label' => 'Tankstellen', ], 'supermarket' => [ 'keywords' => ['supermarkt', 'supermärkte', 'einkaufen', 'lebensmittel', 'spar', 'billa', 'hofer', 'rewe'], 'label' => 'Supermärkte', ], 'hotel' => [ 'keywords' => ['hotel', 'hotels', 'unterkunft', 'übernachten', 'pension', 'hostel'], 'label' => 'Hotels', ], 'government' => [ 'keywords' => ['behörde', 'behörden', 'amt', 'ämter', 'rathaus', 'magistrat', 'bezirksamt'], 'label' => 'Behörden', ], 'police' => [ 'keywords' => ['polizei', 'police', 'polizeistation'], 'label' => 'Polizei', ], 'parking' => [ 'keywords' => ['parkplatz', 'parking', 'parkhaus', 'garage'], 'label' => 'Parkplätze', ], ]; /** * @return array{action:string, city?:string, category?:string, cuisine?:string, label?:string, data?:array}|null */ public function detect(string $message): ?array { $cities = config('cities', []); if (empty($cities)) { return null; } // Compound: "X und Y" - erste Hälfte für direktes Fast-Path-Intent, // restlicher Text in `chain` für Frontend-Sequenz-Verarbeitung. if (preg_match('/^(.+?)\s+und\s+(.+)$/u', trim($message), $cm)) { $first = $this->detectSingle(trim($cm[1])); $second = $this->detectSingle(trim($cm[2])); if ($first && $second) { $first['chain'] = [$second]; return $first; } if ($first) return $first; if ($second) return $second; } return $this->detectSingle($message); } public function detectSingle(string $message): ?array { $cities = config('cities', []); if (empty($cities)) { return null; } $lower = mb_strtolower(trim($message)); // VOR clean() - sonst werden "was/ist/das" als Filler weggestrippt. // Karte / Panel zu - "karte zu", "schließen", "zu", "weg", "abbrechen" if (preg_match('/\b(karte|map|diagnose|status|systemcheck|panel|info|empfehlung)\s*(zu|schließe?n?|schliesse?n?|weg|aus|ausblenden|verbergen|verstecken)\b/u', $lower) || preg_match('/\b(schließe?n?|schliesse?n?|weg\s+mit|schliess?\s+weg)\s+(die\s+|den\s+|das\s+)?(karte|map|panel|fenster|status|diagnose|systemcheck|empfehlung|info)\b/u', $lower) || preg_match('/^(schließen|schliessen|schließe|schliesse|zu|weg|aus|abbrechen|fertig|stop|stopp|ende|alles\s+zu)\.?$/u', $lower)) { return ['action' => 'close_map']; } // Status / Systemcheck / Diagnose if (preg_match('/\b(status|systemcheck|system\s*check|systemstatus|diagnose|gesundheit|health|alles\s+(in\s+)?(ordnung|ok)|wie\s+geht.s\s+(dem|dir|fox|server)|läuft\s+alles|funktioniert\s+alles|allsystem)\b/u', $lower)) { return ['action' => 'system_status']; } // Zahlwörter → Ziffern (ein/zwei/drei → 1/2/3) für Reminder/Timer $numWords = [ 'einer' => 1, 'einen' => 1, 'eine' => 1, 'ein' => 1, 'eins' => 1, 'zwei' => 2, 'drei' => 3, 'vier' => 4, 'fünf' => 5, 'fuenf' => 5, 'sechs' => 6, 'sieben' => 7, 'acht' => 8, 'neun' => 9, 'zehn' => 10, 'elf' => 11, 'zwölf' => 12, 'zwoelf' => 12, 'dreizehn' => 13, 'vierzehn' => 14, 'fünfzehn' => 15, 'fuenfzehn' => 15, 'sechzehn' => 16, 'siebzehn' => 17, 'achtzehn' => 18, 'neunzehn' => 19, 'zwanzig' => 20, 'dreißig' => 30, 'dreissig' => 30, 'vierzig' => 40, 'fünfzig' => 50, 'fuenfzig' => 50, 'sechzig' => 60, ]; $numerized = $lower; foreach ($numWords as $w => $n) { $numerized = preg_replace('/\b'.$w.'\b/u', (string) $n, $numerized); } // Reminder/Timer: liberal unit match — "minteun"/"min"/"minuten"/"sekunde"/"std" alle ok // unit-Erkennung über Anfangsbuchstaben + Levenshtein-Toleranz. // "errinnere" / "erinere" / Whisper-Typos abgedeckt durch e{1,2}r{1,2}inner\w* if (preg_match('/(?:e{1,2}r{1,2}inner\w*\s+mich|wecker|timer|alarm|erinner\w*)\s+(?:.{0,15}?)\bin\s+(\d+)\s*(\S+?)(?:\s+(?:zu|an|dass)\s+|\s+|$)(.*)$/u', $numerized, $rm) || preg_match('/^(?:wecker|timer|alarm)\s+(\d+)\s*(\S+?)(?:\s+(?:zu|an|dass)\s+|\s+|$)(.*)$/u', $numerized, $rm) || preg_match('/^in\s+(\d+)\s*(\S+?)(?:\s+(?:zu|an|dass)\s+|\s+|$)(.*)$/u', $numerized, $rm)) { $n = (int) $rm[1]; $unitRaw = mb_strtolower($rm[2] ?? ''); $what = trim($rm[3] ?? ''); // Unit über Anfangsbuchstaben - reicht. Whisper-Typos meistens am Wortende. $unit = null; if (preg_match('/^(h$|std|stu|stund)/u', $unitRaw)) { $unit = 'h'; } elseif (preg_match('/^m/u', $unitRaw)) { $unit = 'min'; } elseif (preg_match('/^s/u', $unitRaw)) { $unit = 's'; } if ($unit) { $secs = match ($unit) { 's' => $n, 'min' => $n * 60, 'h' => $n * 3600, }; $unitLabel = match ($unit) { 's' => 'Sekunden', 'min' => 'Minuten', 'h' => 'Stunden' }; $what = trim(preg_replace('/^(zu|an|dass)\s+/u', '', $what)); return [ 'action' => 'reminder', 'delay_seconds' => $secs, 'what' => $what !== '' ? $what : 'Erinnerung', 'text' => 'Okay, in '.$n.' '.$unitLabel.($what !== '' ? ': '.$what : '').'.', ]; } } // Referenz-Anfragen auf zuletzt Empfohlenes ("was ist das", "erzähl mehr darüber") // → query = __LAST_RECOMMEND__, HUD fetcht Info für die letzte Empfehlung. if (preg_match('/^(was|wie)\s+(ist|sind|schaut|sieht|heisst|heißt)\s+(das|es|der|die|dies|dieses|dieser)/u', $lower) || preg_match('/(erzähl|sag|info)\s+(mir\s+)?(mehr\s+)?(über|zu|von)\s+(das|dem|es|dies|diesem|dieser|der)\b/u', $lower) || preg_match('/^(mehr\s+)?(infos?|details)\s+(zu|über|von)\s+(dem|das|es|dies|diesem)\b/u', $lower)) { return ['action' => 'place_info', 'query' => '__LAST_RECOMMEND__']; } $cleaned = $this->clean($message); if ($cleaned === '') { return null; } // "Erzähl mir was über X" / "Was ist X" / "Was gibt es in X" / "Wie schaut X aus" / "Info zu X" // → Detail-Karte mit Bild + Beschreibung statt Vollbild-Map $infoPattern = '/^(' .'erzähl|erzaehl|sag|sage|info|infos|mehr\s+infos?|mehr\s+details?' .'|was ist|was sind|was gibt(?:s|\s+es)?|was kann (?:man|ich)|was findet|was steht' .'|was kannst du.+sagen|was weisst|was weißt' .'|wie schaut|wie sieht|wie ist|wie geht' .'|wer ist|wer war' .'|wo ist|wo liegt|wo befindet' .'|zeig\s+mir\s+infos?|zeig\s+infos?' .')\b\s*/u'; if (preg_match($infoPattern, $lower)) { $query = preg_replace($infoPattern, '', $lower); // Pronomen / Füllwörter $query = preg_replace('/\b(mir|dir|uns|man|ich|über|ueber|wegen|aus|von|denn|noch|mehr|etwas|kurz|bitte|du|so|halt|eigentlich|der|die|das|den|dem|des|ein|eine|einen|einem|einer)\b/u', ' ', (string) $query); // Präpositionen $query = preg_replace('/\b(in|im|am|beim|auf|bei|zu)\b/u', ' ', (string) $query); // Tätigkeitsverben $query = preg_replace('/\b(machen|tun|anfangen|sehen|schauen|besuchen|gehen|finden|sein|gibt|kann|könnt|koennt|kannst|will|möchte|moechte)\b/u', ' ', (string) $query); $query = trim((string) preg_replace('/\s+/u', ' ', (string) $query)); $query = trim($query, "?. \t"); // Bezirks-Erweiterung $homeCity = (string) config('services.weather.city', 'Wien'); $ordToNum = [ 'erst' => '1', 'zweit' => '2', 'dritt' => '3', 'viert' => '4', 'fünft' => '5', 'fuenft' => '5', 'sechst' => '6', 'siebt' => '7', 'acht' => '8', 'neunt' => '9', 'zehnt' => '10', 'elft' => '11', 'zwölft' => '12', 'zwoelft' => '12', 'dreizehnt' => '13', 'vierzehnt' => '14', 'fünfzehnt' => '15', 'fuenfzehnt' => '15', 'sechzehnt' => '16', 'siebzehnt' => '17', 'achtzehnt' => '18', 'neunzehnt' => '19', 'zwanzigst' => '20', 'einundzwanzigst' => '21', 'zweiundzwanzigst' => '22', 'dreiundzwanzigst' => '23', ]; // Erkenne Ordnungszahl irgendwo im Query (z.B. "ersten", "erste bezirk") foreach ($ordToNum as $word => $num) { if (preg_match('/\b'.$word.'e?n?\b/u', $query)) { $query = $num.'. Bezirk '.$homeCity; break; } } // "bezirk" alleine ohne Ordinal → Heimat-Bezirk-Liste (1) if ($query === 'bezirk' || preg_match('/^bezirk\s/u', $query)) { $query = '1. '.$query.' '.$homeCity; } // Wenn nach Stripping nur noch Gattungsbegriff übrig ist // ("restaurant", "lokal", "ort", "platz") → letzte Empfehlung gemeint. if (preg_match('/^(restaurant|lokal|ort|platz|laden|geschäft|bezirk|location|cafe|bar)$/u', $query)) { return ['action' => 'place_info', 'query' => '__LAST_RECOMMEND__']; } if (mb_strlen($query) >= 3) { return ['action' => 'place_info', 'query' => $query]; } } // "Zeig mir mehr" → Detail-Aufklapp zur letzten Empfehlung/Place-Info if (preg_match('/^(zeig|zeige)\s+(mir\s+)?(mehr|details|alles)\b/u', $lower) || preg_match('/^(mehr|details)\s+(infos?|details)?$/u', $lower)) { return ['action' => 'place_more']; } // "Wie lange brauche ich" / "Wie weit ist es" → kein neues Routing, // nur die letzte Anzeige nochmal verkünden (Frontend kennt _lastRoute). if (preg_match('/\b(wie\s+(lange|weit)|dauert|wie\s+viele\s+(km|minuten))\b/u', $lower) && ! preg_match('/\b(zu[mr]?|nach|bis|route|navigation)\b/u', $lower)) { return ['action' => 'route_info']; } // Mode-Only-Wechsel: "zu fuß", "mit der u-bahn", "und mit dem rad?" // → Route neu rechnen, gleiches Ziel. $modeOnly = $this->parseModeOnly($lower); if ($modeOnly) { return $modeOnly; } // Route-Anfrage auf dem ROHEN Text prüfen - clean() entfernt sonst "wie/nach/zu/mit". $route = $this->parseRoute($lower); if ($route) { return $route; } // Dynamische Map-Anfrage VOR POI-Detection: explizites "zeig (map/karte) von X" // verhindert Fuzzy-Match wo z.B. "banja" → "bank" wird. if (preg_match('/\b(zeig\w*|öffn\w*|map|karte)\b.*?\b(?:von|der|des|für|mit)\s+(.+)$/u', $lower, $dm) || preg_match('/^(?:karte|map)\s+(.+)$/u', $lower, $dm)) { $q = trim(end($dm)); $q = preg_replace('/\b(mir|bitte|gerne|jetzt|kurz|noch|mal|die|das|den|dem|der)\b/u', ' ', $q); $q = trim((string) preg_replace('/\s+/u', ' ', $q)); // Bekannte Stadt aus config? Dann normaler map-flow if (mb_strlen($q) >= 2) { $cityKeyPre = $this->findCity(' '.$q.' ', $cities); if ($cityKeyPre) { $cityName = $cities[$cityKeyPre]['name'] ?? ucfirst($cityKeyPre); return [ 'action' => 'map', 'city' => $cityKeyPre, 'text' => 'Hier ist '.$cityName.'.', 'data' => $cities[$cityKeyPre], ]; } return ['action' => 'map_dynamic', 'query' => $q]; } } $cityKey = $this->findCity($cleaned, $cities); $category = $this->findCategory($cleaned); $cuisine = $this->findCuisine($cleaned); // Cuisine erwähnt aber keine Kategorie → impliziter restaurant. if ($cuisine && ! $category) { $category = 'restaurant'; } // Transit/U-Bahn → eigene Action (zeichnet Liniennetz auf der Map). if ($this->isTransit($cleaned)) { $key = $cityKey ?? mb_strtolower(config('services.weather.city', 'wien')); $cityData = $cities[$key] ?? null; $cityName = $cityData['name'] ?? ucfirst($key); return [ 'action' => 'transit', 'city' => $key, 'text' => 'Hier ist das öffentliche Verkehrsnetz von '.$cityName.'.', 'data' => $cityData, ]; } // "empfehl mir ein steakhaus" → suche + automatisch eines auswählen. $autoRecommend = (bool) preg_match('/\b(empfehl|empfiehl|vorschl|welch|kannst du eins|gib mir ein|gib mir nen|zeig mir ein|hast du ein)/u', mb_strtolower($message)); if ($cityKey && $category) { return [ 'action' => 'poi', 'category' => $category, 'cuisine' => $cuisine, 'auto_recommend' => $autoRecommend, 'label' => $cuisine ? ucfirst(str_replace('_', '', $cuisine)).' '.self::CATEGORIES[$category]['label'] : self::CATEGORIES[$category]['label'], 'city' => $cityKey, 'data' => $cities[$cityKey], ]; } if ($category) { return [ 'action' => 'poi', 'category' => $category, 'cuisine' => $cuisine, 'auto_recommend' => $autoRecommend, 'label' => $cuisine ? ucfirst(str_replace('_', '', $cuisine)).' '.self::CATEGORIES[$category]['label'] : self::CATEGORIES[$category]['label'], 'city' => null, 'data' => null, ]; } if ($cityKey) { $cityName = $cities[$cityKey]['name'] ?? ucfirst($cityKey); $phrases = [ 'Hier ist '.$cityName.'.', 'Schau, '.$cityName.'.', 'Bitte, '.$cityName.'.', $cityName.' für dich.', 'Da haben wir '.$cityName.'.', 'Da ist '.$cityName.'.', 'Voilà, '.$cityName.'.', ]; return [ 'action' => 'map', 'city' => $cityKey, 'text' => $phrases[array_rand($phrases)], 'data' => $cities[$cityKey], ]; } // Dynamische Map-Anfrage: "zeig (mir die) (karte/map) von X" - X unbekannt → Google Geocoding if (preg_match('/\b(zeig\w*|öffn\w*|map|karte)\b.*?\b(?:von|der|des|für|mit)\s+(.+)$/u', $lower, $dm) || preg_match('/^(?:karte|map)\s+(.+)$/u', $lower, $dm)) { $q = trim(end($dm)); $q = preg_replace('/\b(mir|bitte|gerne|jetzt|kurz|noch|mal)\b/u', ' ', $q); $q = trim((string) preg_replace('/\s+/u', ' ', $q)); if (mb_strlen($q) >= 2) { return ['action' => 'map_dynamic', 'query' => $q]; } } // Template / Website erstellen if (preg_match('/\b(erstell|baue?|mach|generier|erzeug)\w*\s+(?:mir\s+)?(?:ein(?:e|en)?\s+)?(?:website|webseite|html|template|seite|landingpage|landing\s*page)\b/u', $lower) || preg_match('/\b(?:website|webseite|template|seite)\s+(?:für|erstell|baue?n?)\b/u', $lower)) { // Alles nach dem Trigger-Wort als Beschreibung extrahieren $desc = preg_replace( '/^.*?(?:website|webseite|html|template|seite|landingpage)[- ]*/u', '', $lower ); $desc = trim(preg_replace('/\b(für|ein(?:e|en)?|eine?\s+seite|erstell\w*|bitte|mir)\b/u', ' ', $desc)); $desc = trim((string) preg_replace('/\s+/u', ' ', $desc)); return [ 'action' => 'create_template', 'description' => $desc !== '' ? $desc : 'allgemeine Webseite', 'text' => 'Einen Moment - ich erstelle das Template gerade.', ]; } // Desk-Panel: Karte hochheben / zurücklegen if (preg_match('/\b(zurücklegen|zurücklleg\w*|leg\s+\w{0,15}\s*zurück)\b/u', $lower)) { return ['action' => 'desk_down']; } $deskCards = [ 'photo' => ['foto', 'fotos', 'bild', 'bilder', 'galerie'], 'summary' => ['beschreibung', 'zusammenfassung', 'info', 'infos', 'details', 'erklärung'], 'hours' => ['öffnungszeiten', 'öffnungszeit'], 'phone' => ['telefonnummer', 'telefon'], 'rating' => ['bewertung', 'sterne'], 'address' => ['adresse', 'anschrift'], 'website' => ['website', 'webseite', 'homepage'], ]; foreach ($deskCards as $type => $keywords) { foreach ($keywords as $kw) { if (str_contains($lower, $kw)) { return ['action' => 'desk_card', 'card' => $type]; } } } // Brain / Gedächtnis-Visualisierung if (preg_match('/\b(gehirn|gedächtnis|gedaechtnis|memory|brain|was weißt|was weisst|was du weißt|was du weisst)\b/u', $lower)) { return ['action' => 'brain', 'text' => 'Hier ist mein Gedächtnis.']; } return null; } /** * Mode-Only: "zu fuß", "mit der u-bahn", "und mit dem rad" - kein neues Ziel, * Route mit dem alten Ziel und neuem Mode neu rechnen. */ protected function parseModeOnly(string $haystack): ?array { // Wenn ein Ortsname (zum/zur/nach X / dahin) drinsteckt → das ist eine VOLLE // Routenanfrage, nicht Mode-Only. Lass parseRoute() ran. // ABER: "zu fuß" alleine ist Mode-Only, daher "zu" nur ausschließen wenn // KEIN Mode-Keyword direkt folgt. if (preg_match('/\b(?:nach|zum|zur|bis|hinkommen|hinkomme|dahin|dorthin)\b/u', $haystack)) { return null; } if (preg_match('/\bzu\s+(?!fuss|fuß|hause)\S/u', $haystack)) { return null; } $cleaned = trim((string) preg_replace( '/\b(und|aber|wie|lang|lange|wie\s+lang|wie\s+lange|dauert|jetzt|bitte|kannst du|kann ich)\b/u', ' ', $haystack, )); $cleaned = trim((string) preg_replace('/\s+/u', ' ', $cleaned)); // Sehr kurze, mode-spezifische Eingabe: "zu fuß", "mit der ubahn", "öffi", "rad" if (mb_strlen($cleaned) > 50) { return null; } $mode = null; if (preg_match('/\b(zu fuss|zu fuß|fussweg|fußweg|gehen|gehe|laufen|spaziere?n|fuss|fuß)\b/u', $cleaned)) { $mode = 'walking'; } elseif (preg_match('/\b(rad|fahrrad|bike|radeln)\b/u', $cleaned)) { $mode = 'bicycling'; } elseif (preg_match('/\b(öffi|öffis|ubahn|u-bahn|sbahn|s-bahn|öpnv|oeffis|metro|tram|bus|öffentlich)\b/u', $cleaned)) { $mode = 'transit'; } elseif (preg_match('/\b(auto|fahren|fahrend|wagen)\b/u', $cleaned)) { $mode = 'driving'; } if (! $mode) { return null; } return [ 'action' => 'route', 'to' => '__LAST_DESTINATION__', 'mode' => $mode, ]; } /** * Erkennt Routenanfragen + extrahiert Origin/Destination/Mode. * "route zum stephansdom" → to=stephansdom, from=null (=current) * "wie komme ich zu fuß zum prater" → to=prater, mode=walking * "von oper nach schönbrunn" → from=oper, to=schönbrunn * "navigation mit dem rad zum karlsplatz" → to=karlsplatz, mode=bicycling */ protected function parseRoute(string $haystack): ?array { $padded = ' '.$haystack.' '; $hasRouteWord = str_contains($padded, ' route ') || str_contains($padded, ' routenplanung ') || str_contains($padded, ' navigation ') || str_contains($padded, ' navigiere ') || str_contains($padded, ' wegbeschreibung ') || str_contains($padded, ' weg ') || str_contains($padded, ' dahin ') || str_contains($padded, ' dorthin ') || str_contains($padded, ' hinkommen ') || str_contains($padded, ' hinkomme ') || (bool) preg_match('/\bwie\s+(komme|komm|kommen|fahre|fahr|fahren|geh|gehe|gehen)\b/u', $haystack) || (bool) preg_match('/\bzeig.{1,20}\bweg\b/u', $haystack) || (bool) preg_match('/\bbring(e|st)?\s+mich\b/u', $haystack); // "von X nach Y" oder "von X zu/zum Y" - immer route $hasFromTo = (bool) preg_match('/\bvon\s+\S+.+?\s+(?:nach|zu[mr]?)\s+/u', $haystack); // Mode-Keyword + Zielangabe → route (z.B. "zu fuß zum prater", "mit dem rad zum bahnhof") $hasModeKeyword = (bool) preg_match('/\b(zu fuss|zu fuß|fussweg|fußweg|fahrrad|rad|öffi|öffis|öpnv|ubahn|u-bahn)\b/u', $haystack); $hasGoal = (bool) preg_match('/\b(?:nach|zu[mr]?|bis)\s+\S+/u', $haystack); if (! $hasRouteWord && ! $hasFromTo && ! ($hasModeKeyword && $hasGoal)) { return null; } // Mode-Wörter UND Pronomen entfernen, bevor wir from/to extrahieren - // sonst landet "mich" / "mir" / "uns" / "fuß" mit im Ziel. $extractText = preg_replace( '/\b(zu fuss|zu fuß|fussweg|fußweg|mit dem rad|mit dem fahrrad|fahrrad|rad|mit dem auto|mit den öffis?|öffis?|öpnv|ubahn|u-bahn|sbahn|s-bahn|tram|metro|bahn|bus)\b/u', ' ', $haystack, ); $extractText = preg_replace('/\b(mich|mir|uns|du|kannst|bitte)\b/u', ' ', (string) $extractText); $extractText = trim((string) preg_replace('/\s+/u', ' ', (string) $extractText)); // Travel mode $mode = 'driving'; if (preg_match('/\b(zu fuss|zu fuß|fussweg|fußweg|zu fu[sß]|gehen|gehe|laufen|spaziere?n)\b/u', $haystack)) { $mode = 'walking'; } elseif (preg_match('/\b(rad|fahrrad|bike|radeln)\b/u', $haystack)) { $mode = 'bicycling'; } elseif (preg_match('/\b(öffi|öffis|ubahn|u-bahn|sbahn|s-bahn|bus|tram|öpnv|oeffis|metro|bahn)\b/u', $haystack)) { $mode = 'transit'; } // From/To extraction $from = null; $to = null; // "dahin/dorthin/hin" zuerst checken - egal wo im Text -> letzte Empfehlung. // Sonst würde "route mich dahin" → to="mich dahin" extrahieren. if (preg_match('/\b(dahin|dorthin|hinkommen|hinkomme)\b/u', $extractText)) { $to = '__LAST_RECOMMEND__'; } elseif (preg_match('/\bvon\s+(?:der|dem|den|des|einer|einem)?\s*(.+?)\s+(?:nach|zur?m?)\s+(.+)$/iu', $extractText, $m)) { $from = trim($m[1]); $to = trim($m[2]); } elseif (preg_match('/\b(?:nach|zur?m?|bis)\s+(?:der|dem|den|des|einer|einem)?\s*(.+)$/iu', $extractText, $m)) { $to = trim($m[1]); } elseif (preg_match('/\b(route|routenplanung|navigation|wegbeschreibung)\s+(.+)$/iu', $extractText, $m)) { $to = trim($m[2]); } if (! $to) { return null; } // Falls "dahin" im extrahierten $to landete (z.B. "der dahin"), trotzdem auf Last-Recommend mappen. if (preg_match('/\b(dahin|dorthin)\b/u', $to)) { $to = '__LAST_RECOMMEND__'; } // Stop-Words am Ende abschneiden $to = preg_replace('/\b(per|mit|zu)\s+(fuss|fuß|rad|fahrrad|öffi.*|ubahn|bahn|auto)\b.*$/iu', '', $to); $to = trim(preg_replace('/\s+/', ' ', $to)); if ($from !== null) { $from = trim(preg_replace('/\s+/', ' ', $from)); } if ($to === '') { return null; } return [ 'action' => 'route', 'from' => $from, 'to' => $to, 'mode' => $mode, ]; } protected function isTransit(string $haystack): bool { $padded = ' '.$haystack.' '; $keywords = [ 'ubahn', 'u-bahn', 'ubn', 'metro', 'subway', 'sbahn', 's-bahn', 'sbn', 'tram', 'strassenbahn', 'straßenbahn', 'bim', 'bus', 'busse', 'busnetz', 'verkehrsnetz', 'verkehr', 'oeffis', 'öffis', 'liniennetz', 'haltestelle', 'haltestellen', 'transit', ]; foreach ($keywords as $kw) { if (str_contains($padded, ' '.$kw.' ')) { return true; } } return false; } protected function findCity(string $haystack, array $cities): ?string { $keys = array_keys($cities); usort($keys, fn ($a, $b) => mb_strlen($b) <=> mb_strlen($a)); $padded = ' '.$haystack.' '; foreach ($keys as $key) { if (str_contains($padded, ' '.$key.' ')) { return $key; } } $words = array_filter(explode(' ', $haystack), fn ($w) => mb_strlen($w) >= 3); foreach ($words as $word) { foreach ($keys as $key) { if ($key === $word) { return $key; } } } return null; } protected function findCategory(string $haystack): ?string { $padded = ' '.$haystack.' '; // 1. Exact match auf Wortgrenzen foreach (self::CATEGORIES as $cat => $cfg) { foreach ($cfg['keywords'] as $kw) { if (str_contains($padded, ' '.$kw.' ')) { return $cat; } } } // 2. Fuzzy: Stadt-Wörter ausschließen, gegen alle Kategorie-Keywords prüfen. $words = $this->fuzzyWords($haystack); foreach ($words as $word) { $wn = $this->normalize($word); $len = mb_strlen($wn); $maxDist = $this->fuzzyThreshold($len); foreach (self::CATEGORIES as $cat => $cfg) { foreach ($cfg['keywords'] as $kw) { $kn = $this->normalize($kw); if (abs(mb_strlen($kn) - $len) > $maxDist) { continue; } if (levenshtein($wn, $kn) <= $maxDist) { return $cat; } } } } // Substring-Suffix: "steaklokal" / "sushibar" / "burgerrestorant" → category aus Endung. $allWords = explode(' ', $haystack); foreach ($allWords as $word) { if (mb_strlen($word) < 6) { continue; } $wn = $this->normalize($word); foreach (self::CATEGORIES as $cat => $cfg) { foreach ($cfg['keywords'] as $kw) { $kn = $this->normalize($kw); if (mb_strlen($kn) < 4) { continue; } if (str_contains($wn, $kn)) { return $cat; } } } } return null; } protected function findCuisine(string $haystack): ?string { $padded = ' '.$haystack.' '; foreach (self::CUISINES as $kw => $tag) { if (str_contains($padded, ' '.$kw.' ')) { return $tag; } } $words = $this->fuzzyWords($haystack); foreach ($words as $word) { $wn = $this->normalize($word); $len = mb_strlen($wn); $maxDist = $this->fuzzyThreshold($len); foreach (self::CUISINES as $kw => $tag) { $kn = $this->normalize($kw); if (abs(mb_strlen($kn) - $len) > $maxDist) { continue; } if (levenshtein($wn, $kn) <= $maxDist) { return $tag; } } } // Letzte Stufe: Substring in Kompositum (z.B. "steaklokal", "sushibar", "burgerladen"). // Nur Cuisine-Keywords ≥ 4 Zeichen, sonst matcht "thai" in "thailand" usw. $allWords = explode(' ', $haystack); foreach ($allWords as $word) { if (mb_strlen($word) < 6) { continue; // Compound-Suche nur bei längeren Wörtern } $wn = $this->normalize($word); foreach (self::CUISINES as $kw => $tag) { $kn = $this->normalize($kw); if (mb_strlen($kn) < 4) { continue; } if (str_contains($wn, $kn)) { return $tag; } } } return null; } /** * Wörter für Fuzzy-Match: Stadtnamen + zu kurze Wörter raus, * sonst matchen "berlin"→"benzin" und ähnliche Kollisionen. */ protected function fuzzyWords(string $haystack): array { $cities = config('cities', []); $cityKeys = array_map(fn ($k) => $this->normalize($k), array_keys($cities)); // Multi-Word City-Keys einzeln auch sperren foreach (array_keys($cities) as $key) { foreach (explode(' ', $key) as $part) { if (mb_strlen($part) >= 3) { $cityKeys[] = $this->normalize($part); } } } $cityKeys = array_unique($cityKeys); return array_values(array_filter( explode(' ', $haystack), function ($w) use ($cityKeys) { if (mb_strlen($w) < 5) { return false; } $n = $this->normalize($w); // Wenn Wort einer Stadt entspricht oder ähnelt → raus foreach ($cityKeys as $ck) { if ($n === $ck) { return false; } // Auch nahe Stadtnamen ausschließen damit "berln"→"berlin" // nicht versehentlich als Kategorie missgedeutet wird if (mb_strlen($ck) >= 4 && levenshtein($n, $ck) <= 2) { return false; } } return true; } )); } protected function lev(string $a, string $b): int { return levenshtein(mb_strtolower($a), mb_strtolower($b)); } protected function fuzzyThreshold(int $len): int { // Großzügigere Schwelle: erlaubt Transpositionen wie "staek"→"steak" (dist 2). if ($len <= 4) { return 1; } if ($len <= 7) { return 2; } return 3; } /** * Umlaute + Akzente entfernen für Levenshtein (PHP rechnet byte-weise). */ protected function normalize(string $s): string { return strtr(mb_strtolower($s), [ 'ä' => 'a', 'ö' => 'o', 'ü' => 'u', 'ß' => 'ss', 'á' => 'a', 'à' => 'a', 'â' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'í' => 'i', 'ì' => 'i', 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ú' => 'u', 'ù' => 'u', 'ç' => 'c', 'ñ' => 'n', ]); } protected function clean(string $message): string { $text = mb_strtolower($message); $text = preg_replace('/[.,!?;:]/u', ' ', $text); $tokens = preg_split('/\s+/u', $text) ?: []; $tokens = array_values(array_filter( $tokens, fn ($t) => $t !== '' && ! in_array($t, self::FILLER, true), )); return implode(' ', $tokens); } }