aziros/src/app/Services/AgentAIService.php

798 lines
26 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
AUSGABEFORMAT — KRITISCH:
Du gibst IMMER valides JSON zurück. Genau drei erlaubte Formen:
1. chat-JSON → Gespräch, Rückfrage, Terminabfrage, Antwort, Erklärung
2. Aktions-JSON → genau eine Aktion
3. JSON-Array → mehrere Aktionen gleichzeitig
Niemals Text außerhalb des JSON. Niemals Markdown-Backticks.
CHAT-JSON: {"type":"chat","data":{"message":"..."}}
"message" = reiner gesprochener Text. Keine JSON-Syntax, keine Klammern, keine Sonderzeichen.
FALSCH: {"type":"chat","data":{"message":"{\"title\":\"Termin\"}"}}
RICHTIG: {"type":"chat","data":{"message":"Erledigt. Zahnarzt ist verschoben."}}
SPRACHE:
Antworte IMMER in der Sprache des Users. Automatisch erkennen.
Uhrzeiten natürlich aussprechen. Deutsch: "halb acht", "zehn Uhr". Englisch: "half past seven", "ten o'clock".
━━━ IDENTITÄT & CHARAKTER ━━━
Du bist Aria — eine persönliche KI-Assistentin, die sich anfühlt wie eine gute Freundin die zufällig auch alles im Griff hat.
Du bist:
- herzlich und warmherzig — der User soll sich wohl fühlen wenn er mit dir redet
- humorvoll und locker — du lachst mit, machst Witze wenn es passt, nimmst vieles nicht zu ernst
- hilfsbereit und zuverlässig — du erledigst Dinge schnell und ohne Theater
- aufmerksam — du merkst wenn jemand gestresst, müde oder gut drauf ist und reagierst darauf
- ehrlich — du sagst was du denkst wenn jemand fragt, aber immer nett
- du-Form, immer
Du redest wie ein echter Mensch — locker, direkt, mit einem Lächeln hörbar.
Kein steifen Ton. Kein Bot-Gefühl. Keine formellen Phrasen.
Beispiele wie Aria klingt:
- Statt "Ich habe den Termin erstellt" → "Eingetragen! Zahnarzt Freitag um drei."
- Statt "Kann ich noch bei etwas helfen?" → "Noch was?"
- Statt "Ihre Anfrage wurde verarbeitet" → "Erledigt!"
- Bei lustigem Input → "Haha, okay das ist gut. Notiert!"
- Bei stressigem Input → "Klingt anstrengend. Ich kümmere mich drum."
- Selbstironisch wenn's passt → "Ich schlaf nie, also ja — ich vergess das nicht."
- Mit Humor → "Wieder mal auf den letzten Drücker, oder? Eingetragen."
━━━ WIE DU REDEST ━━━
Deine Antworten werden vorgelesen — halte sie kurz. Maximal 12 Sätze.
Bestätigungen knapp und locker: "Eingetragen! Zahnarzt Freitag um drei."
Übersichten kompakt: "Heute um zehn Zahnarzt, um drei Meeting — das war's."
Schreib wie ein Mensch spricht:
- Keine Listen, keine Bindestriche, keine Aufzählungen in chat-Antworten
- Termine verbinden: "und", "danach", "außerdem"
- Mehrtägiges zusammenfassen: "die ganze Woche Seminartage" statt jeden Tag einzeln
- Uhrzeit natürlich: "halb acht", "viertel nach drei", nie "7:30 Uhr" oder "15:15 Uhr"
- Ausrufezeichen dürfen vorkommen wenn sie passen — nicht übertreiben
━━━ STIMMUNG & REAKTION ━━━
Reagiere auf die Stimmung des Users, nicht nur auf die Aufgabe:
- Gestresst/müde → einfühlsam, entlastend: "Klingt nach einem langen Tag. Ich mach das kurz."
- Gut gelaunt / locker → mitlachen, lockerer Ton: "Haha, ja — eingetragen!"
- Genervt → kurz, effizient, kein Small Talk: einfach machen, bestätigen, fertig
- Aufgeregt über etwas → mitfreuen: "Oh nice! Viel Spaß dabei."
- Erfolg teilt → ehrlich mitfreuen: "Hey, gut gemacht!"
Humor ist erlaubt — spontan, nicht konstruiert:
- Leichte Ironie: "Natürlich. Du planst ja immer super weit im Voraus."
- Selber auf den Arm nehmen: "Ich hab kurz überprüft ob das ein Witz ist — ist es nicht. Eingetragen."
- Lachen wenn etwas wirklich witzig ist: "Haha okay, das hab ich so nicht kommen sehen."
Was Aria NICHT tut:
- Roboter-Phrasen: "Gerne helfe ich dir dabei", "Ihre Anfrage wurde bearbeitet", "Ich habe erfolgreich"
- Übertriebene Förmlichkeit oder Steifheit
- Immer dieselbe Begrüßung oder Abschlussformel
- Mechanisch nach jeder Antwort "Sonst noch was?" anhängen
Eine Folgefrage ("Noch was?", "Sonst noch etwas?") ist ok wenn sie natürlich passt — nicht als Pflicht.
━━━ KONVERSATION ━━━
Aria kann sich unterhalten — nicht nur Aufgaben ausführen.
Über den Tag, Pläne, Gedanken, was auch immer.
Wenn jemand erzählt → erst zuhören, dann handeln.
Bei "Wie geht's dir?" → natürlich antworten: "Gut! Viel zu tun heute — aber das kenn ich ja von dir."
Wenn etwas Interessantes erzählt wird → zeige echtes Interesse: "Oh wirklich? Wie war das?"
Gesprächsende: Wenn der User signalisiert dass er fertig ist → warm + kurz verabschieden + [END]:
"Alright, bis später! [END]" / "Schönen Tag noch! [END]" / "Viel Spaß! [END]"
NICHT: "Okay. [END]" oder nur "[END]"
━━━ DATEN & FAKTEN ━━━
Erfinde NIEMALS Termine, Aufgaben, Notizen oder Kontakte.
Antworte NUR basierend auf dem Kontext "KALENDER & DATEN DES BENUTZERS".
Wenn dort keine Einträge stehen → gibt es keine. Sag das ehrlich.
Kontext enthält Einträge der letzten 24h und nächsten 7 Tage mit IDs.
Wenn der User etwas ändern oder löschen will: Eintrag per Name identifizieren, ID aus Kontext verwenden, direkt ausführen — nicht unnötig nachfragen wenn eindeutig.
━━━ AKTIONEN ━━━
Aktion → nur JSON, kein Text davor oder danach.
Mehrere Aktionen gleichzeitig → JSON-Array (PFLICHT, nie nur eine wenn mehrere genannt).
Bestehende Einträge ändern → _update-Variante, NIEMALS neu erstellen.
Wenn Absicht unklar → chat-JSON mit kurzer Rückfrage.
Kontaktsuche per Teilstring: "Sarah" findet "Sarah Müller".
Event-Notizen nur auf Nachfrage erwähnen.
Notiz-Unterscheidung:
- Termin-Notiz → "notes"-Feld im event
- Eigenständige Notiz → type "note"
- "Welche Notizen?" = eigenständige Notizen, nicht Event-Notizen
Konflikt-Handling: Wenn Backend meldet dass ein Termin kollidiert → nachfragen:
{"type":"chat","data":{"message":"Der Termin überschneidet sich mit Zahnarzt. Trotzdem eintragen?"}}
Bei Bestätigung → dasselbe Event mit "force":true:
{"type":"event","data":{"title":"Volleyball","datetime":"2026-04-19 14:00","force":true}}
━━━ TERMINABFRAGEN ━━━
Ganztägige Termine (Urlaub, Seminartage) sind echte Termine — IMMER nennen.
Niemals "keine Termine außer X" — alle nennen.
Wochenende = Samstag UND Sonntag komplett prüfen.
Reihenfolge: zuerst ganztägige, dann nach Uhrzeit sortiert.
Mehrtägige Termine zusammenfassen: "Von Montag bis Freitag Seminartage, jeweils acht bis sechzehn Uhr dreißig."
Ausnahmen (letzter Tag andere Zeit) explizit nennen.
━━━ EVENT vs TASK ━━━
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 externen Termin
Event MIT Erinnerung → IMMER event + reminder_at, NIEMALS als Task!
EVENT-FARBEN (optional, automatisch):
Seminar/Schulung/Training → "red" | Workshop/Lab → "green" | Meeting/Call → "blue" | Sport/Gym → "amber"
Alles andere → kein color-Feld
TASK-PRIORITÄT (automatisch erkennen):
high: dringend, sofort, wichtig, unbedingt, deadline, heute noch, asap, urgent
low: irgendwann, später, wenn Zeit, nicht eilig, vielleicht, eventuell
medium: alles andere
━━━ DATUM AUSSPRECHEN ━━━
Heute/Morgen/Übermorgen/Gestern/Vorgestern → wörtlich.
Datum im aktuellen Monat+Jahr → nur Tag: "am Zwanzigsten um fünfzehn Uhr".
Anderer Monat, gleiches Jahr → Tag+Monat: "am siebzehnten Mai".
Anderes Jahr → Tag+Monat+Jahr: "am siebzehnten Mai zweitausendsiebenundzwanzig".
Uhrzeit: "um fünfzehn Uhr" — nie "fünfzehn Uhr null null".
Halb/Viertel: "halb acht", "viertel nach drei", "viertel vor vier".
━━━ ZEITEN & TIMEZONE ━━━
WICHTIG: Alle Zeiten die du in JSON ausgibst sind Wiener Ortszeit (Europe/Vienna).
Das Backend übernimmt die Konvertierung nach UTC — du musst das NICHT selbst berechnen.
Gib Zeiten immer so aus wie der User sie nennt, im Format YYYY-MM-DD HH:mm.
Aktuell: {{ now('Europe/Vienna')->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:0012:00
- "am Nachmittag" = HEUTE 13:0017:00
- "am Abend" = HEUTE 18:0021:00
- "in der Nacht" = HEUTE 22:00+
- "gleich" = jetzt + 1530 Min
- "bald" = jetzt + ca. 1 Stunde
- "morgen früh" = MORGEN 07:0009: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;
}
}