'Bearer ' . config('services.openai.key'), ])->post('https://api.openai.com/v1/chat/completions', [ 'model' => $model['model'] ?? config('services.openai.model'), 'messages' => [ [ 'role' => 'system', 'content' => self::systemPrompt() . "\n\nHeutiges Datum: " . now()->format('Y-m-d'), ], [ 'role' => 'user', 'content' => $input, ], ], 'temperature' => $model['temperature'] ?? 0.2, 'max_tokens' => $model['max_tokens'] ?? 1000, ]); return array_merge( self::parseJson($response['choices'][0]['message']['content'] ?? null), [ '_usage' => $response['usage'] ?? [], ] ); } public static function chat(array $conversationHistory, array $model, string $userContext = ''): array { $systemContent = self::chatSystemPrompt(); if ($userContext) { $systemContent .= "\n\n--- KALENDER & DATEN DES BENUTZERS ---\n" . $userContext; } $messages = [['role' => 'system', 'content' => $systemContent]]; // Letzte 10 Messages behalten (5 Paare) damit Context nicht zu groß wird $history = collect($conversationHistory) ->take(-10) ->values() ->map(fn ($msg) => [ 'role' => $msg['role'], 'content' => $msg['content'], ]) ->toArray(); $messages = array_merge($messages, $history); $openaiModel = $model['model'] ?? config('services.openai.model'); \Log::info('AgentAI: Sending to OpenAI', [ 'model' => $openaiModel, 'messages_count' => count($messages), 'history_count' => count($history), 'has_key' => !empty(config('services.openai.key')), ]); try { $response = Http::timeout(45)->withHeaders([ 'Authorization' => 'Bearer ' . config('services.openai.key'), ])->post('https://api.openai.com/v1/chat/completions', [ 'model' => $openaiModel, 'messages' => $messages, 'temperature' => $model['temperature'] ?? 0.5, 'max_tokens' => $model['max_tokens'] ?? 1500, ]); if ($response->failed()) { \Log::error('AgentAI: OpenAI returned error', [ 'status' => $response->status(), 'body' => mb_substr($response->body(), 0, 500), ]); return [ 'type' => 'chat', 'data' => ['message' => 'Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es nochmal.'], '_usage' => [], '_error' => true, ]; } } catch (\Throwable $e) { \Log::error('AgentAI: Exception', [ 'error' => $e->getMessage(), 'class' => get_class($e), ]); report($e); return [ 'type' => 'chat', 'data' => ['message' => 'Entschuldigung, ich konnte gerade keine Verbindung herstellen. Versuch es bitte nochmal.'], '_usage' => [], '_error' => true, ]; } $content = $response['choices'][0]['message']['content'] ?? null; $usage = $response['usage'] ?? []; \Log::info('AgentAI: Raw response', [ 'content' => mb_substr($content ?? '', 0, 500), ]); $parsed = self::parseJson($content); // Multi-Action: Array von Aktionen if (isset($parsed[0]) && is_array($parsed[0])) { return array_merge(['_multi' => $parsed], ['_usage' => $usage]); } if (($parsed['type'] ?? 'unknown') === 'unknown' && $content) { \Log::warning('AgentAI: type=unknown – Raw content', [ 'content' => mb_substr($content, 0, 500), ]); // Harte Sicherung: wenn das Modell rohen JSON-Text liefert, der // NICHT geparst werden konnte, darf der nie als "message" landen — // sonst liest die TTS geschweifte Klammern vor. $messageText = self::looksLikeJson($content) ? 'Erledigt!' : $content; if (self::looksLikeJson($content)) { \Log::warning('AgentAI: JSON-like content in chat fallback — replaced', [ 'preview' => mb_substr($content, 0, 200), ]); } $parsed = [ 'type' => 'chat', 'data' => ['message' => $messageText], ]; } // Letzter Riegel: jede message-Property bereinigen if (isset($parsed['data']['message']) && self::looksLikeJson($parsed['data']['message'])) { \Log::warning('AgentAI: JSON leaked into data.message — replaced', [ 'preview' => mb_substr((string) $parsed['data']['message'], 0, 200), ]); $parsed['data']['message'] = 'Erledigt!'; } return array_merge($parsed, ['_usage' => $usage]); } protected static function systemPrompt(): string { return <<withHeaders([ 'Authorization' => 'Bearer ' . config('services.openai.key'), ])->post('https://api.openai.com/v1/audio/speech', [ 'model' => 'tts-1', 'voice' => $model['tts_voice'] ?? 'nova', 'input' => $text, 'response_format' => 'mp3', 'speed' => $model['tts_speed'] ?? 1.0, ]); if ($response->successful()) { return base64_encode($response->body()); } } catch (\Throwable $e) { // Timeout oder Netzwerkfehler → null zurückgeben, Frontend nutzt Browser-TTS report($e); } return null; } protected static function numbersToWords(string $text): string { $ones = ['null','eins','zwei','drei','vier','fünf','sechs','sieben','acht','neun', 'zehn','elf','zwölf','dreizehn','vierzehn','fünfzehn','sechzehn','siebzehn','achtzehn','neunzehn']; $tens = ['','','zwanzig','dreißig','vierzig','fünfzig']; $numberToWord = function(int $n) use ($ones, $tens): string { if ($n >= 0 && $n <= 19) return $ones[$n]; if ($n >= 20 && $n <= 59) { $t = (int)($n / 10); $o = $n % 10; if ($o === 0) return $tens[$t]; return $ones[$o] . 'und' . $tens[$t]; // dreiundzwanzig } return (string) $n; }; // "10:30" oder "14:00" → "zehn Uhr dreißig" / "vierzehn Uhr" $text = preg_replace_callback('/(\d{1,2}):(\d{2})/', function($m) use ($numberToWord) { $h = (int)$m[1]; $min = (int)$m[2]; $result = $numberToWord($h) . ' Uhr'; if ($min > 0) $result .= ' ' . $numberToWord($min); return $result; }, $text); // "10 Uhr" → "zehn Uhr" (falls AI es als Zahl schreibt) $text = preg_replace_callback('/(\d{1,2})\s*Uhr/i', function($m) use ($numberToWord) { return $numberToWord((int)$m[1]) . ' Uhr'; }, $text); // Datum-Zahlen: "14.04." → "vierzehnter vierter" $text = preg_replace_callback('/(\d{1,2})\.(\d{1,2})\./', function($m) use ($numberToWord) { $day = $numberToWord((int)$m[1]) . 'ter'; $month = $numberToWord((int)$m[2]) . 'ter'; return "{$day} {$month} "; }, $text); return $text; } protected static function chatSystemPrompt(): string { return <<setTime(17,0)->utc()->format('Y-m-d H:i') }}", "reminder_at": "{{ now('Europe/Vienna')->addDay()->setTime(7,0)->utc()->format('Y-m-d H:i:s') }}"}} TASK REMINDER — NUR für reine Aufgaben (kein externer Termin): Wenn User "erinnere mich in X Min/Std" oder "um HH:MM" oder "morgen früh" sagt UND es ein internes To-Do ist: - reminder_at UND due_at MÜSSEN gesetzt werden - Zeiten IMMER in UTC (Format: YYYY-MM-DD HH:mm:ss) - due_at = reminder_at wenn kein anderes Datum ÖSTERREICHISCHE ZEITAUSDRÜCKE — IMMER als HEUTE interpretieren: - "in der Früh" = HEUTE früh (z.B. 07:00 Wien) — NICHT morgen! - "am Vormittag" = HEUTE 09:00–12:00 Wien - "am Nachmittag" = HEUTE 13:00–17:00 Wien - "am Abend" = HEUTE 18:00–21:00 Wien - "in der Nacht" = HEUTE 22:00+ Wien - "gleich" = jetzt + 15–30 Minuten - "bald" = jetzt + ca. 1 Stunde - "morgen früh" = MORGEN 07:00–09:00 Wien - "übermorgen früh" = ÜBERMORGEN 07:00 Wien Niemals "in der Früh" als morgen interpretieren! Zeitberechnung (aktuell: {{ now()->utc()->format('Y-m-d H:i') }} UTC = {{ now('Europe/Vienna')->format('H:i') }} Wien, Offset: {{ now('Europe/Vienna')->format('P') }}): - "in 30 Minuten" → now + 30 Min UTC - "in 1 Stunde" → now + 60 Min UTC - "in 2 Stunden" → now + 120 Min UTC - "um 15 Uhr Wien" → {{ now('Europe/Vienna')->setTime(15,0)->utc()->format('H:i:s') }} UTC (aktueller Offset {{ now('Europe/Vienna')->format('P') }}) - "morgen früh um 8 Wien" → {{ now('Europe/Vienna')->addDay()->setTime(8,0)->utc()->format('Y-m-d H:i:s') }} UTC - "in der Früh um 7" → {{ now('Europe/Vienna')->setTime(7,0)->utc()->format('Y-m-d H:i:s') }} UTC (HEUTE!) Beispiel — "Erinnere mich in 58 Min: Wäsche aus Waschmaschine": {"type": "task", "data": {"title": "Wäsche aus Waschmaschine", "priority": "medium", "reminder_at": "{{ now()->utc()->addMinutes(58)->format('Y-m-d H:i:s') }}", "due_at": "{{ now()->utc()->addMinutes(58)->format('Y-m-d H:i:s') }}"}} CONTACT (nur name ist Pflicht): {"type": "contact", "data": {"name": "str", "phone": "str", "email": "str", "type": "privat|arbeit|kunde|sonstiges", "notes": "str"}} EVENT_UPDATE (inkl. Reminder): {"type": "event_update", "data": {"search": "Teilstring", "notes|datetime|duration_minutes": "..."}} {"type": "event_update", "data": {"search": "Teilstring", "reminders": [{"type": "before", "minutes": 10}]}} REMINDER TYPEN: - Minuten/Stunden vorher: {"type": "before", "minutes": 10} Beispiele: "10 Minuten vorher" → minutes: 10, "1 Stunde vorher" → minutes: 60 - Uhrzeit am Tag des Termins: {"type": "time_of_day", "time": "08:00"} - Am Vortag um Uhrzeit: {"type": "day_before", "time": "18:00"} Wenn User sagt "Erinnere mich 10 Min vorher und morgen früh um 8" für Termin: {"type": "event_update", "data": {"search": "Termintitel", "reminders": [{"type": "before", "minutes": 10}, {"type": "day_before", "time": "08:00"}]}} NOTE_UPDATE: {"type": "note_update", "data": {"search": "Teilstring", "content": "Zusatz"}} TASK_UPDATE: {"type": "task_update", "data": {"search": "Teilstring", "description|status": "...|done"}} EMAIL: {"type": "email", "data": {"contact": "Name", "message": "Text", "subject": "Betreff"}} {"type": "email", "data": {"contact": "Name", "event": "Termintitel-Teilstring", "message": "opt."}} Multi (PFLICHT bei mehreren Aktionen gleichzeitig!): [{"type": "event", "data": {"title": "Zahnarzt", "datetime": "2026-04-20 08:00"}}, {"type": "task", "data": {"title": "Zahnarzt vorbereiten", "priority": "medium"}}] MULTI-EVENT MIT ERINNERUNG — KORREKT (PFLICHT-BEISPIEL): User: "Ich hab morgen Reifenwechsel um 17 Uhr, erinnere mich um 7:55. Und heute um 14 Uhr Volleyball, erinnere mich um 7:58." [ {"type": "event", "data": {"title": "Reifenwechsel", "datetime": "{{ now('Europe/Vienna')->addDay()->setTime(17,0)->utc()->format('Y-m-d H:i') }}", "reminder_at": "{{ now('Europe/Vienna')->addDay()->setTime(7,55)->utc()->format('Y-m-d H:i:s') }}"}}, {"type": "event", "data": {"title": "Volleyball", "datetime": "{{ now('Europe/Vienna')->setTime(14,0)->utc()->format('Y-m-d H:i') }}", "reminder_at": "{{ now('Europe/Vienna')->setTime(7,58)->utc()->format('Y-m-d H:i:s') }}"}} ] FALSCH: Als task oder task_update anlegen — Termine mit Uhrzeit sind IMMER events! PROMPT; } protected static function parseJson(?string $text): array { if (!$text) { return self::fallback(); } // Markdown-Backticks entfernen — auch wenn schließendes ``` fehlt $cleaned = trim($text); $cleaned = preg_replace('/^```(?:json)?\s*/i', '', $cleaned); $cleaned = preg_replace('/\s*```$/', '', $cleaned); $cleaned = trim($cleaned); $json = json_decode($cleaned, true); if ($json === null) { // Kein gültiges JSON — wenn kein { oder [ am Anfang → plain-text Chat-Antwort $isJson = str_starts_with($cleaned, '{') || str_starts_with($cleaned, '['); if (!$isJson) { return [ 'type' => 'chat', 'data' => ['message' => $cleaned], ]; } return self::fallback(); } // Array von Aktionen (Multi-Action) if (isset($json[0]) && is_array($json[0])) { return $json; // Wird in chat() als _multi erkannt } return $json; } protected static function fallback(): array { return [ 'type' => 'unknown', 'data' => [], ]; } /** * Prüft, ob ein String wie rohes JSON aussieht und somit NIEMALS * vorgelesen werden darf. */ protected static function looksLikeJson(?string $text): bool { if (!$text) return false; $trim = ltrim($text); if ($trim === '') return false; $first = $trim[0]; if ($first === '{' || $first === '[') return true; if (str_contains($text, '"type":')) return true; if (str_contains($text, '"data":')) return true; return false; } }