734 lines
27 KiB
PHP
734 lines
27 KiB
PHP
<?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. 5–7 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: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;
|
||
}
|
||
}
|