fox/app/Services/IntentDetector.php

864 lines
36 KiB
PHP

<?php
namespace App\Services;
/**
* Intent-Erkennung mit Stadt + Kategorie.
*
* Erkennt z.B.:
* "Wien" → action=map, city=wien
* "Restaurants in Wien" → action=poi, category=restaurant, city=wien
* "Apotheke in Berlin" → action=poi, category=pharmacy, city=berlin
* "Krankenhaus Salzburg" → action=poi, category=hospital, city=salzburg
* "Behörden in Wien" → action=poi, category=government, city=wien
*/
class IntentDetector
{
protected const FILLER = [
'zeig', 'zeige', 'mir', 'mal', 'bitte', 'die', 'der', 'das', 'den', 'dem',
'karte', 'von', 'nach', 'zu', 'in', 'über', 'auf', 'an', 'bei', 'um', 'für',
'wie', 'was', 'ist', 'sind', 'gibt', 'es', 'ich', 'will', 'möchte', 'würde',
'gerne', 'flieg', 'fliege', 'geh', 'gehe', 'schau', 'show', 'me', 'fly', 'to',
'go', 'navigate', 'open', 'map', 'eine', 'einen', 'ein', 'the', 'a', 'an',
'und', 'oder', 'aber', 'mit', 'ohne', 'auch', 'noch', 'jetzt',
'finde', 'finden', 'such', 'suche', 'wo', 'where',
];
/**
* Cuisine-Mapping für Restaurants: deutsches Wort → OSM cuisine-Tag.
*/
protected const CUISINES = [
'italienisch' => '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);
}
}