diff --git a/DeskHub-Webseite/cron/process_snoozes.php b/DeskHub-Webseite/cron/process_snoozes.php
new file mode 100644
index 00000000..b81ad3c0
--- /dev/null
+++ b/DeskHub-Webseite/cron/process_snoozes.php
@@ -0,0 +1,23 @@
+ $n, 'log' => ['[' . date('Y-m-d H:i:s') . "] Wiedervorlagen geprüft, {$n} Ticket(s) zurückgeholt."]];
+}
+
+if (php_sapi_name() === 'cli') {
+ foreach (run_process_snoozes(get_db(), true)['log'] as $line) echo $line . "\n";
+}
diff --git a/DeskHub-Webseite/cron/remind_customers.php b/DeskHub-Webseite/cron/remind_customers.php
new file mode 100644
index 00000000..ab9e83e9
--- /dev/null
+++ b/DeskHub-Webseite/cron/remind_customers.php
@@ -0,0 +1,63 @@
+ 0, 'log' => ['[' . date('Y-m-d H:i:s') . '] Kundenerinnerung ist deaktiviert – nichts zu tun.']];
+ }
+ $days = customer_reminder_days();
+ $threshold = date('Y-m-d H:i:s', time() - $days * 86400);
+ $closeDays = get_setting('auto_close_enabled', '0') === '1' ? max(1, (int) get_setting('auto_close_days', '7')) : 0;
+
+ $stmt = $pdo->prepare(
+ "SELECT t.*, d.name AS department_name FROM tickets t LEFT JOIN departments d ON d.id = t.department_id
+ WHERE t.status = 'answered' AND t.is_spam = 0 AND t.source <> 'chat' AND t.updated_at < ?
+ AND (t.reminder_sent_at IS NULL OR t.reminder_sent_at < t.updated_at)
+ AND (t.snoozed_until IS NULL OR t.snoozed_until <= ?)
+ ORDER BY t.updated_at LIMIT 100"
+ );
+ $stmt->execute([$threshold, db_now()]);
+ $sent = 0;
+ foreach ($stmt->fetchAll() as $t) {
+ if (empty($t['requester_email']) || !filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue;
+ $closeNote = '';
+ if ($closeDays > $days) {
+ $closeNote = '
Hören wir bis dahin nichts von Ihnen, schließen wir das Ticket in ' . ($closeDays - $days) . ' ' . (($closeDays - $days) === 1 ? 'Tag' : 'Tagen') . ' automatisch. Sie können es jederzeit wieder öffnen, indem Sie antworten.
';
+ } elseif ($closeDays > 0) {
+ $closeNote = '
Hören wir von Ihnen nichts mehr, schließen wir das Ticket in Kürze automatisch. Sie können es jederzeit wieder öffnen, indem Sie antworten.
';
+ }
+ [$subject, $body] = render_email_template('customer_reminder', [
+ 'ticket_number' => $t['ticket_number'],
+ 'subject' => e($t['subject']),
+ 'customer_name' => e($t['requester_name'] ?? ''),
+ 'ticket_url' => e(ticket_access_url($t)),
+ 'close_note' => $closeNote,
+ ], "Erinnerung zu Ihrem Ticket [#{$t['ticket_number']}]", email_customer_reminder_default_body());
+ $from = resolve_from_address($t['department_id'] ?? null, $t['priority_id'] ?? null);
+ email_send_and_log($t['requester_email'], $subject, email_wrap($body), $from, 'Erinnerung an den Kunden', $t['ticket_number']);
+ // updated_at bleibt unverändert, damit das automatische Schließen seine Frist behält
+ $pdo->prepare('UPDATE tickets SET reminder_sent_at = ?, updated_at = updated_at WHERE id = ?')->execute([db_now(), $t['id']]);
+ log_activity($pdo, (int) $t['id'], null, 'Erinnerung an den Kunden gesendet');
+ $sent++;
+ if ($verbose) $log[] = "[ok] Erinnerung für Ticket #{$t['ticket_number']}";
+ }
+ set_setting('cron_remind_customers_last_run', db_now());
+ $log[] = '[' . date('Y-m-d H:i:s') . "] Kundenerinnerung: {$sent} Mail(s) gesendet (nach {$days} Tagen ohne Reaktion).";
+ return ['sent' => $sent, 'log' => $log];
+}
+
+if (php_sapi_name() === 'cli') {
+ foreach (run_remind_customers(get_db(), true)['log'] as $line) echo $line . "\n";
+}
diff --git a/DeskHub-Webseite/database/migrate.php b/DeskHub-Webseite/database/migrate.php
index 754d9c80..7104ff74 100644
--- a/DeskHub-Webseite/database/migrate.php
+++ b/DeskHub-Webseite/database/migrate.php
@@ -1227,6 +1227,109 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
}
}
+ // Migration 0044: Antworten des digitalen Assistenten (zusätzlich zur Wissensdatenbank).
+ if (!isset($done['php_0044_assistant_answers'])) {
+ try {
+ $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
+ $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
+ $pdo->exec("CREATE TABLE IF NOT EXISTS assistant_answers (
+ id $ai,
+ title VARCHAR(255) NOT NULL,
+ keywords TEXT NULL,
+ answer TEXT NOT NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ hits INT NOT NULL DEFAULT 0,
+ created_at DATETIME NULL,
+ updated_at DATETIME NULL
+ )$eng");
+ $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0044_assistant_answers']);
+ $result['applied']++;
+ if ($verbose) $result['log'][] = ' [OK] php_0044_assistant_answers';
+ } catch (Throwable $e) {
+ $result['errors'][] = 'php_0044_assistant_answers: ' . $e->getMessage();
+ if ($verbose) $result['log'][] = ' [FAIL] php_0044_assistant_answers - ' . $e->getMessage();
+ }
+ }
+
+ // Migration 0045: Protokoll der Fragen an den digitalen Assistenten.
+ if (!isset($done['php_0045_assistant_log'])) {
+ try {
+ $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
+ $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
+ $pdo->exec("CREATE TABLE IF NOT EXISTS assistant_log (
+ id $ai,
+ question VARCHAR(300) NOT NULL,
+ result VARCHAR(20) NOT NULL,
+ source VARCHAR(10) NULL,
+ matched_id INT NULL,
+ matched_title VARCHAR(255) NULL,
+ ticket_number VARCHAR(40) NULL,
+ created_at DATETIME NOT NULL
+ )$eng");
+ $pdo->exec('CREATE INDEX idx_assistant_log_created ON assistant_log (created_at)');
+ $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0045_assistant_log']);
+ $result['applied']++;
+ if ($verbose) $result['log'][] = ' [OK] php_0045_assistant_log';
+ } catch (Throwable $e) {
+ $result['errors'][] = 'php_0045_assistant_log: ' . $e->getMessage();
+ if ($verbose) $result['log'][] = ' [FAIL] php_0045_assistant_log - ' . $e->getMessage();
+ }
+ }
+
+ // Migration 0046: Wiedervorlage, Beobachter, Kundenerinnerung, Rückmeldung zu Antworten des Assistenten.
+ if (!isset($done['php_0046_ticket_extras'])) {
+ try {
+ $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ $has = function (string $table, string $col) use ($pdo): bool {
+ try { $pdo->query("SELECT $col FROM $table LIMIT 1"); return true; } catch (Throwable $e) { return false; }
+ };
+ if (!$has('tickets', 'snoozed_until')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_until DATETIME NULL');
+ if (!$has('tickets', 'snoozed_by')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_by INT NULL');
+ if (!$has('tickets', 'reminder_sent_at')) $pdo->exec('ALTER TABLE tickets ADD COLUMN reminder_sent_at DATETIME NULL');
+ $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
+ $pdo->exec("CREATE TABLE IF NOT EXISTS ticket_followers (
+ ticket_id INT NOT NULL,
+ staff_id INT NOT NULL,
+ created_at DATETIME NOT NULL,
+ PRIMARY KEY (ticket_id, staff_id)
+ )$eng");
+ if (!$has('assistant_log', 'helpful')) $pdo->exec('ALTER TABLE assistant_log ADD COLUMN helpful TINYINT NULL');
+ $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0046_ticket_extras']);
+ $result['applied']++;
+ if ($verbose) $result['log'][] = ' [OK] php_0046_ticket_extras';
+ } catch (Throwable $e) {
+ $result['errors'][] = 'php_0046_ticket_extras: ' . $e->getMessage();
+ if ($verbose) $result['log'][] = ' [FAIL] php_0046_ticket_extras - ' . $e->getMessage();
+ }
+ }
+
+ // Migration 0047: einmalige Anmelde-Adressen für die Übergabe von der App an die Weboberfläche.
+ if (!isset($done['php_0047_web_login_tokens'])) {
+ try {
+ $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
+ $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
+ $pdo->exec("CREATE TABLE IF NOT EXISTS web_login_tokens (
+ id $ai,
+ staff_id INT NOT NULL,
+ token_hash VARCHAR(64) NOT NULL,
+ path VARCHAR(255) NOT NULL,
+ created_at DATETIME NOT NULL,
+ expires_at DATETIME NOT NULL,
+ used_at DATETIME NULL
+ )$eng");
+ $pdo->exec('CREATE UNIQUE INDEX idx_web_login_tokens_hash ON web_login_tokens (token_hash)');
+ $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0047_web_login_tokens']);
+ $result['applied']++;
+ if ($verbose) $result['log'][] = ' [OK] php_0047_web_login_tokens';
+ } catch (Throwable $e) {
+ $result['errors'][] = 'php_0047_web_login_tokens: ' . $e->getMessage();
+ if ($verbose) $result['log'][] = ' [FAIL] php_0047_web_login_tokens - ' . $e->getMessage();
+ }
+ }
+
return $result;
}
diff --git a/DeskHub-Webseite/includes/assistant.php b/DeskHub-Webseite/includes/assistant.php
new file mode 100644
index 00000000..d3f80e47
--- /dev/null
+++ b/DeskHub-Webseite/includes/assistant.php
@@ -0,0 +1,347 @@
+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.',
+ ];
+}
diff --git a/DeskHub-Webseite/includes/assistant_widget.php b/DeskHub-Webseite/includes/assistant_widget.php
new file mode 100644
index 00000000..c7843d0e
--- /dev/null
+++ b/DeskHub-Webseite/includes/assistant_widget.php
@@ -0,0 +1,207 @@
+
+
+
+
+
🤖
+
Ein Mitarbeiter ist jetzt erreichbar. Laden Sie die Seite neu, um den Live-Chat zu nutzen.