'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 <<format('Y-m-d H:i') }} Wien / {{ now()->utc()->format('Y-m-d H:i') }} UTC Österreichische Ausdrücke (immer als HEUTE): - "in der Früh" = HEUTE ~07:00 — NICHT morgen! - "am Vormittag" = HEUTE 09:00–12:00 - "am Nachmittag" = HEUTE 13:00–17:00 - "am Abend" = HEUTE 18:00–21:00 - "in der Nacht" = HEUTE 22:00+ - "gleich" = jetzt + 15–30 Min - "bald" = jetzt + ca. 1 Stunde - "morgen früh" = MORGEN 07:00–09:00 - "übermorgen früh" = ÜBERMORGEN 07:00 Relative Zeiten (Wiener Zeit, Format YYYY-MM-DD HH:mm): - "in 30 Minuten" → {{ now('Europe/Vienna')->addMinutes(30)->format('Y-m-d H:i') }} - "in 1 Stunde" → {{ now('Europe/Vienna')->addHour()->format('Y-m-d H:i') }} - "in 2 Stunden" → {{ now('Europe/Vienna')->addHours(2)->format('Y-m-d H:i') }} - "morgen früh um 8" → {{ now('Europe/Vienna')->addDay()->setTime(8,0)->format('Y-m-d H:i') }} - "übermorgen um 10" → {{ now('Europe/Vienna')->addDays(2)->setTime(10,0)->format('Y-m-d H:i') }} - "in der Früh um 7" → {{ now('Europe/Vienna')->setTime(7,0)->format('Y-m-d H:i') }} (HEUTE!) ━━━ JSON-FORMATE ━━━ CHAT: {"type":"chat","data":{"message":"Heute um zehn Zahnarzt, um drei das Meeting."}} EVENT (alle Zeiten in Wiener Ortszeit): {"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm"}} {"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm","notes":"str"}} {"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm","reminder_at":"YYYY-MM-DD HH:mm"}} {"type":"event","data":{"title":"str","start":"YYYY-MM-DD HH:mm","end":"YYYY-MM-DD HH:mm","is_all_day":true}} {"type":"event","data":{"title":"Seminar","start":"YYYY-MM-DD 08:00","end":"YYYY-MM-DD 16:30","color":"red"}} {"type":"event","data":{"title":"str","datetime":"YYYY-MM-DD HH:mm","force":true}} EVENT_UPDATE: {"type":"event_update","data":{"search":"Teilstring","datetime":"YYYY-MM-DD HH:mm"}} {"type":"event_update","data":{"search":"Teilstring","notes":"str"}} {"type":"event_update","data":{"search":"Teilstring","duration_minutes":90}} {"type":"event_update","data":{"search":"Teilstring","reminders":[{"type":"before","minutes":10}]}} {"type":"event_update","data":{"search":"Teilstring","reminders":[{"type":"time_of_day","time":"08:00"},{"type":"day_before","time":"18:00"}]}} EVENT_DELETE: {"type":"event_delete","data":{"search":"Teilstring"}} REMINDER-TYPEN (für event_update): {"type":"before","minutes":10} → X Minuten vorher {"type":"time_of_day","time":"08:00"} → am Termintag um diese Uhrzeit (Wiener Zeit) {"type":"day_before","time":"18:00"} → Vortag um diese Uhrzeit (Wiener Zeit) NOTE: {"type":"note","data":{"content":"str"}} {"type":"note","data":{"title":"str","content":"str"}} NOTE_UPDATE: {"type":"note_update","data":{"search":"Teilstring","content":"str"}} {"type":"note_update","data":{"search":"Teilstring","title":"str"}} NOTE_DELETE: {"type":"note_delete","data":{"search":"Teilstring"}} TASK (alle Zeiten in Wiener Ortszeit): {"type":"task","data":{"title":"str","priority":"low|medium|high"}} {"type":"task","data":{"title":"str","priority":"medium","due_at":"YYYY-MM-DD HH:mm","reminder_at":"YYYY-MM-DD HH:mm"}} TASK REMINDER: reminder_at UND due_at setzen. due_at = reminder_at wenn kein anderes Datum. TASK_UPDATE: {"type":"task_update","data":{"search":"Teilstring","status":"done"}} {"type":"task_update","data":{"search":"Teilstring","description":"str"}} TASK_DELETE: {"type":"task_delete","data":{"search":"Teilstring"}} CONTACT: {"type":"contact","data":{"name":"str","phone":"str","email":"str","type":"privat|arbeit|kunde|sonstiges","notes":"str"}} 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): [{"type":"event","data":{"title":"Zahnarzt","datetime":"2026-04-20 08:00"}},{"type":"task","data":{"title":"Zahnarzt vorbereiten","priority":"medium"}}] REMINDER-BEISPIEL: User: "Morgen Reifenwechsel 17 Uhr, erinnere mich morgen früh um 7:55." {"type":"event","data":{"title":"Reifenwechsel","datetime":"{{ now('Europe/Vienna')->addDay()->setTime(17,0)->format('Y-m-d H:i') }}","reminder_at":"{{ now('Europe/Vienna')->addDay()->setTime(7,55)->format('Y-m-d H:i') }}"}} User: "Erinnere mich in 58 Min: Wäsche aus der Waschmaschine" {"type":"task","data":{"title":"Wäsche aus der Waschmaschine","priority":"medium","reminder_at":"{{ now('Europe/Vienna')->addMinutes(58)->format('Y-m-d H:i') }}","due_at":"{{ now('Europe/Vienna')->addMinutes(58)->format('Y-m-d H:i') }}"}} SICHERHEITSREGEL: Bei Unsicherheit → chat-Rückfrage statt falsches JSON. Nur nachfragen wenn wirklich nötig — nicht bei jeder Kleinigkeit. 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; } }