864 lines
36 KiB
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);
|
|
}
|
|
}
|