Add AI weekly and monthly summaries with archive UI and backup support

This commit is contained in:
2026-04-14 09:57:53 +02:00
parent 0a8ccef5a7
commit 9e79e93724
10 changed files with 1538 additions and 49 deletions
+16
View File
@@ -7,6 +7,7 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
- Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern - Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern
- Vier Bereiche: Dashboard, Tracking, Optionen, Archiv - Vier Bereiche: Dashboard, Tracking, Optionen, Archiv
- Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt` - Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt`
- KI-Wochen- und Monatszusammenfassungen im Archiv mit verschlüsselter dateibasierter Ablage
- Pro Nutzer eigene Einstellungen für die Bewertungslogik - Pro Nutzer eigene Einstellungen für die Bewertungslogik
- Admin kann weitere Accounts direkt in der Weboberfläche anlegen - Admin kann weitere Accounts direkt in der Weboberfläche anlegen
- Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs - Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs
@@ -30,9 +31,24 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
## Hinweise ## Hinweise
- Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien. - Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien.
- Tagesdateien und KI-Zusammenfassungen werden serverseitig verschlüsselt gespeichert und im Backup wieder als lesbare TXT-Dateien exportiert.
- Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt. - Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt.
- Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar. - Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar.
## KI-Zusammenfassungen
- Für KI-Zusammenfassungen im Archiv wird ein OpenAI-Modell aus der Mini-Klasse verwendet.
- Der API-Key kommt aus der Server-Umgebung, das Modell und der Timeout können zusätzlich zentral durch einen Admin in den Optionen angepasst werden.
- Wochenzusammenfassungen werden als `storage/users/<user>/summaries/weekly/YYYY-KW-XX.txt` gespeichert.
- Monatszusammenfassungen werden als `storage/users/<user>/summaries/monthly/YYYY-MM.txt` gespeichert.
- Der Backup-Export nimmt diese Dateien automatisch mit und legt sie im ZIP unter `summaries/weekly/` und `summaries/monthly/` ab.
### Benötigte Umgebungsvariablen
- `OPENAI_API_KEY` (erforderlich)
- `OPENAI_MODEL` (optional, Standard: `gpt-4o-mini`)
- `OPENAI_TIMEOUT` (optional, Standard: `25`)
## Lizenz ## Lizenz
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`. - Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
+70
View File
@@ -1150,6 +1150,59 @@ input[type="range"] {
gap: 0.75rem; gap: 0.75rem;
} }
.archive-summary-section {
display: grid;
gap: 0.9rem;
margin-bottom: 1.2rem;
}
.archive-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 0.85rem;
}
.archive-summary-card {
display: grid;
gap: 0.7rem;
padding: 1rem 1.05rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.archive-summary-card__head {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: flex-start;
}
.archive-summary-card__head strong {
display: block;
margin-top: 0.35rem;
}
.summary-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.15rem;
min-height: 1.55rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: rgba(139, 228, 255, 0.16);
border: 1px solid rgba(139, 228, 255, 0.24);
color: var(--text);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
}
.chart-chip--muted {
opacity: 0.82;
}
.archive-item { .archive-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1185,6 +1238,15 @@ input[type="range"] {
flex-wrap: wrap; flex-wrap: wrap;
} }
.archive-item__actions--stack {
justify-content: flex-start;
align-items: stretch;
}
.archive-item__actions--stack form {
margin: 0;
}
.archive-action { .archive-action {
min-height: 2.4rem; min-height: 2.4rem;
padding-inline: 0.85rem; padding-inline: 0.85rem;
@@ -1242,6 +1304,10 @@ input[type="range"] {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
.note-box--summary p {
white-space: pre-wrap;
}
.note-box h4 { .note-box h4 {
margin: 0 0 0.55rem; margin: 0 0 0.55rem;
} }
@@ -1536,6 +1602,10 @@ input[type="range"] {
align-items: flex-start; align-items: flex-start;
} }
.archive-summary-grid {
grid-template-columns: 1fr;
}
.calendar-detail { .calendar-detail {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
+498 -15
View File
@@ -12,6 +12,9 @@ final class App
private Auth $auth; private Auth $auth;
private NotificationRepository $notifications; private NotificationRepository $notifications;
private WebPushService $webPush; private WebPushService $webPush;
private SummaryRepository $summaries;
private AiConfigRepository $aiConfig;
private OpenAiSummaryService $openAi;
public function __construct() public function __construct()
{ {
@@ -23,6 +26,9 @@ final class App
$this->auth = new Auth($this->users); $this->auth = new Auth($this->users);
$this->notifications = new NotificationRepository(); $this->notifications = new NotificationRepository();
$this->webPush = new WebPushService($this->notifications); $this->webPush = new WebPushService($this->notifications);
$this->summaries = new SummaryRepository();
$this->aiConfig = new AiConfigRepository();
$this->openAi = new OpenAiSummaryService($this->aiConfig);
} }
public function run(): void public function run(): void
@@ -89,7 +95,7 @@ final class App
return; return;
case '/archive': case '/archive':
$this->showArchive(); $method === 'POST' ? $this->handleArchive() : $this->showArchive();
return; return;
case '/options': case '/options':
@@ -340,8 +346,12 @@ final class App
$user = $this->requireUser(); $user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null; $selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null;
$selectedSummaryKind = isset($_GET['summary_kind']) ? (string) $_GET['summary_kind'] : null;
$selectedSummaryKey = isset($_GET['summary_key']) ? (string) $_GET['summary_key'] : null;
$entries = $this->entries->all($user['username']); $entries = $this->entries->all($user['username']);
$archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings)); $archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings));
$weeklySummaries = $this->summaries->weekly($user['username']);
$monthlySummaries = $this->summaries->monthly($user['username']);
$selectedEntry = null; $selectedEntry = null;
if ($selectedDate !== null) { if ($selectedDate !== null) {
@@ -353,16 +363,78 @@ final class App
} }
} }
$selectedSummary = null;
if ($selectedSummaryKind !== null && $selectedSummaryKey !== null) {
$selectedSummary = $this->summaries->find($user['username'], $selectedSummaryKind, $selectedSummaryKey);
}
View::render('archive', [ View::render('archive', [
'pageTitle' => 'Archiv', 'pageTitle' => 'Archiv',
'page' => 'archive', 'page' => 'archive',
'authUser' => $user, 'authUser' => $user,
'entries' => $archive, 'entries' => $archive,
'selectedEntry' => $selectedEntry, 'selectedEntry' => $selectedEntry,
'selectedSummary' => $selectedSummary,
'settings' => $settings, 'settings' => $settings,
'weeklyArchive' => $this->buildWeeklyArchiveCards($archive, $weeklySummaries),
'monthlyArchive' => $this->buildMonthlyArchiveCards($archive, $weeklySummaries, $monthlySummaries),
'aiAvailable' => $this->openAi->isAvailable(),
]); ]);
} }
private function handleArchive(): void
{
$this->enforceCsrf();
$user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
$form = (string) ($_POST['form_name'] ?? '');
try {
if ($form === 'generate_weekly_summary') {
$weekKey = trim((string) ($_POST['week_key'] ?? ''));
$context = $this->buildWeeklySummaryContext($weekKey, $entries);
$text = $this->openAi->generateWeekly($context['prompt']);
$this->summaries->save($user['username'], 'weekly', $weekKey, [
'title' => 'Wochenzusammenfassung ' . iso_week_label($weekKey),
'created_at' => date(DATE_ATOM),
'date_from' => $context['date_from'],
'date_to' => $context['date_to'],
'text' => $text,
]);
flash('success', 'Die KI-Wochenzusammenfassung wurde erstellt.');
redirect('/archive?summary_kind=weekly&summary_key=' . rawurlencode($weekKey));
}
if ($form === 'generate_monthly_summary') {
$monthKey = trim((string) ($_POST['month_key'] ?? ''));
$weeklySummaries = $this->summaries->weekly($user['username']);
$context = $this->buildMonthlySummaryContext($monthKey, $entries, $weeklySummaries);
$text = $this->openAi->generateMonthly($context['prompt']);
preg_match('/^(\d{4})-(\d{2})$/', $monthKey, $monthMatches);
$this->summaries->save($user['username'], 'monthly', $monthKey, [
'title' => 'Monatszusammenfassung ' . (string) ($monthMatches[2] ?? '') . ' / ' . (string) ($monthMatches[1] ?? ''),
'created_at' => date(DATE_ATOM),
'date_from' => $context['date_from'],
'date_to' => $context['date_to'],
'text' => $text,
]);
flash('success', 'Die KI-Monatszusammenfassung wurde erstellt.');
redirect('/archive?summary_kind=monthly&summary_key=' . rawurlencode($monthKey));
}
} catch (RuntimeException $exception) {
flash('error', $exception->getMessage());
redirect('/archive');
}
redirect('/archive');
}
private function showOptions(): void private function showOptions(): void
{ {
$user = $this->requireUser(); $user = $this->requireUser();
@@ -402,6 +474,8 @@ final class App
'pushPublicKey' => $pushPublicKey, 'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']), 'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'backupAvailable' => class_exists('ZipArchive'), 'backupAvailable' => class_exists('ZipArchive'),
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
'users' => $user['is_admin'] ? $this->users->all() : [], 'users' => $user['is_admin'] ? $this->users->all() : [],
'maxScore' => $this->scoring->evaluate([ 'maxScore' => $this->scoring->evaluate([
'mood' => 10, 'mood' => 10,
@@ -440,6 +514,12 @@ final class App
redirect('/options'); redirect('/options');
} }
if ($form === 'ai_config' && ($user['is_admin'] ?? false)) {
$this->aiConfig->save($_POST['ai'] ?? []);
flash('success', 'Die zentrale KI-Konfiguration wurde aktualisiert.');
redirect('/options');
}
if ($form === 'export_backup') { if ($form === 'export_backup') {
$settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$this->downloadUserBackup($user, $settings); $this->downloadUserBackup($user, $settings);
@@ -450,7 +530,12 @@ final class App
try { try {
$imported = $this->importUserBackup($user, $settings); $imported = $this->importUserBackup($user, $settings);
flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.'); flash(
'success',
$imported === 1
? '1 Archivobjekt wurde aus dem Backup verarbeitet.'
: $imported . ' Archivobjekte wurden aus dem Backup verarbeitet.'
);
} catch (RuntimeException $exception) { } catch (RuntimeException $exception) {
flash('error', $exception->getMessage()); flash('error', $exception->getMessage());
} }
@@ -609,6 +694,17 @@ final class App
$zip->addFromString($date . '.txt', $markdown); $zip->addFromString($date . '.txt', $markdown);
} }
foreach ($this->summaries->exportBackupFiles((string) ($user['username'] ?? '')) as $summaryFile) {
$path = trim((string) ($summaryFile['path'] ?? ''));
$content = (string) ($summaryFile['content'] ?? '');
if ($path === '' || $content === '') {
continue;
}
$zip->addFromString($path, $content);
}
$zip->close(); $zip->close();
$fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip'; $fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip';
@@ -632,6 +728,7 @@ final class App
} }
$importedEntries = []; $importedEntries = [];
$importedSummaryCount = 0;
foreach ($files as $file) { foreach ($files as $file) {
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE); $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
@@ -653,9 +750,11 @@ final class App
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($extension === 'zip') { if ($extension === 'zip') {
foreach ($this->entriesFromZip($tmpName) as $date => $entry) { $zipContent = $this->entriesFromZip($user['username'], $tmpName);
foreach ($zipContent['entries'] as $date => $entry) {
$importedEntries[$date] = $entry; $importedEntries[$date] = $entry;
} }
$importedSummaryCount += (int) ($zipContent['summaries'] ?? 0);
continue; continue;
} }
@@ -663,8 +762,13 @@ final class App
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.'); throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
} }
$date = $this->dateFromBackupFileName($name);
$content = (string) file_get_contents($tmpName); $content = (string) file_get_contents($tmpName);
if ($this->summaries->importBackupFile((string) ($user['username'] ?? ''), $name, $content)) {
$importedSummaryCount++;
continue;
}
$date = $this->dateFromBackupFileName($name);
$entry = $this->entries->parseMarkdown($content, $date); $entry = $this->entries->parseMarkdown($content, $date);
if ($entry === null) { if ($entry === null) {
@@ -674,8 +778,8 @@ final class App
$importedEntries[$date] = $entry; $importedEntries[$date] = $entry;
} }
if ($importedEntries === []) { if ($importedEntries === [] && $importedSummaryCount === 0) {
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.'); throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien oder KI-Zusammenfassungen gefunden.');
} }
$existingEntries = $this->entries->all((string) ($user['username'] ?? '')); $existingEntries = $this->entries->all((string) ($user['username'] ?? ''));
@@ -693,12 +797,14 @@ final class App
$entryMap[$date] = $entry; $entryMap[$date] = $entry;
} }
if ($importedEntries !== []) {
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap)); $this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
return count($importedEntries);
} }
private function entriesFromZip(string $path): array return count($importedEntries) + $importedSummaryCount;
}
private function entriesFromZip(string $username, string $path): array
{ {
if (!class_exists('ZipArchive')) { if (!class_exists('ZipArchive')) {
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.'); throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
@@ -712,6 +818,7 @@ final class App
} }
$entries = []; $entries = [];
$summaryCount = 0;
for ($index = 0; $index < $zip->numFiles; $index++) { for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index); $name = (string) $zip->getNameIndex($index);
@@ -720,17 +827,23 @@ final class App
} }
$baseName = basename($name); $baseName = basename($name);
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
continue;
}
$date = $this->dateFromBackupFileName($baseName);
$content = $zip->getFromIndex($index); $content = $zip->getFromIndex($index);
if (!is_string($content)) { if (!is_string($content)) {
continue; continue;
} }
if ($this->summaries->importBackupFile($username, $name, $content)) {
$summaryCount++;
continue;
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
continue;
}
$date = $this->dateFromBackupFileName($baseName);
$entry = $this->entries->parseMarkdown($content, $date); $entry = $this->entries->parseMarkdown($content, $date);
if ($entry !== null) { if ($entry !== null) {
$entries[$date] = $entry; $entries[$date] = $entry;
@@ -739,7 +852,10 @@ final class App
$zip->close(); $zip->close();
return $entries; return [
'entries' => $entries,
'summaries' => $summaryCount,
];
} }
private function dateFromBackupFileName(string $fileName): string private function dateFromBackupFileName(string $fileName): string
@@ -961,6 +1077,373 @@ final class App
return $evaluated; return $evaluated;
} }
private function buildWeeklyArchiveCards(array $entries, array $weeklySummaries): array
{
$groups = [];
foreach ($entries as $entry) {
$key = iso_week_key((string) ($entry['date'] ?? ''));
if (!isset($groups[$key])) {
$groups[$key] = [];
}
$groups[$key][] = $entry;
}
$summaryMap = [];
foreach ($weeklySummaries as $summary) {
$key = (string) ($summary['summary_key'] ?? '');
if ($key !== '') {
$summaryMap[$key] = $summary;
$groups[$key] = $groups[$key] ?? [];
}
}
$cards = [];
foreach ($groups as $key => $weekEntries) {
$range = $this->weekRangeFromKey($key);
$summary = $summaryMap[$key] ?? null;
$noteEntries = array_values(array_filter(
$weekEntries,
static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== ''
));
$cards[] = [
'summary_key' => $key,
'label' => iso_week_label($key),
'date_from' => $summary['date_from'] ?? $range['date_from'],
'date_to' => $summary['date_to'] ?? $range['date_to'],
'tracked_days' => count($weekEntries),
'note_entries_count' => count($noteEntries),
'can_generate' => count($noteEntries) >= 3,
'summary' => $summary,
'has_summary' => $summary !== null,
];
}
usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['date_to'], (string) $left['date_to']));
return $cards;
}
private function buildMonthlyArchiveCards(array $entries, array $weeklySummaries, array $monthlySummaries): array
{
$monthKeys = [];
foreach ($entries as $entry) {
$monthKeys[month_key((string) ($entry['date'] ?? ''))] = true;
}
foreach ($monthlySummaries as $summary) {
$monthKeys[(string) ($summary['summary_key'] ?? '')] = true;
}
foreach ($weeklySummaries as $summary) {
foreach ($this->monthKeysForRange((string) ($summary['date_from'] ?? ''), (string) ($summary['date_to'] ?? '')) as $monthKey) {
$monthKeys[$monthKey] = true;
}
}
$monthlySummaryMap = [];
foreach ($monthlySummaries as $summary) {
$key = (string) ($summary['summary_key'] ?? '');
if ($key !== '') {
$monthlySummaryMap[$key] = $summary;
}
}
$cards = [];
foreach (array_keys($monthKeys) as $monthKey) {
$range = $this->monthRangeFromKey($monthKey);
$monthEntries = array_values(array_filter(
$entries,
static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey
));
$monthWeeklySummaries = array_values(array_filter(
$weeklySummaries,
fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey)
));
usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? '')));
$cards[] = [
'summary_key' => $monthKey,
'label' => month_label($monthKey),
'date_from' => $range['date_from'],
'date_to' => $range['date_to'],
'tracked_days' => count($monthEntries),
'weekly_summary_count' => count($monthWeeklySummaries),
'can_generate' => count($monthWeeklySummaries) >= 2,
'summary' => $monthlySummaryMap[$monthKey] ?? null,
'has_summary' => isset($monthlySummaryMap[$monthKey]),
];
}
usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['summary_key'], (string) $left['summary_key']));
return $cards;
}
private function buildWeeklySummaryContext(string $weekKey, array $entries): array
{
$range = $this->weekRangeFromKey($weekKey);
$weekEntries = array_values(array_filter(
$entries,
static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey
));
if ($weekEntries === []) {
throw new RuntimeException('Für diese Kalenderwoche gibt es noch keine getrackten Tage.');
}
$noteEntries = array_values(array_filter(
$weekEntries,
static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== ''
));
if (count($noteEntries) < 3) {
throw new RuntimeException('Für eine KI-Wochenzusammenfassung sind mindestens 3 Texteinträge nötig.');
}
usort($weekEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
usort($noteEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
$bestDay = $this->bestEntry($weekEntries);
$worstDay = $this->worstEntry($weekEntries);
$alcoholDays = count(array_filter($weekEntries, static fn (array $entry): bool => !empty($entry['alcohol'])));
$sportDays = count(array_filter($weekEntries, static fn (array $entry): bool => (int) ($entry['sport_minutes'] ?? 0) > 0));
return [
'date_from' => $range['date_from'],
'date_to' => $range['date_to'],
'prompt' => [
'week_label' => iso_week_label($weekKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')',
'entry_count' => (string) count($noteEntries),
'tracked_days' => (string) count($weekEntries),
'avg_mood' => format_points($this->average($weekEntries, 'mood')),
'avg_stress' => format_points($this->average($weekEntries, 'stress')),
'avg_energy' => format_points($this->average($weekEntries, 'energy')),
'avg_sleep' => format_points($this->average($weekEntries, 'sleep_hours')) . ' h',
'walk_days' => (string) count(array_filter($weekEntries, static fn (array $entry): bool => walk_chart_value($entry) > 0)),
'sport_days' => (string) $sportDays,
'alcohol_days' => (string) $alcoholDays,
'best_day' => $bestDay !== null ? $this->summaryDayLabel($bestDay) : 'nicht bestimmbar',
'worst_day' => $worstDay !== null ? $this->summaryDayLabel($worstDay) : 'nicht bestimmbar',
'daily_entries' => $this->renderWeeklyDailyEntries($noteEntries),
],
];
}
private function buildMonthlySummaryContext(string $monthKey, array $entries, array $weeklySummaries): array
{
$range = $this->monthRangeFromKey($monthKey);
$monthEntries = array_values(array_filter(
$entries,
static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey
));
$monthWeeklySummaries = array_values(array_filter(
$weeklySummaries,
fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey)
));
usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? '')));
if (count($monthWeeklySummaries) < 2) {
throw new RuntimeException('Für eine KI-Monatszusammenfassung sind mindestens 2 KI-Wochenzusammenfassungen nötig.');
}
return [
'date_from' => $range['date_from'],
'date_to' => $range['date_to'],
'prompt' => [
'month_label' => month_label($monthKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')',
'weekly_summary_count' => (string) count($monthWeeklySummaries),
'avg_mood_month' => format_points($this->average($monthEntries, 'mood')),
'avg_stress_month' => format_points($this->average($monthEntries, 'stress')),
'avg_energy_month' => format_points($this->average($monthEntries, 'energy')),
'avg_sleep_month' => format_points($this->average($monthEntries, 'sleep_hours')) . ' h',
'weekly_summaries' => $this->renderMonthlyWeeklySummaries($monthWeeklySummaries, $entries),
],
];
}
private function renderWeeklyDailyEntries(array $entries): string
{
$chunks = [];
foreach ($entries as $entry) {
$details = [
'Stimmung ' . (string) ($entry['mood'] ?? 0) . '/10',
'Energie ' . (string) ($entry['energy'] ?? 0) . '/10',
'Stress ' . (string) ($entry['stress'] ?? 0) . '/10',
'Schlaf ' . format_points((float) ($entry['sleep_hours'] ?? 0)) . ' h',
'Schlafgefühl ' . (string) ($entry['sleep_feeling'] ?? 0) . '/5',
'Sport ' . (string) ((int) ($entry['sport_minutes'] ?? 0)) . ' min',
'Spaziergang ' . format_walk_value($entry),
'Alkohol ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
'Urteil ' . (string) ($entry['evaluation']['label'] ?? ''),
];
if (array_key_exists('pain', $entry) && !empty($entry['pain_enabled'])) {
$details[] = 'Schmerzen ' . (string) ($entry['pain'] ?? 0) . '/10';
}
$chunks[] = implode("\n", [
format_display_date((string) ($entry['date'] ?? ''), false) . ' (' . (string) ($entry['date'] ?? '') . ')',
implode(' · ', $details),
'Notiz: ' . trim((string) ($entry['note'] ?? '')),
]);
}
return implode("\n\n", $chunks);
}
private function renderMonthlyWeeklySummaries(array $weeklySummaries, array $entries): string
{
$blocks = [];
foreach ($weeklySummaries as $summary) {
$weekKey = (string) ($summary['summary_key'] ?? '');
$weekEntries = array_values(array_filter(
$entries,
static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey
));
$blocks[] = implode("\n", [
iso_week_label($weekKey) . ' (' . (string) ($summary['date_from'] ?? '') . ' bis ' . (string) ($summary['date_to'] ?? '') . ')',
'Wochenkennzahlen: Stimmung ' . format_points($this->average($weekEntries, 'mood'))
. ' · Stress ' . format_points($this->average($weekEntries, 'stress'))
. ' · Energie ' . format_points($this->average($weekEntries, 'energy'))
. ' · Schlaf ' . format_points($this->average($weekEntries, 'sleep_hours')) . ' h',
'KI-Wochenrückblick:',
trim((string) ($summary['text'] ?? '')),
]);
}
return implode("\n\n", $blocks);
}
private function average(array $entries, string $field): float
{
if ($entries === []) {
return 0.0;
}
$sum = 0.0;
foreach ($entries as $entry) {
$sum += (float) ($entry[$field] ?? 0);
}
return round($sum / count($entries), 1);
}
private function bestEntry(array $entries): ?array
{
if ($entries === []) {
return null;
}
usort($entries, static fn (array $left, array $right): int => ((float) ($right['evaluation']['total'] ?? 0)) <=> ((float) ($left['evaluation']['total'] ?? 0)));
return $entries[0] ?? null;
}
private function worstEntry(array $entries): ?array
{
if ($entries === []) {
return null;
}
usort($entries, static fn (array $left, array $right): int => ((float) ($left['evaluation']['total'] ?? 0)) <=> ((float) ($right['evaluation']['total'] ?? 0)));
return $entries[0] ?? null;
}
private function summaryDayLabel(array $entry): string
{
return format_display_date((string) ($entry['date'] ?? ''), false)
. ' · '
. format_points((float) ($entry['evaluation']['total'] ?? 0))
. ' Punkte'
. ' · '
. (string) ($entry['evaluation']['label'] ?? '');
}
private function weekRangeFromKey(string $weekKey): array
{
if (preg_match('/^(\d{4})-KW-(\d{2})$/', $weekKey, $matches) !== 1) {
throw new RuntimeException('Die Kalenderwoche ist ungültig.');
}
$year = (int) ($matches[1] ?? 0);
$week = (int) ($matches[2] ?? 0);
if ($week < 1 || $week > 53) {
throw new RuntimeException('Die Kalenderwoche ist ungültig.');
}
$start = (new DateTimeImmutable('now'))->setISODate($year, $week, 1);
$end = $start->modify('+6 days');
return [
'date_from' => $start->format('Y-m-d'),
'date_to' => $end->format('Y-m-d'),
];
}
private function monthRangeFromKey(string $monthKey): array
{
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $monthKey) !== 1) {
throw new RuntimeException('Der Monat ist ungültig.');
}
$current = DateTimeImmutable::createFromFormat('Y-m-d', $monthKey . '-01');
if ($current === false) {
throw new RuntimeException('Der Monat ist ungültig.');
}
return [
'date_from' => $current->format('Y-m-01'),
'date_to' => $current->modify('last day of this month')->format('Y-m-d'),
];
}
private function monthKeysForRange(string $dateFrom, string $dateTo): array
{
if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) {
return [];
}
$keys = [];
$current = DateTimeImmutable::createFromFormat('Y-m-d', $dateFrom);
$end = DateTimeImmutable::createFromFormat('Y-m-d', $dateTo);
if ($current === false || $end === false) {
return [];
}
$current = $current->modify('first day of this month');
$end = $end->modify('first day of this month');
while ($current <= $end) {
$keys[] = $current->format('Y-m');
$current = $current->modify('+1 month');
}
return $keys;
}
private function summaryOverlapsMonth(array $summary, string $monthKey): bool
{
$range = $this->monthRangeFromKey($monthKey);
$summaryFrom = (string) ($summary['date_from'] ?? '');
$summaryTo = (string) ($summary['date_to'] ?? '');
return $summaryFrom !== '' && $summaryTo !== ''
&& $summaryFrom <= $range['date_to']
&& $summaryTo >= $range['date_from'];
}
private function sendSecurityHeaders(): void private function sendSecurityHeaders(): void
{ {
header('Referrer-Policy: strict-origin-when-cross-origin'); header('Referrer-Policy: strict-origin-when-cross-origin');
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
final class AiConfigRepository
{
private string $path;
public function __construct()
{
$this->path = storage_path('system/ai.json');
}
public function get(): array
{
$saved = decode_json_file($this->path, []);
$config = array_replace($this->defaults(), is_array($saved) ? $saved : []);
$config['model'] = trim((string) ($config['model'] ?? $this->defaults()['model']));
if ($config['model'] === '') {
$config['model'] = $this->defaults()['model'];
}
$config['timeout'] = max(5, min(120, (int) ($config['timeout'] ?? $this->defaults()['timeout'])));
return $config;
}
public function save(array $input): array
{
$config = [
'model' => trim((string) ($input['model'] ?? $this->defaults()['model'])),
'timeout' => max(5, min(120, (int) ($input['timeout'] ?? $this->defaults()['timeout']))),
];
if ($config['model'] === '') {
$config['model'] = $this->defaults()['model'];
}
$directory = dirname($this->path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$bytes = file_put_contents(
$this->path,
json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($bytes === false) {
throw new RuntimeException('Die KI-Konfiguration konnte nicht gespeichert werden.');
}
return $config;
}
private function defaults(): array
{
$model = trim((string) ($_ENV['OPENAI_MODEL'] ?? getenv('OPENAI_MODEL') ?: 'gpt-4o-mini'));
$timeout = (int) ($_ENV['OPENAI_TIMEOUT'] ?? getenv('OPENAI_TIMEOUT') ?: 25);
return [
'model' => $model !== '' ? $model : 'gpt-4o-mini',
'timeout' => max(5, min(120, $timeout > 0 ? $timeout : 25)),
];
}
}
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
final class SummaryRepository
{
private EntryCrypto $crypto;
public function __construct()
{
$this->crypto = new EntryCrypto();
}
public function all(string $username): array
{
$items = array_merge(
$this->readKind($username, 'weekly'),
$this->readKind($username, 'monthly')
);
usort($items, static function (array $left, array $right): int {
$leftDate = (string) ($left['date_to'] ?? '');
$rightDate = (string) ($right['date_to'] ?? '');
$byDate = strcmp($rightDate, $leftDate);
if ($byDate !== 0) {
return $byDate;
}
return strcmp((string) ($right['summary_key'] ?? ''), (string) ($left['summary_key'] ?? ''));
});
return $items;
}
public function weekly(string $username): array
{
return $this->readKind($username, 'weekly');
}
public function monthly(string $username): array
{
return $this->readKind($username, 'monthly');
}
public function find(string $username, string $kind, string $key): ?array
{
$path = $this->pathFor($username, $kind, $key);
if (!is_file($path)) {
return null;
}
$content = (string) file_get_contents($path);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX);
}
return $this->parse($plaintext, $kind, $key);
}
public function save(string $username, string $kind, string $key, array $summary): void
{
$normalized = $this->normalizeSummary($kind, $key, $summary);
$path = $this->pathFor($username, $kind, $key);
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($path, $this->crypto->encrypt($this->toText($normalized)), LOCK_EX);
}
public function exportBackupFiles(string $username): array
{
$exports = [];
foreach (['weekly', 'monthly'] as $kind) {
foreach ($this->readKind($username, $kind) as $summary) {
$exports[] = [
'path' => 'summaries/' . $kind . '/' . (string) $summary['summary_key'] . '.txt',
'content' => $this->toText($summary),
];
}
}
return $exports;
}
public function importBackupFile(string $username, string $fileName, string $content): bool
{
$detected = $this->detectBackupFile($fileName);
if ($detected === null) {
return false;
}
$summary = $this->parse($content, $detected['kind'], $detected['key']);
if ($summary === null) {
throw new RuntimeException('Eine KI-Zusammenfassung aus dem Backup konnte nicht gelesen werden.');
}
$this->save($username, $detected['kind'], $detected['key'], $summary);
return true;
}
private function readKind(string $username, string $kind): array
{
$directory = $this->directoryFor($username, $kind);
if (!is_dir($directory)) {
return [];
}
$files = glob($directory . '/*.txt') ?: [];
rsort($files, SORT_STRING);
$summaries = [];
foreach ($files as $file) {
$key = basename($file, '.txt');
$content = (string) file_get_contents($file);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX);
}
$summary = $this->parse($plaintext, $kind, $key);
if ($summary !== null) {
$summaries[] = $summary;
}
}
return $summaries;
}
private function parse(string $content, string $kind, string $key): ?array
{
$plaintext = $this->crypto->decrypt($content);
$kind = $this->normalizeKind($kind);
if ($kind === null || !$this->isValidKey($kind, $key)) {
return null;
}
$title = $this->extract('/^Titel:\s*(.+)$/mu', $plaintext);
$type = $this->extract('/^Typ:\s*(.+)$/mu', $plaintext);
$createdAt = $this->extract('/^Erstellt am:\s*(.+)$/mu', $plaintext);
if (!preg_match('/^Zeitraum:\s*(\d{4}-\d{2}-\d{2})\s+bis\s+(\d{4}-\d{2}-\d{2})$/mu', $plaintext, $rangeMatch)) {
return null;
}
if (!preg_match('/^Zeitraum:\s*.+$\R\R([\s\S]*)\z/mu', $plaintext, $textMatch)) {
return null;
}
if ($title === null || $type === null || $createdAt === null) {
return null;
}
$expectedType = $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung';
if (trim($type) !== $expectedType) {
return null;
}
$summary = [
'summary_kind' => $kind,
'summary_key' => $key,
'title' => trim($title),
'type' => $expectedType,
'created_at' => trim($createdAt),
'date_from' => trim((string) ($rangeMatch[1] ?? '')),
'date_to' => trim((string) ($rangeMatch[2] ?? '')),
'text' => trim((string) ($textMatch[1] ?? '')),
];
if (!$this->isValidDate($summary['date_from']) || !$this->isValidDate($summary['date_to'])) {
return null;
}
return $summary;
}
private function toText(array $summary): string
{
$normalized = $this->normalizeSummary(
(string) $summary['summary_kind'],
(string) $summary['summary_key'],
$summary
);
return implode("\n", [
'Titel: ' . $normalized['title'],
'Typ: ' . $normalized['type'],
'Erstellt am: ' . $normalized['created_at'],
'Zeitraum: ' . $normalized['date_from'] . ' bis ' . $normalized['date_to'],
'',
trim((string) $normalized['text']),
'',
]);
}
private function normalizeSummary(string $kind, string $key, array $summary): array
{
$kind = $this->normalizeKind($kind);
if ($kind === null || !$this->isValidKey($kind, $key)) {
throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Schlüssel.');
}
$dateFrom = trim((string) ($summary['date_from'] ?? ''));
$dateTo = trim((string) ($summary['date_to'] ?? ''));
$createdAt = trim((string) ($summary['created_at'] ?? date(DATE_ATOM)));
$text = trim((string) ($summary['text'] ?? ''));
if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) {
throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Zeitraum.');
}
if ($text === '') {
throw new RuntimeException('Die Zusammenfassung darf nicht leer sein.');
}
return [
'summary_kind' => $kind,
'summary_key' => $key,
'title' => trim((string) ($summary['title'] ?? $this->defaultTitle($kind, $key))),
'type' => $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung',
'created_at' => $createdAt,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'text' => $text,
];
}
private function defaultTitle(string $kind, string $key): string
{
if ($kind === 'weekly' && preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches)) {
return 'Wochenzusammenfassung KW ' . $matches[2] . ' / ' . $matches[1];
}
if ($kind === 'monthly' && preg_match('/^(\d{4})-(\d{2})$/', $key, $matches)) {
return 'Monatszusammenfassung ' . $matches[2] . ' / ' . $matches[1];
}
return 'KI-Zusammenfassung';
}
private function detectBackupFile(string $fileName): ?array
{
$normalized = str_replace('\\', '/', trim($fileName));
$baseName = basename($normalized);
if (preg_match('/^(\d{4}-KW-\d{2})\.txt$/', $baseName, $matches)) {
return [
'kind' => 'weekly',
'key' => (string) $matches[1],
];
}
if (preg_match('/^(\d{4}-\d{2})\.txt$/', $baseName, $matches)) {
return [
'kind' => 'monthly',
'key' => (string) $matches[1],
];
}
return null;
}
private function directoryFor(string $username, string $kind): string
{
return storage_path('users/' . normalize_username($username) . '/summaries/' . $kind);
}
private function pathFor(string $username, string $kind, string $key): string
{
return $this->directoryFor($username, $kind) . '/' . $key . '.txt';
}
private function normalizeKind(string $kind): ?string
{
$kind = trim($kind);
return in_array($kind, ['weekly', 'monthly'], true) ? $kind : null;
}
private function isValidKey(string $kind, string $key): bool
{
if ($kind === 'weekly' && preg_match('/^\d{4}-KW-(\d{2})$/', $key, $matches) === 1) {
$week = (int) ($matches[1] ?? 0);
return $week >= 1 && $week <= 53;
}
if ($kind === 'monthly' && preg_match('/^\d{4}-(\d{2})$/', $key, $matches) === 1) {
$month = (int) ($matches[1] ?? 0);
return $month >= 1 && $month <= 12;
}
return false;
}
private function isValidDate(string $date): bool
{
$parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date);
return $parsed !== false && $parsed->format('Y-m-d') === $date;
}
private function extract(string $pattern, string $content): ?string
{
if (preg_match($pattern, $content, $matches) !== 1) {
return null;
}
return trim((string) ($matches[1] ?? ''));
}
}
+283
View File
@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
final class OpenAiSummaryService
{
private const CHAT_COMPLETIONS_ENDPOINT = 'https://api.openai.com/v1/chat/completions';
private const WEEK_SYSTEM_PROMPT = <<<'TEXT'
Du bist ein verhaltenstherapeutisch orientierter Assistent für einen persönlichen Mood-Tracker.
Deine Aufgabe ist es, aus den Einträgen einer Kalenderwoche eine psychologisch plausible, ruhige und hilfreiche Wochenzusammenfassung zu schreiben. Du sollst Muster erkennen, Belastungen benennen, Ressourcen sichtbar machen und die Tagebuchtexte mit den Gefühlswerten in Beziehung setzen.
Wichtige Regeln:
- Schreibe empathisch, klar, ruhig und konkret.
- Schreibe in natürlichem Fließtext.
- Nutze sowohl die Stimmungswerte als auch die Tagebuchtexte.
- Stelle Zusammenhänge zwischen Gefühlsbild und beschriebenen Ereignissen her.
- Benenne auffällige Schwankungen, Belastungen und stabilisierende Faktoren.
- Hebe kleine Zeichen von Selbstwirksamkeit, Struktur, Entlastung oder Überforderung hervor.
- Formuliere vorsichtig und nachvollziehbar.
- Stelle keine Diagnosen.
- Verwende kein übertriebenes Therapievokabular.
- Verharmlose Warnsignale nicht.
- Dramatisiere aber auch nicht.
- Erfinde nichts, was nicht in den Daten steht.
- Wenn die Datenlage lückenhaft ist, erwähne das kurz.
- Keine Listen.
- Keine Emojis.
- Keine Kalendersprüche.
- Keine direkten Handlungsanweisungen im Befehlston.
- Lobe, wenn weniger al s2 mal Alkohol getrunken habe, mache aufmerksam, wenn es mehr als dreimal ist, weil nicht gut.
- Motiviere, wenn die WOche weniger als 2 mal Sport gemacht wurde, für die kommende Woche.
Die Zusammenfassung soll so klingen wie ein ruhiger verhaltenstherapeutischer Wochenrückblick.
Am Ende soll eine vorsichtige Einordnung stehen, worauf der Fokus als Nächstes sinnvollerweise liegen könnte, zum Beispiel eher Stabilisierung, Struktur, Entlastung oder Aktivierung.
Länge: etwa 250 bis 450 Wörter.
TEXT;
private const WEEK_USER_TEMPLATE = <<<'TEXT'
Bitte schreibe eine Wochenzusammenfassung für den folgenden Zeitraum.
Voraussetzung:
Es liegen mindestens 3 ausgefüllte Tagebucheinträge in dieser Woche vor.
Zeitraum:
{{WEEK_LABEL}}
Wochendaten:
- Anzahl ausgefüllter Einträge: {{ENTRY_COUNT}}
- Getrackte Tage insgesamt: {{TRACKED_DAYS}}
- Durchschnittliche Stimmung: {{AVG_MOOD}}
- Durchschnittlicher Stress: {{AVG_STRESS}}
- Durchschnittliche Energie: {{AVG_ENERGY}}
- Durchschnittlicher Schlaf: {{AVG_SLEEP}}
- Anzahl Spaziergänge: {{WALK_DAYS}}
- Anzahl Sporttage: {{SPORT_DAYS}}
- Alkoholtage: {{ALCOHOL_DAYS}}
- Bester Tag: {{BEST_DAY}}
- Schwerster Tag: {{WORST_DAY}}
Tägliche Einträge:
{{DAILY_ENTRIES}}
Wichtige Aufgabe:
Verbinde die beschriebenen Texte mit dem Gefühlsbild der Woche. Arbeite heraus, welche Ereignisse, Beziehungen, Aktivitäten oder Gedankenlagen mit besseren oder schlechteren Tagen zusammenhängen könnten. Achte dabei besonders auf wiederkehrende Belastungen, emotionale Schwankungen und stabilisierende Momente.
Wichtige Zusatzregeln:
- Wenn mehr als dreimal Alkohol getrunken wurde, sprich klar, aber ruhig an, dass das dem Wohlbefinden eher nicht guttut.
- Wenn in dieser Woche kein Sport gemacht wurde, formuliere zum Schluss eine vorsichtige, motivierende Aktivierung für die kommende Woche.
- Bleibe optimistisch, wohlwollend und nicht anklagend.
Schreibe einen zusammenhängenden Fließtext mit etwa 250 bis 450 Wörtern.
TEXT;
private const MONTH_SYSTEM_PROMPT = <<<'TEXT'
Du bist ein verhaltenstherapeutisch orientierter Assistent für einen persönlichen Mood-Tracker.
Deine Aufgabe ist es, aus bereits vorhandenen KI-Wochenzusammenfassungen eine Monatszusammenfassung zu erstellen. Du sollst keine Tagesdetails neu auswerten, sondern die vorhandenen Wochenrückblicke verdichten, Muster über mehrere Wochen erkennen und einen übergeordneten Verlauf beschreiben.
Wichtige Regeln:
- Schreibe empathisch, klar, ruhig und konkret.
- Schreibe in natürlichem Fließtext.
- Arbeite nur mit den vorliegenden Wochenzusammenfassungen und den zugehörigen Wochenkennzahlen.
- Suche nach Entwicklungen über den Monat hinweg: Stabilisierung, Verschlechterung, Schwankungen, wiederkehrende Konflikte, Ressourcen, Belastungsschwerpunkte.
- Stelle keine Diagnosen.
- Kein Fachjargon-Overkill.
- Keine Listen.
- Keine Emojis.
- Keine Floskeln.
- Erfinde nichts, was nicht aus den Wochenzusammenfassungen ableitbar ist.
- Wenn die Datengrundlage schmal ist, erwähne das kurz.
Am Ende soll eine vorsichtige therapeutische Einordnung stehen, was sich im Monatsverlauf als besonders relevant gezeigt hat.
Länge: etwa 300 bis 500 Wörter.
TEXT;
private const MONTH_USER_TEMPLATE = <<<'TEXT'
Bitte schreibe eine Monatszusammenfassung für den folgenden Zeitraum.
Voraussetzung:
Es liegen mindestens 2 bereits erzeugte KI-Wochenzusammenfassungen für diesen Monat vor.
Zeitraum:
{{MONTH_LABEL}}
Monatsdaten:
- Anzahl verfügbarer KI-Wochenzusammenfassungen: {{WEEKLY_SUMMARY_COUNT}}
- Durchschnittliche Stimmung im Monat: {{AVG_MOOD_MONTH}}
- Durchschnittlicher Stress im Monat: {{AVG_STRESS_MONTH}}
- Durchschnittliche Energie im Monat: {{AVG_ENERGY_MONTH}}
- Durchschnittlicher Schlaf im Monat: {{AVG_SLEEP_MONTH}}
Vorliegende KI-Wochenzusammenfassungen:
{{WEEKLY_SUMMARIES}}
Aufgabe:
Verdichte die Wochenzusammenfassungen zu einem stimmigen Monatsrückblick. Beschreibe, welche Muster sich über mehrere Wochen zeigen, welche Belastungen besonders prägend waren, welche Ressourcen erkennbar wurden und ob sich eher Stabilisierung, Zuspitzung oder starke Schwankung zeigt.
Schreibe einen zusammenhängenden Fließtext mit etwa 300 bis 500 Wörtern.
TEXT;
private AiConfigRepository $config;
public function __construct(AiConfigRepository $config)
{
$this->config = $config;
}
public function configuration(): array
{
$config = $this->config->get();
return [
'model' => (string) ($config['model'] ?? 'gpt-4o-mini'),
'timeout' => (int) ($config['timeout'] ?? 25),
'has_api_key' => $this->apiKey() !== '',
'available' => $this->isAvailable(),
];
}
public function isAvailable(): bool
{
return function_exists('curl_init') && $this->apiKey() !== '';
}
public function generateWeekly(array $payload): string
{
return $this->requestSummary(
self::WEEK_SYSTEM_PROMPT,
$this->renderTemplate(self::WEEK_USER_TEMPLATE, $payload)
);
}
public function generateMonthly(array $payload): string
{
return $this->requestSummary(
self::MONTH_SYSTEM_PROMPT,
$this->renderTemplate(self::MONTH_USER_TEMPLATE, $payload)
);
}
private function requestSummary(string $systemPrompt, string $userPrompt): string
{
if (!function_exists('curl_init')) {
throw new RuntimeException('Die KI-Zusammenfassung ist auf diesem Server aktuell nicht verfügbar.');
}
$apiKey = $this->apiKey();
if ($apiKey === '') {
throw new RuntimeException('Für KI-Zusammenfassungen fehlt der OpenAI API-Key.');
}
$config = $this->config->get();
$body = json_encode([
'model' => (string) ($config['model'] ?? 'gpt-4o-mini'),
'messages' => [
[
'role' => 'system',
'content' => $systemPrompt,
],
[
'role' => 'user',
'content' => $userPrompt,
],
],
'temperature' => 0.8,
'max_completion_tokens' => 900,
'store' => false,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($body)) {
throw new RuntimeException('Die KI-Anfrage konnte nicht vorbereitet werden.');
}
$handle = curl_init(self::CHAT_COMPLETIONS_ENDPOINT);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zur KI konnte nicht vorbereitet werden.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_TIMEOUT => (int) ($config['timeout'] ?? 25),
]);
$responseBody = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($responseBody === false || $error !== '') {
throw new RuntimeException('Die KI-Anfrage ist fehlgeschlagen oder hat zu lange gedauert.');
}
$decoded = json_decode((string) $responseBody, true);
if (!is_array($decoded)) {
throw new RuntimeException('Die KI-Antwort konnte nicht gelesen werden.');
}
if ($status < 200 || $status >= 300) {
$message = trim((string) ($decoded['error']['message'] ?? ''));
if ($message === '') {
$message = 'Die KI-Anfrage konnte gerade nicht verarbeitet werden.';
}
throw new RuntimeException($message);
}
$text = trim($this->extractResponseText($decoded));
if ($text === '') {
throw new RuntimeException('Die KI hat keine verwertbare Zusammenfassung zurückgegeben.');
}
return $text;
}
private function extractResponseText(array $response): string
{
$content = $response['choices'][0]['message']['content'] ?? null;
if (is_string($content)) {
return $content;
}
if (is_array($content)) {
$parts = [];
foreach ($content as $item) {
if (is_string($item)) {
$parts[] = $item;
continue;
}
if (is_array($item) && is_string($item['text'] ?? null)) {
$parts[] = (string) $item['text'];
}
}
return trim(implode("\n\n", array_filter($parts)));
}
return '';
}
private function renderTemplate(string $template, array $payload): string
{
$replacements = [];
foreach ($payload as $key => $value) {
$replacements['{{' . strtoupper((string) $key) . '}}'] = is_scalar($value)
? (string) $value
: '';
}
return strtr($template, $replacements);
}
private function apiKey(): string
{
return trim((string) ($_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY') ?: ''));
}
}
+3
View File
@@ -6,11 +6,14 @@ require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php'; require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.php'; require __DIR__ . '/Support/Auth.php';
require __DIR__ . '/Support/EntryCrypto.php'; require __DIR__ . '/Support/EntryCrypto.php';
require __DIR__ . '/Support/OpenAiSummaryService.php';
require __DIR__ . '/Support/View.php'; require __DIR__ . '/Support/View.php';
require __DIR__ . '/Support/WebPushService.php'; require __DIR__ . '/Support/WebPushService.php';
require __DIR__ . '/Domain/AiConfigRepository.php';
require __DIR__ . '/Domain/UserRepository.php'; require __DIR__ . '/Domain/UserRepository.php';
require __DIR__ . '/Domain/SettingsRepository.php'; require __DIR__ . '/Domain/SettingsRepository.php';
require __DIR__ . '/Domain/EntryRepository.php'; require __DIR__ . '/Domain/EntryRepository.php';
require __DIR__ . '/Domain/SummaryRepository.php';
require __DIR__ . '/Domain/LoginThrottle.php'; require __DIR__ . '/Domain/LoginThrottle.php';
require __DIR__ . '/Domain/NotificationRepository.php'; require __DIR__ . '/Domain/NotificationRepository.php';
require __DIR__ . '/Domain/ScoringService.php'; require __DIR__ . '/Domain/ScoringService.php';
+81
View File
@@ -197,6 +197,87 @@ function format_display_date(string $date, bool $withWeekday = true): string
return $weekdays[(int) $current->format('w')] . ', ' . $label; return $weekdays[(int) $current->format('w')] . ', ' . $label;
} }
function format_display_datetime(string $value): string
{
try {
$current = new DateTimeImmutable($value);
} catch (Throwable) {
return $value;
}
$months = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
return $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y') . ' um ' . $current->format('H:i');
}
function iso_week_key(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return date('o-\K\W-W');
}
return $current->format('o-\K\W-W');
}
function month_key(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return date('Y-m');
}
return $current->format('Y-m');
}
function iso_week_label(string $key): string
{
if (preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches) === 1) {
return 'KW ' . $matches[2] . ' / ' . $matches[1];
}
return $key;
}
function month_label(string $key): string
{
if (preg_match('/^(\d{4})-(\d{2})$/', $key, $matches) !== 1) {
return $key;
}
$months = [
'01' => 'Januar',
'02' => 'Februar',
'03' => 'März',
'04' => 'April',
'05' => 'Mai',
'06' => 'Juni',
'07' => 'Juli',
'08' => 'August',
'09' => 'September',
'10' => 'Oktober',
'11' => 'November',
'12' => 'Dezember',
];
return ($months[$matches[2]] ?? $matches[2]) . ' ' . $matches[1];
}
function icon_path(string $name): string function icon_path(string $name): string
{ {
return '/assets/icons/' . $name . '.svg'; return '/assets/icons/' . $name . '.svg';
+137 -3
View File
@@ -3,11 +3,132 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<p class="eyebrow">Archiv</p> <p class="eyebrow">Archiv</p>
<h3>Alle gespeicherten Tage</h3> <h3>KI-Rückblicke und gespeicherte Tage</h3>
</div> </div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span> <span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
</div> </div>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">KI</p>
<h4>Monatszusammenfassungen</h4>
</div>
<?php if (empty($aiAvailable)): ?>
<span class="chart-chip chart-chip--muted">API nicht bereit</span>
<?php endif; ?>
</div>
<?php if ($monthlyArchive === []): ?>
<p class="empty-state">Sobald genügend Wochenzusammenfassungen vorliegen, erscheinen hier die Monatsrückblicke.</p>
<?php else: ?>
<div class="archive-summary-grid">
<?php foreach ($monthlyArchive as $month): ?>
<article class="archive-summary-card">
<div class="archive-summary-card__head">
<div>
<span class="summary-badge">KI</span>
<strong><?= e($month['label']) ?></strong>
</div>
<?php if (!empty($month['has_summary'])): ?>
<span class="chart-chip">vorhanden</span>
<?php else: ?>
<span class="chart-chip chart-chip--muted">offen</span>
<?php endif; ?>
</div>
<p class="helper-text"><?= e($month['date_from']) ?> bis <?= e($month['date_to']) ?></p>
<p class="helper-text"><?= e((string) $month['weekly_summary_count']) ?> KI-Wochenzusammenfassungen im Monat verfügbar</p>
<?php if (!empty($month['summary'])): ?>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $month['summary']['created_at'])) ?></p>
<?php else: ?>
<p class="helper-text">Mindestens 2 KI-Wochenzusammenfassungen nötig.</p>
<?php endif; ?>
<div class="archive-item__actions archive-item__actions--stack">
<?php if (!empty($month['summary'])): ?>
<a class="ghost-link archive-action" href="/archive?summary_kind=monthly&amp;summary_key=<?= e(rawurlencode((string) $month['summary_key'])) ?>">Öffnen</a>
<?php endif; ?>
<form method="post" action="/archive">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_monthly_summary">
<input type="hidden" name="month_key" value="<?= e((string) $month['summary_key']) ?>">
<button class="ghost-button ghost-button--small" type="submit" <?= !$month['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>
<?= !empty($month['has_summary']) ? 'Neu generieren' : 'KI-Monatszusammenfassung erzeugen' ?>
</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">KI</p>
<h4>Wochenzusammenfassungen</h4>
</div>
</div>
<?php if ($weeklyArchive === []): ?>
<p class="empty-state">Noch keine Wochen verfügbar. Sobald Einträge vorliegen, kannst du hier Wochenrückblicke erzeugen.</p>
<?php else: ?>
<div class="archive-summary-grid">
<?php foreach ($weeklyArchive as $week): ?>
<article class="archive-summary-card">
<div class="archive-summary-card__head">
<div>
<span class="summary-badge">KI</span>
<strong><?= e($week['label']) ?></strong>
</div>
<?php if (!empty($week['has_summary'])): ?>
<span class="chart-chip">vorhanden</span>
<?php else: ?>
<span class="chart-chip chart-chip--muted">offen</span>
<?php endif; ?>
</div>
<p class="helper-text"><?= e($week['date_from']) ?> bis <?= e($week['date_to']) ?></p>
<p class="helper-text"><?= e((string) $week['note_entries_count']) ?> Texteinträge · <?= e((string) $week['tracked_days']) ?> getrackte Tage</p>
<?php if (!empty($week['summary'])): ?>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $week['summary']['created_at'])) ?></p>
<?php else: ?>
<p class="helper-text">Mindestens 3 Texteinträge nötig.</p>
<?php endif; ?>
<div class="archive-item__actions archive-item__actions--stack">
<?php if (!empty($week['summary'])): ?>
<a class="ghost-link archive-action" href="/archive?summary_kind=weekly&amp;summary_key=<?= e(rawurlencode((string) $week['summary_key'])) ?>">Öffnen</a>
<?php endif; ?>
<form method="post" action="/archive">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_weekly_summary">
<input type="hidden" name="week_key" value="<?= e((string) $week['summary_key']) ?>">
<button class="ghost-button ghost-button--small" type="submit" <?= !$week['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>
<?= !empty($week['has_summary']) ? 'Neu generieren' : 'KI-Wochenzusammenfassung erzeugen' ?>
</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">Tage</p>
<h4>Alle gespeicherten Tage</h4>
</div>
</div>
<?php if ($entries === []): ?> <?php if ($entries === []): ?>
<p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p> <p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
<?php else: ?> <?php else: ?>
@@ -40,10 +161,23 @@
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</section>
</article> </article>
<aside class="stack-column"> <aside class="stack-column">
<?php if ($selectedEntry !== null): ?> <?php if ($selectedSummary !== null): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">KI-Zusammenfassung</p>
<h3><?= e($selectedSummary['title']) ?></h3>
<p class="hero-label"><?= e($selectedSummary['date_from']) ?> bis <?= e($selectedSummary['date_to']) ?></p>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $selectedSummary['created_at'])) ?></p>
<div class="note-box note-box--summary">
<h4>Text</h4>
<p><?= e($selectedSummary['text']) ?></p>
</div>
</article>
<?php elseif ($selectedEntry !== null): ?>
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Ausgewählt</p> <p class="eyebrow">Ausgewählt</p>
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3> <h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
@@ -91,7 +225,7 @@
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Details</p> <p class="eyebrow">Details</p>
<h3>Archivansicht</h3> <h3>Archivansicht</h3>
<p>Wähle links einen Tag aus, um alle Werte anzuzeigen oder direkt in die Bearbeitung zu springen.</p> <p>Wähle links einen Tag oder eine KI-Zusammenfassung aus. Wochenrückblicke benötigen mindestens 3 Texteinträge, Monatsrückblicke mindestens 2 vorhandene KI-Wochenzusammenfassungen.</p>
</article> </article>
<?php endif; ?> <?php endif; ?>
</aside> </aside>
+31
View File
@@ -390,6 +390,37 @@
</article> </article>
<?php if (!empty($authUser['is_admin'])): ?> <?php if (!empty($authUser['is_admin'])): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">KI</p>
<h3>OpenAI für Zusammenfassungen</h3>
<p class="helper-text">Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.</p>
<?php if (!empty($aiStatus)): ?>
<div class="user-list">
<div class="user-row">
<strong>API-Key</strong>
<span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span>
</div>
<div class="user-row">
<strong>Aktuelles Modell</strong>
<span><?= e((string) ($aiStatus['model'] ?? '')) ?></span>
</div>
<div class="user-row">
<strong>Timeout</strong>
<span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span>
</div>
</div>
<?php endif; ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="ai_config">
<label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label>
<label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label>
<button class="primary-button" type="submit">KI-Konfiguration speichern</button>
</form>
</article>
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Mehrere Accounts</p> <p class="eyebrow">Mehrere Accounts</p>
<h3>Neuen Nutzer anlegen</h3> <h3>Neuen Nutzer anlegen</h3>