Compare commits
6 Commits
V1.2.1
...
297f63c7d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 297f63c7d5 | |||
| 889f5ffa8a | |||
| 41183f04db | |||
| 796e5b23d2 | |||
| af84243866 | |||
| 9e79e93724 |
@@ -7,6 +7,7 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
|
||||
- Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern
|
||||
- Vier Bereiche: Dashboard, Tracking, Optionen, Archiv
|
||||
- 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
|
||||
- Admin kann weitere Accounts direkt in der Weboberfläche anlegen
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
|
||||
|
||||
@@ -1150,6 +1150,59 @@ input[type="range"] {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1185,6 +1238,15 @@ input[type="range"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.archive-item__actions--stack {
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.archive-item__actions--stack form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.archive-action {
|
||||
min-height: 2.4rem;
|
||||
padding-inline: 0.85rem;
|
||||
@@ -1242,6 +1304,10 @@ input[type="range"] {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.note-box--summary p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.note-box h4 {
|
||||
margin: 0 0 0.55rem;
|
||||
}
|
||||
@@ -1536,6 +1602,10 @@ input[type="range"] {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.archive-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calendar-detail {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
+498
-15
@@ -12,6 +12,9 @@ final class App
|
||||
private Auth $auth;
|
||||
private NotificationRepository $notifications;
|
||||
private WebPushService $webPush;
|
||||
private SummaryRepository $summaries;
|
||||
private AiConfigRepository $aiConfig;
|
||||
private OpenAiSummaryService $openAi;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -23,6 +26,9 @@ final class App
|
||||
$this->auth = new Auth($this->users);
|
||||
$this->notifications = new NotificationRepository();
|
||||
$this->webPush = new WebPushService($this->notifications);
|
||||
$this->summaries = new SummaryRepository();
|
||||
$this->aiConfig = new AiConfigRepository();
|
||||
$this->openAi = new OpenAiSummaryService($this->aiConfig);
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
@@ -89,7 +95,7 @@ final class App
|
||||
return;
|
||||
|
||||
case '/archive':
|
||||
$this->showArchive();
|
||||
$method === 'POST' ? $this->handleArchive() : $this->showArchive();
|
||||
return;
|
||||
|
||||
case '/options':
|
||||
@@ -340,8 +346,12 @@ final class App
|
||||
$user = $this->requireUser();
|
||||
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||
$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']);
|
||||
$archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings));
|
||||
$weeklySummaries = $this->summaries->weekly($user['username']);
|
||||
$monthlySummaries = $this->summaries->monthly($user['username']);
|
||||
|
||||
$selectedEntry = 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', [
|
||||
'pageTitle' => 'Archiv',
|
||||
'page' => 'archive',
|
||||
'authUser' => $user,
|
||||
'entries' => $archive,
|
||||
'selectedEntry' => $selectedEntry,
|
||||
'selectedSummary' => $selectedSummary,
|
||||
'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
|
||||
{
|
||||
$user = $this->requireUser();
|
||||
@@ -402,6 +474,8 @@ final class App
|
||||
'pushPublicKey' => $pushPublicKey,
|
||||
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
|
||||
'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() : [],
|
||||
'maxScore' => $this->scoring->evaluate([
|
||||
'mood' => 10,
|
||||
@@ -440,6 +514,12 @@ final class App
|
||||
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') {
|
||||
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||
$this->downloadUserBackup($user, $settings);
|
||||
@@ -450,7 +530,12 @@ final class App
|
||||
|
||||
try {
|
||||
$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) {
|
||||
flash('error', $exception->getMessage());
|
||||
}
|
||||
@@ -609,6 +694,17 @@ final class App
|
||||
$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();
|
||||
|
||||
$fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip';
|
||||
@@ -632,6 +728,7 @@ final class App
|
||||
}
|
||||
|
||||
$importedEntries = [];
|
||||
$importedSummaryCount = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||
@@ -653,9 +750,11 @@ final class App
|
||||
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
|
||||
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;
|
||||
}
|
||||
$importedSummaryCount += (int) ($zipContent['summaries'] ?? 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -663,8 +762,13 @@ final class App
|
||||
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
|
||||
}
|
||||
|
||||
$date = $this->dateFromBackupFileName($name);
|
||||
$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);
|
||||
|
||||
if ($entry === null) {
|
||||
@@ -674,8 +778,8 @@ final class App
|
||||
$importedEntries[$date] = $entry;
|
||||
}
|
||||
|
||||
if ($importedEntries === []) {
|
||||
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.');
|
||||
if ($importedEntries === [] && $importedSummaryCount === 0) {
|
||||
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien oder KI-Zusammenfassungen gefunden.');
|
||||
}
|
||||
|
||||
$existingEntries = $this->entries->all((string) ($user['username'] ?? ''));
|
||||
@@ -693,12 +797,14 @@ final class App
|
||||
$entryMap[$date] = $entry;
|
||||
}
|
||||
|
||||
if ($importedEntries !== []) {
|
||||
$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')) {
|
||||
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
|
||||
@@ -712,6 +818,7 @@ final class App
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
$summaryCount = 0;
|
||||
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$name = (string) $zip->getNameIndex($index);
|
||||
@@ -720,17 +827,23 @@ final class App
|
||||
}
|
||||
|
||||
$baseName = basename($name);
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->dateFromBackupFileName($baseName);
|
||||
$content = $zip->getFromIndex($index);
|
||||
|
||||
if (!is_string($content)) {
|
||||
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);
|
||||
if ($entry !== null) {
|
||||
$entries[$date] = $entry;
|
||||
@@ -739,7 +852,10 @@ final class App
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $entries;
|
||||
return [
|
||||
'entries' => $entries,
|
||||
'summaries' => $summaryCount,
|
||||
];
|
||||
}
|
||||
|
||||
private function dateFromBackupFileName(string $fileName): string
|
||||
@@ -961,6 +1077,373 @@ final class App
|
||||
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
|
||||
{
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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] ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<?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 ruhige, dichte und psychologisch plausible Wochenzusammenfassung zu schreiben. Du sollst Muster erkennen, Belastungen benennen, Ressourcen sichtbar machen und die Tagebuchtexte mit dem Gefühlsbild der Woche in Beziehung setzen.
|
||||
|
||||
Die Zusammenfassung soll nicht wie ein Tagebuch, nicht wie ein Bericht und nicht wie ein Ratgebertext klingen, sondern wie eine verdichtete persönliche Einordnung der Woche.
|
||||
|
||||
Verbindliche Stilregeln:
|
||||
- Schreibe konsequent in der Du-Form.
|
||||
- Schreibe in natürlichem, ruhigem Fließtext.
|
||||
- Schreibe dicht, konkret, unaufgeregt und persönlich.
|
||||
- Klinge reflektiert, aber nicht klinisch.
|
||||
- Klinge verhaltenstherapeutisch orientiert, aber nicht wie ein Therapiebericht.
|
||||
|
||||
Inhaltliche Regeln:
|
||||
- Nutze sowohl die Tagebuchtexte als auch die Stimmungs- und Belastungswerte.
|
||||
- Übersetze Werte und Skalen in sprachliche Einordnungen wie „stark schwankend“, „deutlich belastet“, „wenig erholt“, „etwas stabiler“ oder „spürbar entlastet“.
|
||||
- Nenne keine konkreten Zahlenwerte im Fließtext.
|
||||
- Nenne keine konkreten Kalenderdaten im Fließtext.
|
||||
- Wenn zeitliche Orientierung wirklich nötig ist, nutze höchstens Formulierungen wie „zu Wochenbeginn“, „zur Wochenmitte“ oder „gegen Ende der Woche“.
|
||||
- Schreibe nicht chronologisch und gehe nicht Tag für Tag durch die Woche.
|
||||
- Verdichte stattdessen die Woche zu Mustern, Spannungen, Auslösern, Belastungen, Gegenpolen und stabilisierenden Momenten.
|
||||
- Einzelne Tage sollen nur erwähnt werden, wenn sie für das Verständnis der ganzen Woche wirklich zentral sind.
|
||||
- Beschreibe nicht nur, was passiert ist, sondern ordne ein, wie es auf das Erleben gewirkt hat.
|
||||
- Benenne Belastungen klar, ohne zu dramatisieren.
|
||||
- Benenne Ressourcen klar, ohne sie künstlich aufzuwerten.
|
||||
- Verharmlose Warnsignale nicht.
|
||||
- Erfinde nichts, was nicht aus den Daten ableitbar ist.
|
||||
- Wenn die Datenlage lückenhaft ist, erwähne das kurz und unaufgeregt.
|
||||
|
||||
Was vermieden werden soll:
|
||||
- Keine Listen.
|
||||
- Keine Emojis.
|
||||
- Keine Kalendersprüche.
|
||||
- Keine direkten Handlungsanweisungen im Befehlston.
|
||||
- Keine pauschalen Beziehungstipps.
|
||||
- Keine künstlich optimistischen Schlüsse über die Beziehung.
|
||||
- Keine klinisch-distanzierten Formulierungen wie „deine Einträge zeigen“, „die durchschnittliche Stimmung betrug“ oder „es äußerten sich deutliche Schwankungen“.
|
||||
- Keine formelhaften Sätze wie „es ist wichtig zu erkennen“, „es ist verständlich“, „es wäre hilfreich“, „könnte helfen“ oder „Zeichen von Selbstwirksamkeit“, wenn sie nicht wirklich natürlich klingen.
|
||||
- Kein schulbuchhafter Ton.
|
||||
- Kein Auswertungs- oder Gutachtenstil.
|
||||
- Vermeide weichgespülte oder formelhafte Wendungen wie „es zeigt sich, dass“, „es bleibt zu beobachten“, „könnte hilfreich sein“, „könnte wertvoll sein“, „könnte als Belastungsfaktor wahrgenommen werden“ oder „es ist spürbar“.
|
||||
- Schreibe nicht wie ein psychologischer Infotext, sondern wie eine dichte persönliche Einordnung.
|
||||
- Vermeide allgemeine Schlussformeln über „Ressourcen“, „Dynamiken“ oder „Rituale“, wenn sie nicht konkret aus der Woche entstehen.
|
||||
|
||||
Zusätzliche Regeln:
|
||||
- Formuliere klarer und direkter, ohne hart oder anklagend zu werden.
|
||||
- Du kannst den Nutzer ruhig mit du ansprechen, um persönlicher zu wirken
|
||||
- Wenn an weniger als 2 Tagen Alkohol eingetragen wurde, erwähne das höchstens knapp und ohne Warnung.
|
||||
- Wenn an 3 oder mehr Tagen Alkohol eingetragen wurde, benenne das ruhig als möglichen Belastungsfaktor.
|
||||
- Wenn in dieser Woche weniger als 2 Mal Sport gemacht wurde, darf am Ende höchstens ein kleiner, alltagsnaher und motivierender Impuls für die kommende Woche stehen.
|
||||
- Dieser Impuls soll kurz bleiben und nicht wie ein Ratschlagstext klingen.
|
||||
|
||||
Aufbau:
|
||||
- Beginne mit einer knappen Einordnung des Gesamtmusters der Woche.
|
||||
- Verdichte danach die wichtigsten Belastungen und Gegenpole.
|
||||
- Schließe mit einer kurzen, vorsichtigen Einordnung, was für die nächste Woche eher im Vordergrund stehen könnte, zum Beispiel Stabilisierung, Entlastung, Struktur oder Aktivierung.
|
||||
- Diese Schlusspassage soll beobachtend klingen, nicht belehrend.
|
||||
|
||||
Länge: etwa 180 bis 280 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}}
|
||||
|
||||
Aufgabe:
|
||||
Schreibe keine tagebuchartige oder chronologische Nacherzählung. Fasse die Woche als Gesamtbild zusammen. Arbeite heraus, welche Belastungen, Konflikte, Aktivitäten, Gedankenlagen oder kleinen Gegenpole das Erleben geprägt haben und wie sie mit dem Gefühlsbild der Woche zusammenhängen.
|
||||
|
||||
Wichtige Vorgaben:
|
||||
- Verwandle Zahlen und Skalenwerte in sprachliche Einordnungen, statt sie direkt zu nennen.
|
||||
- Verwende keine konkreten Datumsangaben.
|
||||
- Schreibe nicht Tag für Tag.
|
||||
- Verdichte Muster statt Abläufe.
|
||||
- Wenn überhaupt zeitliche Einordnung nötig ist, nutze höchstens Formulierungen wie „zu Wochenbeginn“, „zur Wochenmitte“ oder „gegen Ende der Woche“.
|
||||
- Gib keine pauschalen Beziehungstipps.
|
||||
- Bleibe wohlwollend, ruhig und klar.
|
||||
- Klinge nicht klinisch und nicht schulbuchhaft.
|
||||
- Vermeide Floskeln und Standardformulierungen aus Ratgeber- oder Therapietexten.
|
||||
- Wenn an weniger als 2 Tagen Alkohol eingetragen wurde, erwähne das höchstens knapp und ohne Warnung.
|
||||
- Wenn an 3 oder mehr Tagen Alkohol eingetragen wurde, benenne das ruhig als möglichen Belastungsfaktor.
|
||||
- Wenn in dieser Woche weniger als 2 Mal Sport gemacht wurde, formuliere am Ende höchstens einen kurzen, alltagsnahen Impuls für die kommende Woche.
|
||||
- Schreibe klar und möglichst konkret statt vorsichtig-abstrakt.
|
||||
- Verwende wenige Konjunktive.
|
||||
- Vermeide therapeutische Standardformulierungen und allgemeine Lebenshilfe-Sprache.
|
||||
- Wenn Alkohol nur an einem Tag vorkam und nicht zentral für die Woche war, erwähne ihn nicht.
|
||||
- Der Schlussteil soll kurz sein und nicht wie ein Coaching-Impuls klingen.
|
||||
- Der letzte Absatz darf höchstens 2 Sätze lang sein.
|
||||
- Er soll eher eine ruhige Einordnung des nächsten Schwerpunkts geben als konkrete Tipps.
|
||||
|
||||
Die Zusammenfassung soll wie eine verdichtete persönliche Einordnung der Woche klingen, nicht wie ein Bericht.
|
||||
|
||||
Schreibe einen zusammenhängenden Fließtext mit etwa 180 bis 280 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') ?: ''));
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ require __DIR__ . '/helpers.php';
|
||||
require __DIR__ . '/Support/Defaults.php';
|
||||
require __DIR__ . '/Support/Auth.php';
|
||||
require __DIR__ . '/Support/EntryCrypto.php';
|
||||
require __DIR__ . '/Support/OpenAiSummaryService.php';
|
||||
require __DIR__ . '/Support/View.php';
|
||||
require __DIR__ . '/Support/WebPushService.php';
|
||||
require __DIR__ . '/Domain/AiConfigRepository.php';
|
||||
require __DIR__ . '/Domain/UserRepository.php';
|
||||
require __DIR__ . '/Domain/SettingsRepository.php';
|
||||
require __DIR__ . '/Domain/EntryRepository.php';
|
||||
require __DIR__ . '/Domain/SummaryRepository.php';
|
||||
require __DIR__ . '/Domain/LoginThrottle.php';
|
||||
require __DIR__ . '/Domain/NotificationRepository.php';
|
||||
require __DIR__ . '/Domain/ScoringService.php';
|
||||
|
||||
@@ -197,6 +197,87 @@ function format_display_date(string $date, bool $withWeekday = true): string
|
||||
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
|
||||
{
|
||||
return '/assets/icons/' . $name . '.svg';
|
||||
|
||||
+137
-3
@@ -3,11 +3,132 @@
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Archiv</p>
|
||||
<h3>Alle gespeicherten Tage</h3>
|
||||
<h3>KI-Rückblicke und gespeicherte Tage</h3>
|
||||
</div>
|
||||
<span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
|
||||
</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&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&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 === []): ?>
|
||||
<p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
|
||||
<?php else: ?>
|
||||
@@ -40,10 +161,23 @@
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<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">
|
||||
<p class="eyebrow">Ausgewählt</p>
|
||||
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
|
||||
@@ -91,7 +225,7 @@
|
||||
<article class="glass-panel detail-card">
|
||||
<p class="eyebrow">Details</p>
|
||||
<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>
|
||||
<?php endif; ?>
|
||||
</aside>
|
||||
|
||||
@@ -390,6 +390,37 @@
|
||||
</article>
|
||||
|
||||
<?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">
|
||||
<p class="eyebrow">Mehrere Accounts</p>
|
||||
<h3>Neuen Nutzer anlegen</h3>
|
||||
|
||||
Reference in New Issue
Block a user