Files
deskhub/DeskHub-Webseite/includes/assistant.php
T
2026-10-23 17:22:17 +00:00

348 lines
18 KiB
PHP

<?php
/**
* includes/assistant.php
*
* Digitaler Assistent für das Kundenportal: Er beantwortet Fragen aus der Wissensdatenbank und aus zusätzlich im Backend hinterlegten
* Antworten (Verwaltung → Digitaler Assistent). Findet er nichts, bietet er an, ein Ticket zu erstellen, und fragt dafür alles Nötige ab.
* Er ist nur aktiv, wenn der Live-Chat ausgeschaltet oder gerade niemand erreichbar ist (oder in der Einstellung "immer", zum Testen).
*
* Die Antworten werden ohne externen KI-Dienst gesucht (Stichwort-Treffer in Titel und Text); es verlassen keine Kundenfragen den Server.
*/
require_once __DIR__ . '/mail_text.php';
function assistant_enabled(): bool
{
return get_setting('assistant_enabled', '0') === '1';
}
/** Ist gerade mindestens ein Mitarbeiter als "Verfügbar" gemeldet (wie bei der Anzeige im Live-Chat)? */
function assistant_staff_available(PDO $pdo): bool
{
staff_run_auto_offline($pdo);
$stmt = $pdo->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<int,string[]> 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<string,string> 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<int,array{title:string,link:string}>}|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<string,int> */
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<int,array{question:string,n:int,last:string}>
*/
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<int,array{id:int,name:string}> */
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.',
];
}