query( "SELECT COUNT(*) FROM staff s INNER JOIN staff_availability sa ON sa.staff_id = s.id WHERE s.is_active = 1 AND sa.status = 'available'" ); return (int) $stmt->fetchColumn() > 0; } /** Darf der Assistent jetzt Kunden ansprechen? */ function assistant_active(PDO $pdo): bool { if (!assistant_enabled()) return false; if (get_setting('helpdesk_status', 'online') === 'offline') return false; if (get_setting('assistant_mode', 'offline_only') === 'always') return true; if (get_setting('live_chat_enabled', '0') !== '1') return true; // Live-Chat ausgeschaltet return !assistant_staff_available($pdo); // Live-Chat an, aber niemand erreichbar } // ── Suche ──────────────────────────────────────────────────────────────────── const ASSISTANT_STOPWORDS = [ 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen', 'einem', 'einer', 'und', 'oder', 'aber', 'ich', 'du', 'wir', 'ihr', 'sie', 'er', 'es', 'ist', 'sind', 'war', 'hat', 'haben', 'habe', 'kann', 'koennen', 'kannst', 'wie', 'was', 'wo', 'wann', 'warum', 'wer', 'wird', 'werden', 'mein', 'meine', 'meinen', 'meinem', 'mir', 'mich', 'dir', 'dich', 'auch', 'nicht', 'kein', 'keine', 'bitte', 'hallo', 'guten', 'tag', 'danke', 'mit', 'fuer', 'von', 'zu', 'zum', 'zur', 'im', 'in', 'an', 'auf', 'aus', 'bei', 'nach', 'ueber', 'um', 'so', 'wenn', 'dass', 'da', 'dort', 'hier', 'noch', 'nur', 'schon', 'mal', 'gibt', 'moechte', 'moechten', 'wuerde', 'will', 'brauche', 'hilfe', 'frage', 'bekomme', 'bekommen', 'bekommt', 'erhalte', 'erhalten', 'kriege', 'kriegen', 'finde', 'finden', 'moechtest', 'gerne', 'gern', 'eigentlich', 'denn', 'bin', 'bist', 'habt', 'hab', 'gibts', 'wieso', 'weshalb', 'welche', 'welcher', 'welches', 'muss', 'soll', 'sollen', 'man', 'etwas', 'mehr', 'sehr', 'ganz', 'dann', 'doch', ]; /** Kleinbuchstaben ohne Umlaute und Sonderzeichen, Wörter getrennt. */ function assistant_normalize(string $s): string { $s = mb_strtolower($s, 'UTF-8'); $s = strtr($s, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'à' => 'a', 'á' => 'a']); return trim((string) preg_replace('/[^a-z0-9]+/', ' ', $s)); } /** Grobe Wortstamm-Bildung, damit "Passwörter" und "Passwort" oder "Rechnungen" und "Rechnung" zusammenpassen. */ function assistant_stem(string $w): string { foreach (['ungen', 'ung', 'heit', 'keit', 'en', 'er', 'es', 'em', 'e', 'n', 's'] as $suffix) { $len = strlen($w); if ($len >= strlen($suffix) + 4 && str_ends_with($w, $suffix)) return substr($w, 0, $len - strlen($suffix)); } return $w; } /** * Gleichbedeutende Wörter (normalisiert, ohne Umlaute): Frage und Artikel müssen nicht dieselben Worte benutzen, z. B. „Zugang zum Discord“ * und „Wie trete ich dem Server bei?“. Das erste Wort einer Gruppe steht für alle. */ const ASSISTANT_SYNONYMS = [ ['beitreten', 'beitritt', 'beitrete', 'trete', 'tritt', 'zugang', 'zutritt', 'einladung', 'einladungslink', 'invite', 'join', 'joinen', 'anschliessen', 'eintreten', 'teilnehmen', 'mitmachen'], ['loeschen', 'entfernen', 'entfernt', 'geloescht', 'loesche'], ['fehler', 'problem', 'probleme', 'stoerung', 'bug', 'absturz', 'abstuerzen', 'kaputt', 'funktioniert'], ['gesperrt', 'sperre', 'sperren', 'bann', 'ban', 'gebannt', 'blockiert'], ['kontakt', 'erreichen', 'erreiche', 'ansprechen', 'ansprechpartner', 'schreiben'], ['kosten', 'preis', 'preise', 'gebuehr', 'gebuehren', 'bezahlen', 'kostenlos', 'gratis'], ['regel', 'regeln', 'richtlinie', 'richtlinien', 'verhaltensregeln'], ['senden', 'einsenden', 'einreichen', 'hochladen', 'schicken', 'uebermitteln', 'einschicken', 'veroeffentlichen'], ['passwort', 'kennwort', 'zugangsdaten', 'anmeldedaten', 'login'], ]; /** * Vom Admin gepflegte Gruppen (Einstellung "assistant_synonyms": eine Gruppe je Zeile, Wörter durch Komma getrennt). * * @return array normalisierte Einzelwörter je Gruppe */ function assistant_custom_synonyms(?string $text = null): array { $text ??= (string) get_setting('assistant_synonyms', ''); $groups = []; foreach (preg_split('/\R/', $text) ?: [] as $line) { $words = []; foreach (explode(',', $line) as $w) { $n = assistant_normalize($w); if ($n !== '' && !str_contains($n, ' ') && strlen($n) >= 3) $words[$n] = true; } if (count($words) >= 2) $groups[] = array_slice(array_keys($words), 0, 20); if (count($groups) >= 100) break; } return $groups; } /** @return array Wortstamm => Stamm des Gruppenworts (eigene Gruppen überschreiben die eingebauten) */ function assistant_synonym_map(): array { static $map = null; if ($map === null) { $map = []; foreach (array_merge(ASSISTANT_SYNONYMS, assistant_custom_synonyms()) as $group) { $head = assistant_stem($group[0]); foreach ($group as $w) $map[assistant_stem($w)] = $head; } } return $map; } /** @return string[] eindeutige Wortstämme ohne Füllwörter */ function assistant_tokens(string $text): array { $out = []; $syn = assistant_synonym_map(); foreach (explode(' ', assistant_normalize($text)) as $w) { if (strlen($w) < 3 || in_array($w, ASSISTANT_STOPWORDS, true)) continue; $s = assistant_stem($w); $out[$syn[$s] ?? $s] = true; } return array_keys($out); } /** Text aus HTML, gekürzt an einer Wort- bzw. Satzgrenze. */ function assistant_excerpt(string $html, int $max = 600): string { $text = mail_html_to_text($html); if (mb_strlen($text) <= $max) return $text; $cut = mb_substr($text, 0, $max); $dot = max((int) mb_strrpos($cut, '. '), (int) mb_strrpos($cut, "\n")); if ($dot > $max * 0.5) return rtrim(mb_substr($cut, 0, $dot + 1)) . ' …'; $space = (int) mb_strrpos($cut, ' '); return rtrim($space > 0 ? mb_substr($cut, 0, $space) : $cut) . ' …'; } /** * Sucht die beste Antwort auf eine Frage: zuerst die im Backend hinterlegten Antworten (Stichwörter), dann die Artikel der * Wissensdatenbank (Titel zählt mehr als Text). Gibt null zurück, wenn nichts ausreichend passt. * * @return array{source:string,text:string,title:string,link:?string,id:int,others:array}|null */ function assistant_search(PDO $pdo, string $question, bool $countHit = true): ?array { $q = assistant_tokens($question); if (!$q) return null; $need = max(1, (int) ceil(count($q) / 2)); // mindestens die Hälfte der Suchwörter muss vorkommen $candidates = []; // Zusätzlich im Backend hinterlegte Antworten try { foreach ($pdo->query('SELECT id, title, keywords, answer FROM assistant_answers WHERE is_active = 1')->fetchAll() as $a) { $words = array_flip(assistant_tokens($a['title'] . ' ' . ($a['keywords'] ?? ''))); $kw = array_flip(assistant_tokens((string) ($a['keywords'] ?? ''))); $hit = 0; $kwHit = 0; foreach ($q as $t) { if (isset($words[$t])) $hit++; if (isset($kw[$t])) $kwHit++; } // Ein ausdrücklich hinterlegtes Stichwort genügt (z. B. „Discord“); Wörter nur aus dem Titel müssen zur Hälfte passen if ($hit >= $need || $kwHit >= 1) $candidates[] = ['score' => 5.0 * max($hit, $kwHit) / count($q) + 100 + $kwHit, 'source' => 'custom', 'id' => (int) $a['id'], 'title' => (string) $a['title'], 'text' => trim((string) $a['answer']), 'link' => null]; } } catch (Throwable $e) { // Tabelle fehlt (Datenbank noch nicht aktualisiert): nur die Wissensdatenbank } // Wissensdatenbank (nur wenn sie im Kundenportal eingeschaltet ist) if (get_setting('kb_enabled', '1') === '1') { foreach ($pdo->query('SELECT a.id, a.title, a.slug, a.body, c.name AS category FROM kb_articles a LEFT JOIN kb_categories c ON c.id = a.category_id WHERE a.is_published = 1 ORDER BY a.id DESC LIMIT 500')->fetchAll() as $a) { $titleWords = array_flip(assistant_tokens($a['title'] . ' ' . (string) ($a['category'] ?? ''))); // die Kategorie (z. B. „Discord“) zählt wie der Titel $bodyWords = array_flip(assistant_tokens(mb_substr(strip_tags((string) $a['body']), 0, 20000))); $score = 0.0; $hit = 0; foreach ($q as $t) { if (isset($titleWords[$t])) { $score += 4; $hit++; } elseif (isset($bodyWords[$t])) { $score += 1.5; $hit++; } } if ($hit >= $need && $score >= 2.5) { $candidates[] = ['score' => $score / count($q), 'source' => 'kb', 'id' => (int) $a['id'], 'title' => (string) $a['title'], 'text' => assistant_excerpt((string) $a['body']), 'link' => '/kb_article.php?slug=' . rawurlencode((string) $a['slug'])]; } } } if (!$candidates) return null; usort($candidates, fn($a, $b) => $b['score'] <=> $a['score']); $best = $candidates[0]; $others = []; foreach (array_slice($candidates, 1) as $c) { // nur wirklich ähnlich gute Artikel als weiterer Hinweis (nicht jeder schwache Treffer) $strong = $best['source'] === 'kb' ? $c['score'] >= $best['score'] * 0.75 : $c['score'] >= 3.0; if ($c['source'] === 'kb' && $strong && count($others) < 2) $others[] = ['title' => $c['title'], 'link' => $c['link']]; } if ($countHit && $best['source'] === 'custom') { try { $pdo->prepare('UPDATE assistant_answers SET hits = hits + 1 WHERE id = ?')->execute([$best['id']]); } catch (Throwable $e) { /* Zähler ist unwichtig */ } } return ['source' => $best['source'], 'text' => $best['text'], 'title' => $best['title'], 'link' => $best['link'], 'id' => $best['id'], 'others' => $others]; } // ── Protokoll der Fragen ───────────────────────────────────────────────────── /** Ergebnisse im Protokoll (Schlüssel => Beschriftung). */ function assistant_log_results(): array { return ['custom' => 'Meine Antwort gefunden', 'kb' => 'Wissensdatenbank', 'none' => 'Keine Antwort', 'ticket_request' => 'Wollte ein Ticket', 'ticket_created' => 'Ticket erstellt']; } /** Wie viele Tage Fragen aufbewahrt werden (0 = es wird nichts protokolliert). */ function assistant_log_days(): int { return max(0, min(3650, (int) get_setting('assistant_log_days', '90'))); } /** * Hält eine Frage und das Ergebnis fest. Es werden bewusst nur Frage, Ergebnis und Treffer gespeichert (keine Adresse, kein Name); * Fehler dabei dürfen den Assistenten nie stören. Alte Einträge werden nebenbei gelöscht. */ function assistant_log_write(PDO $pdo, string $question, string $result, ?array $match = null, ?string $ticketNumber = null): ?int { $days = assistant_log_days(); if ($days === 0) return null; try { $pdo->prepare('INSERT INTO assistant_log (question, result, source, matched_id, matched_title, ticket_number, created_at) VALUES (?,?,?,?,?,?,?)') ->execute([mb_substr($question, 0, 300), $result, $match['source'] ?? null, isset($match['id']) ? (int) $match['id'] : null, isset($match['title']) ? mb_substr((string) $match['title'], 0, 255) : null, $ticketNumber, db_now()]); $id = (int) $pdo->lastInsertId(); if (random_int(1, 25) === 1) assistant_log_cleanup($pdo); return $id > 0 ? $id : null; } catch (Throwable $e) { // Tabelle fehlt (Datenbank noch nicht aktualisiert) oder ähnliches: nicht schlimm return null; } } /** Auswahl im Filter des Fragen-Logs: Ergebnisse plus die Rückmeldung der Kunden. */ function assistant_log_filters(): array { return assistant_log_results() + ['helpful' => 'Hat geholfen', 'not_helpful' => 'Hat nicht geholfen']; } /** Löscht Einträge, die älter als die Aufbewahrungszeit sind. @return int Anzahl gelöschter Einträge */ function assistant_log_cleanup(PDO $pdo): int { $days = assistant_log_days(); $cut = date('Y-m-d H:i:s', time() - max(1, $days) * 86400); $stmt = $pdo->prepare('DELETE FROM assistant_log WHERE created_at < ?'); $stmt->execute([$cut]); return $stmt->rowCount(); } /** * Liest das Protokoll mit Filtern: result (Schlüssel aus assistant_log_results), period (today|7|30|all), q (Suchtext). * * @return array{rows:array,total:int,pages:int,page:int} */ function assistant_log_list(PDO $pdo, array $filter, int $page = 1, int $perPage = 50): array { $where = []; $params = []; $result = (string) ($filter['result'] ?? ''); if ($result === 'helpful') { $where[] = 'helpful = 1'; } elseif ($result === 'not_helpful') { $where[] = 'helpful = 0'; } elseif (array_key_exists($result, assistant_log_results())) { $where[] = 'result = ?'; $params[] = $result; } $since = match ((string) ($filter['period'] ?? 'all')) { 'today' => date('Y-m-d 00:00:00'), '7' => date('Y-m-d H:i:s', time() - 7 * 86400), '30' => date('Y-m-d H:i:s', time() - 30 * 86400), default => null, }; if ($since !== null) { $where[] = 'created_at >= ?'; $params[] = $since; } $q = trim((string) ($filter['q'] ?? '')); if ($q !== '') { $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_substr($q, 0, 100)) . '%'; $where[] = "(question LIKE ? ESCAPE '\\' OR matched_title LIKE ? ESCAPE '\\')"; array_push($params, $like, $like); } $sqlWhere = $where ? ' WHERE ' . implode(' AND ', $where) : ''; $count = $pdo->prepare('SELECT COUNT(*) FROM assistant_log' . $sqlWhere); $count->execute($params); $total = (int) $count->fetchColumn(); $pages = max(1, (int) ceil($total / $perPage)); $page = max(1, min($pages, $page)); $stmt = $pdo->prepare('SELECT * FROM assistant_log' . $sqlWhere . ' ORDER BY id DESC LIMIT ' . $perPage . ' OFFSET ' . (($page - 1) * $perPage)); $stmt->execute($params); return ['rows' => $stmt->fetchAll(), 'total' => $total, 'pages' => $pages, 'page' => $page]; } /** Kennzahlen des Protokolls im gewählten Zeitraum: Anzahl je Ergebnis. @return array */ function assistant_log_stats(PDO $pdo, string $period = 'all'): array { $stats = array_fill_keys(array_keys(assistant_log_filters()), 0); $since = match ($period) { 'today' => date('Y-m-d 00:00:00'), '7' => date('Y-m-d H:i:s', time() - 7 * 86400), '30' => date('Y-m-d H:i:s', time() - 30 * 86400), default => null }; $stmt = $pdo->prepare('SELECT result, COUNT(*) AS n FROM assistant_log' . ($since !== null ? ' WHERE created_at >= ?' : '') . ' GROUP BY result'); $stmt->execute($since !== null ? [$since] : []); foreach ($stmt->fetchAll() as $r) $stats[$r['result']] = (int) $r['n']; $fb = $pdo->prepare('SELECT helpful, COUNT(*) AS n FROM assistant_log WHERE helpful IS NOT NULL' . ($since !== null ? ' AND created_at >= ?' : '') . ' GROUP BY helpful'); $fb->execute($since !== null ? [$since] : []); $stats['helpful'] = 0; $stats['not_helpful'] = 0; foreach ($fb->fetchAll() as $r) $stats[(int) $r['helpful'] === 1 ? 'helpful' : 'not_helpful'] = (int) $r['n']; return $stats; } /** * Häufig gestellte Fragen ohne Antwort (gleiche Fragen zusammengefasst, am häufigsten zuerst): Vorlage für neue Antworten. * * @return array */ function assistant_log_unanswered(PDO $pdo, string $period = 'all', int $limit = 10): array { $since = match ($period) { 'today' => date('Y-m-d 00:00:00'), '7' => date('Y-m-d H:i:s', time() - 7 * 86400), '30' => date('Y-m-d H:i:s', time() - 30 * 86400), default => null }; $stmt = $pdo->prepare("SELECT question, created_at FROM assistant_log WHERE result = 'none'" . ($since !== null ? ' AND created_at >= ?' : '') . ' ORDER BY id DESC LIMIT 2000'); $stmt->execute($since !== null ? [$since] : []); $groups = []; foreach ($stmt->fetchAll() as $r) { $key = implode(' ', assistant_tokens($r['question'])) ?: assistant_normalize($r['question']); if ($key === '') continue; if (!isset($groups[$key])) $groups[$key] = ['question' => $r['question'], 'n' => 0, 'last' => $r['created_at']]; $groups[$key]['n']++; } usort($groups, fn($a, $b) => [$b['n'], $b['last']] <=> [$a['n'], $a['last']]); return array_slice(array_values($groups), 0, $limit); } /** Öffentliche Hilfethemen als Auswahl für die Ticket-Erstellung. @return array */ function assistant_help_topics(PDO $pdo): array { $out = []; foreach ($pdo->query('SELECT id, name FROM help_topics WHERE is_active = 1 AND is_public = 1 ORDER BY sort_order, name')->fetchAll() as $t) { $out[] = ['id' => (int) $t['id'], 'name' => (string) $t['name']]; } return $out; } /** Texte des Assistenten (aus den Einstellungen, mit Vorgaben). */ function assistant_texts(): array { return [ 'name' => trim((string) get_setting('assistant_name', '')) ?: 'Digitaler Assistent', 'greeting' => trim((string) get_setting('assistant_greeting', '')) ?: 'Hallo! Ich bin der digitale Assistent. Gerade ist niemand direkt erreichbar, aber ich kann Ihre Fragen beantworten oder ein Ticket für Sie erstellen. Wie kann ich helfen?', 'no_answer' => trim((string) get_setting('assistant_no_answer', '')) ?: 'Dazu habe ich leider keine passende Antwort. Soll ich ein Ticket für Sie erstellen? Unser Team meldet sich dann per E-Mail.', ]; }