348 lines
18 KiB
PHP
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.',
|
|
];
|
|
}
|