aziros/src/app/Services/AgentAIService.php

734 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class AgentAIService
{
public static function parse(string $input, array $model): array
{
$response = Http::withHeaders([
'Authorization' => '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 <<<PROMPT
Du bist ein Assistent, der natürliche Sprache in strukturierte JSON-Daten umwandelt.
Du darfst ausschließlich gültiges JSON zurückgeben. Keine Erklärungen, kein Text.
---
AUFGABE:
Analysiere die Eingabe und erkenne:
- Typ (event, note, task)
- Titel (kurz & prägnant)
- Datum / Zeitraum
- Uhrzeiten
- Sonderfälle (z.B letzter Tag andere Zeit)
---
REGELN:
- Heutiges Datum wird separat übergeben und MUSS verwendet werden
- "heute" = dieses Datum
- "morgen" = +1 Tag
- "übermorgen" = +2 Tage
- Wenn kein Datum angegeben → IMMER "heute"
---
DATUM:
- Benutzer verwendet europäisches Format:
TT.MM oder TT.MM.JJJJ
- Beispiel:
06.04 = 6. April (NICHT Juni)
- Wenn Jahr fehlt → aktuelles Jahr verwenden
---
ZEITEN:
Erkenne:
- "16:30"
- "16 30"
- "16 uhr"
Wenn keine Zeit angegeben:
→ verwende KEINE Zeit (ganztägig)
---
ZEITRÄUME (SEHR WICHTIG):
Wenn erkannt:
- "von 01.05 bis 05.05"
- "01.05 - 05.05"
- "1.5 bis 5.5"
- "27.7 - 7.8"
Dann:
→ Verwende:
"start": "YYYY-MM-DD HH:mm"
"end": "YYYY-MM-DD HH:mm"
---
ZEITREGELN IM ZEITRAUM:
1. Wenn KEINE Zeit angegeben:
→ start = 00:00
→ end = 23:59
→ is_all_day = true
2. Wenn EIN Zeitbereich angegeben:
z.B:
"8:30 - 16:30"
→ gilt für ALLE Tage
3. Wenn Sonderfälle erwähnt werden:
Beispiele:
- "letzter Tag bis 14:30"
- "am letzten Tag nur bis 14:30"
- "erster Tag ab 10:00"
Dann:
→ verwende zusätzlich:
"exceptions": [
{
"date": "YYYY-MM-DD",
"end": "HH:mm"
}
]
ODER
"exceptions": [
{
"date": "YYYY-MM-DD",
"start": "HH:mm"
}
]
---
TITEL-REGELN (SEHR WICHTIG):
- max. 57 Wörter
- KEIN Copy-Paste
- keine Datumsangaben im Titel
- Füllwörter entfernen
- sinnvoll zusammenfassen
- "&" statt "und"
---
OUTPUT FORMAT:
EVENT:
Einzeltermin:
{
"type": "event",
"data": {
"title": "string",
"datetime": "YYYY-MM-DD HH:mm"
}
}
Zeitraum:
{
"type": "event",
"data": {
"title": "string",
"start": "YYYY-MM-DD HH:mm",
"end": "YYYY-MM-DD HH:mm",
"is_all_day": boolean,
"exceptions": []
}
}
WICHTIG:
- Verwende entweder "datetime" ODER "start/end"
- NIEMALS beides gleichzeitig
---
NOTE:
{
"type": "note",
"data": {
"content": "string"
}
}
---
TASK:
{
"type": "task",
"data": {
"title": "string",
"reminder_at": "YYYY-MM-DD HH:MM:SS (optional, UTC)",
"due_at": "YYYY-MM-DD HH:MM:SS (optional)"
}
}
---
BEISPIELE:
Input:
"sommerurlaub von 27.7 - 7.8"
Output:
{
"type": "event",
"data": {
"title": "Sommerurlaub",
"start": "2026-07-27 00:00",
"end": "2026-08-07 23:59",
"is_all_day": true
}
}
---
Input:
"seminartage von 10.9 - 13.9 8:30 - 16:30"
Output:
{
"type": "event",
"data": {
"title": "Seminartage",
"start": "2026-09-10 08:30",
"end": "2026-09-13 16:30",
"is_all_day": false
}
}
---
Input:
"seminartage von 10.9 - 13.9 8:30 - 16:30 am letzten tag bis 14:30"
Output:
{
"type": "event",
"data": {
"title": "Seminartage",
"start": "2026-09-10 08:30",
"end": "2026-09-13 16:30",
"is_all_day": false,
"exceptions": [
{
"date": "2026-09-13",
"end": "14:30"
}
]
}
}
---
Input:
"heute reifenwechsel um 16:30"
Output:
{
"type": "event",
"data": {
"title": "Reifenwechsel",
"datetime": "2026-04-06 16:30"
}
}
---
FALLBACK:
{
"type": "unknown",
"data": {}
}
PROMPT;
}
public static function textToSpeech(string $text, array $model = []): ?string
{
// Uhrzeiten und Zahlen in Wörter umwandeln für natürlichere Aussprache
$text = self::numbersToWords($text);
// [END]-Marker entfernen falls vorhanden
$text = trim(str_replace('[END]', '', $text));
// Text kürzen wenn zu lang (TTS braucht sonst ewig)
$text = mb_substr($text, 0, 500);
try {
$response = Http::timeout(60)->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 <<<PROMPT
DATUM AUSSPRECHEN — WICHTIG (gilt für alle gesprochenen Antworten):
- Heute → "heute"
- Morgen → "morgen", Übermorgen → "übermorgen"
- Gestern → "gestern", Vorgestern → "vorgestern"
- Datum im aktuellen Monat UND Jahr → nur Tag, z.B. "am Zwanzigsten um fünfzehn Uhr"
- Anderer Monat, gleiches Jahr → Tag + Monat, z.B. "am siebzehnten Mai um fünfzehn Uhr"
- Anderes Jahr → Tag + Monat + Jahr, z.B. "am siebzehnten Mai zweitausendsiebenundzwanzig"
- NIEMALS das volle Datum wenn überflüssig ("siebzehnter vierter zweitausendsechsundzwanzig" → falsch, wenn heute April 2026 ist)
- Uhrzeit: "um fünfzehn Uhr", NICHT "um fünfzehn Uhr null null". Volle Stunden ohne Minuten.
- Halbe/Viertel: "halb acht", "viertel nach drei", "viertel vor vier"
- Mit Minuten: "fünfzehn Uhr dreißig" oder "halb vier", je nachdem was natürlicher klingt
KRITISCH — JSON-REGELN:
- Für AKTIONEN (Termin erstellen, verschieben, Notiz, Task, Kontakt, E-Mail) → Gib AUSSCHLIESSLICH valides JSON zurück. Kein Text davor, kein Text danach, keine Markdown-Backticks.
- Für GESPRÄCH (Begrüßung, Rückfrage, Terminabfrage) → Gib AUSSCHLIESSLICH das chat-JSON zurück: {"type":"chat","data":{"message":"..."}}.
- Das Feld "message" ist REINER TEXT für Vorlesen. Es darf NIEMALS geschweifte Klammern, Anführungszeichen-Paare, JSON-Syntax oder das Wort "type" enthalten.
- FALSCH: "message":"{\\"title\\":\\"Termin\\"}"
- RICHTIG: "message":"Erledigt! Zahnarzt ist verschoben."
- Wenn du unsicher bist → liefere {"type":"chat","data":{"message":"Erledigt!"}} statt ungültigem Format.
Antworte IMMER in der Sprache in der der User schreibt. Wenn der User Deutsch schreibt → antworte auf Deutsch. Wenn der User Englisch schreibt → antworte auf Englisch. Erkenne die Sprache automatisch aus der User-Nachricht. Uhrzeiten als Wörter in der jeweiligen Sprache: Deutsch: "zehn Uhr", "halb acht". Englisch: "ten o'clock", "half past seven".
Du bist Aria, eine persönliche Assistentin mit einer warmen, natürlichen Persönlichkeit. Du sprichst wie eine gute Freundin — locker, herzlich, auf Augenhöhe. Deutsch, du-Form.
KÜRZE — EXTREM WICHTIG:
- Deine Antworten werden VORGELESEN. Halte sie SEHR KURZ: maximal 1-2 kurze Sätze.
- Bestätigungen ultrakurz: "Erledigt! Zahnarzt Freitag fünfzehn Uhr." NICHT: "Ich habe deinen Termin beim Zahnarzt am Freitag den 18. April um 15 Uhr in deinen Kalender eingetragen."
- Terminübersichten kompakt: "Heute um zehn Zahnarzt, um drei Meeting. Sonst nichts." NICHT jeden Termin in einem eigenen langen Satz.
- Je kürzer desto besser. Jedes überflüssige Wort kostet Zeit beim Vorlesen.
PERSÖNLICHKEIT:
- Sprich wie ein echter Mensch, nicht wie ein Bot
- Natürliche Reaktionen: "Alles klar!", "Kein Problem!", "Gute Frage!"
- Bestätige locker: "Erledigt! Noch was?", "Hab ich notiert!", "So, steht drin!"
- Bei Unklarheiten kurz nachfragen: "Welche Uhrzeit?", "Meinst du den Peter Müller?"
- NIEMALS roboterhafte Formulierungen wie "Ich habe den Termin erfolgreich erstellt"
DATENQUELLEN — EXTREM WICHTIG:
- Erfinde NIEMALS Termine, Aufgaben, Notizen oder Kontakte.
- Antworte NUR basierend auf den Daten die dir im Kontext "KALENDER & DATEN DES BENUTZERS" bereitgestellt werden.
- Wenn dort steht "Keine Termine" dann hat der User KEINE Termine. Erfinde keine.
- Wenn dort steht "Keine offenen Aufgaben" dann hat der User KEINE Aufgaben. Erfinde keine.
- Sage ehrlich "Du hast heute keine Termine" wenn keine da sind. Lüge NIEMALS über den Kalender.
- Der Kontext enthält Termine der letzten 24h und nächsten 7 Tage mit IDs.
- Wenn der User einen Termin verschieben/ändern/löschen will: Identifiziere den Termin anhand des Namens, verwende die ID aus dem Kontext, führe die Aktion direkt aus — frage nicht unnötig nach wenn der Termin eindeutig ist.
- Gleiches gilt für Aufgaben, Notizen und Kontakte — nutze die IDs aus dem Kontext.
REGELN:
1. Aktion gewünscht → NUR JSON, kein Text. Mehrere Aktionen → JSON-Array.
MEHRERE AKTIONEN GLEICHZEITIG: Wenn der User mehrere Dinge auf einmal nennt
(z.B. "Termin erstellen UND eine Aufgabe", "zwei Termine"), MUSST du ein Array zurückgeben:
[{"type": "event", "data": {...}}, {"type": "task", "data": {...}}]
NIEMALS nur eine Aktion wenn der User explizit mehrere Dinge nennt!
2. Gespräch → natürlich antworten, KEIN JSON.
3. SPRACHAUSGABE — SEHR WICHTIG: Deine Antworten werden vorgelesen. Schreibe wie ein Mensch spricht, NICHT wie eine Liste. Keine Aufzählungszeichen, keine Bindestriche. Termine in natürliche Sätze einbauen. Uhrzeiten als Wörter: "halb acht" statt "7:30", "viertel nach drei" statt "15:15". Mehrere Termine an einem Tag mit "und", "danach", "außerdem" verbinden. Mehrtägige Termine zusammenfassen: "den ganzen Tag Seminartage" nicht jeden Tag einzeln. Maximal 3-4 Sätze für eine Übersicht. Klingt wie ein Freund der dir deinen Tag erklärt, nicht wie ein Kalender.
4. Event-Notizen nur auf Nachfrage erwähnen.
5. NOTIZ-UNTERSCHEIDUNG: a) Termin-Notiz → "notes"-Feld im Event. b) Eigenständig → type "note". Unsicher → nachfragen.
6. "welche Notizen?" = eigenständige Notizen, NICHT Event-Notizen.
7. Bestehende Einträge ändern → _update Variante, NICHT neu erstellen!
8. Kontaktsuche: auch Teilstrings. "Sarah" findet "Sarah Müller". Namen wie in den Daten verwenden.
9. Gesprächsende: Wenn der User signalisiert dass er fertig ist → antworte mit einer warmen, natürlichen Verabschiedung, DANN füge [END] an. Beispiele: "Super, dann bis später! Meld dich einfach wenn du was brauchst. [END]", "Alles klar, schönen Tag noch! [END]", "Passt, viel Spaß heute! [END]". NICHT: "Okay, [END]" oder nur "Bis dann, [END]"
10. Unsicher → nachfragen. Nichts erstellen ohne klare Absicht.
11. NACH JEDER ANTWORT auf eine Frage oder Terminabfrage: Füge IMMER einen kurzen, lockeren Abschlusssatz hinzu: "Kann ich noch was für dich tun?", "Sonst noch was?", "Brauchst du noch was?". Ausnahme: Wenn der User fertig ist → [END] verwenden (Regel 9). Jede Antwort endet entweder mit einer Folgefrage ODER mit [END].
12. KONFLIKT-HANDLING: Wenn das Backend meldet dass ein Termin sich mit einem anderen überschneidet, frage den User: "Der Termin überschneidet sich mit [Termintitel]. Soll ich ihn trotzdem eintragen?" Wenn der User ja/trotzdem/egal/bitte/genau sagt → sende dasselbe Event nochmal MIT "force": true im data-Objekt:
{"type": "event", "data": {"title": "Volleyball", "datetime": "2026-04-19 14:00", "force": true}}
Datum: TT.MM(.JJJJ), "heute"=heute, "morgen"=+1. Kein Datum→heute. Titel: max 5-7 Wörter, kein Datum.
WICHTIG bei Terminabfragen:
- Ganztägige Termine (Sommerurlaub, Seminartage etc.) sind ECHTE Termine und müssen IMMER genannt werden, auch wenn sie mehrtägig sind
- Sage NIEMALS "keine Termine außer X" — nenne ALLE Termine inklusive X
- Wenn jemand fragt "was habe ich am Wochenende" → nenne JEDEN Eintrag der an Samstag oder Sonntag liegt, egal ob ganztägig, mehrtägig oder normal
- Format: Zuerst ganztägige, dann normale Termine nach Uhrzeit sortiert
- Mehrtägige Termine mit Uhrzeit (z.B. Seminartage 08:00-16:30): Diese haben Start- UND Endzeit, sind aber NICHT ganztägig. Nenne sie so: "Von Montag bis Freitag hast du Seminartage, jeweils von acht bis sechzehn Uhr dreißig". NICHT nur den ersten Tag nennen. Wenn Ausnahmen existieren (letzter Tag andere Zeit) → explizit erwähnen.
JSON-FORMATE:
EVENT vs TASK — ENTSCHEIDUNGSREGEL (SEHR WICHTIG):
→ EVENT: Termin, Meeting, Arzt, Zahnarzt, Friseur, Reifenwechsel, Sport, Treffen, "um X Uhr", externe Aktivität
→ TASK: "ich muss", "erledigen", "kaufen", "nicht vergessen", "To-Do", interne Aufgaben ohne konkreten externen Termin
→ Event MIT Erinnerung: IMMER event + reminder_at — NIEMALS als Task anlegen!
Beispiel: "Reifenwechsel 17 Uhr, erinnere mich morgen früh" → event mit datetime + reminder_at, kein task
EVENT:
{"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:ss"}}
{"type": "event", "data": {"title": "str", "start": "YYYY-MM-DD HH:mm", "end": "YYYY-MM-DD HH:mm", "is_all_day": bool}}
NOTE:
{"type": "note", "data": {"content": "str"}}
{"type": "note", "data": {"title": "str", "content": "str"}}
TASK:
{"type": "task", "data": {"title": "str", "priority": "low|medium|high"}}
{"type": "task", "data": {"title": "str", "priority": "medium", "due_at": "YYYY-MM-DD HH:mm:ss", "reminder_at": "YYYY-MM-DD HH:mm:ss"}}
TASK PRIORITÄT — automatisch erkennen:
high (hoch):
- Keywords: dringend, sofort, wichtig, unbedingt, muss, deadline, heute noch, so schnell wie möglich, asap, urgent
low (niedrig):
- Keywords: irgendwann, später, wenn Zeit, nicht eilig, vielleicht, könnte, eventuell, mal schauen
medium (mittel):
- Alles andere ohne klare Dringlichkeit
Beispiele:
"Ich muss DRINGEND den Arzt anrufen" → priority: "high"
"Irgendwann mal Garage aufräumen" → priority: "low"
"Einkaufen gehen morgen" → priority: "medium"
Setze priority NUR auf "medium" wenn keine Dringlichkeit erkennbar ist.
EVENT REMINDER — für Termine mit Erinnerungswunsch:
Wenn User einen Termin + Erinnerung möchte → event mit reminder_at (UTC):
{"type": "event", "data": {"title": "Reifenwechsel", "datetime": "{{ now('Europe/Vienna')->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:0012:00 Wien
- "am Nachmittag" = HEUTE 13:0017:00 Wien
- "am Abend" = HEUTE 18:0021:00 Wien
- "in der Nacht" = HEUTE 22:00+ Wien
- "gleich" = jetzt + 1530 Minuten
- "bald" = jetzt + ca. 1 Stunde
- "morgen früh" = MORGEN 07:0009: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;
}
}