journal-bot/CLAUDE.md

9.9 KiB

journal-bot — Telegram → Obsidian Daily Journal

Pipeline: Telegram-Bot empfängt Text/Sprache/Foto → semantische Verarbeitung → Eintrag in Christians Obsidian Daily Note.

Zweck

Christian pflegt sein Daily Journal in Obsidian. Dieses Projekt erlaubt es ihm, Einträge bequem per Handy (auch als Sprachnachricht) abzusetzen. Beispiel: „Gestern haben wir eine Party gefeiert" landet in der Daily Note von gestern, nicht von heute. Meta-Phrasen („Schreib ins Journal, dass …") werden vor dem Schreiben gefiltert.

Architektur

Zwei-Stage-Pipeline mit File-Queue dazwischen:

Telegram → [Ingest] → queue/pending/ → [Process] → Obsidian Daily Note
                                    ↘ [Claude-Skill /journal-sync] (Premium-Fallback)
  • Ingest (ingest.py): Long-Polling Telegram getUpdates, Sender-Filter, Voice-Transcription via faster-whisper (Fallback: Telegram-Premium transcribed_text), Foto-Download, schreibt QueueItem als JSON in runtime/queue/pending/.
  • Process (process.py): claimt nächstes Item, baut ProcessorInput, ruft Processor, schreibt Eintrag via VaultWriter in die Daily Note, verschiebt Item nach done/.
  • Zwei Processor-Backends (Protocol in processor_protocol.py):
    1. LMStudioProcessor (default, lokal, hands-off) — spricht LM Studio per OpenAI-kompatibler API.
    2. Claude-Code-Skill /journal-sync im Vault (D:\projects\chrka\brain\.claude\skills\journal-sync.md) — manueller Premium-Pfad, wenn die LLM-Qualität von LM Studio nicht reicht oder LM Studio aus ist.

Crash-Safety: atomic os.rename zwischen pending/working/done/failed, atomic .tmp + replace für State-File, processed_ids-Deque verhindert Doppelverarbeitung.

Projekt-Layout

src/journal_bot/
  __main__.py            # CLI: ingest | process | both | write
  config.py              # pydantic-settings (env)
  state.py               # last_update_id + processed_ids deque
  queue.py               # QueueItem + atomic file queue
  vault_writer.py        # append to daily note, frontmatter, Klärungs-Callout
  telegram_client.py     # httpx.AsyncClient, getUpdates, download, react
  transcribe.py          # faster-whisper + premium fallback
  context.py             # Personen + aktive Projekte aus Vault
  processor_protocol.py  # Protocol + Pydantic ProcessorInput/Output
  processor_lmstudio.py  # OpenAI-kompatibler LM-Studio-Client
  ingest.py              # poll → queue
  process.py             # queue → vault
  prompts/
    journal_system.md    # System-Prompt, gilt für beide Backends
scripts/
  run.ps1                # venv-Wrapper für CLI
  build-tools.ps1        # publish .NET tools as self-contained EXEs
  install-service.ps1    # (Admin) register JournalBotService
  install-tray.ps1       # autostart tray @logon (HKCU Run key)
runtime/                 # generiert, NICHT committen
  queue/{pending,working,done,failed}/
  state/state.json
  attachments/
  logs/
tests/
  test_*.py              # 38 Tests, alle grün
  fixtures/prompt_regression/inputs.yaml

Pfade

  • Projekt-Root: D:\projects\chrka\journal-bot
  • Vault: D:\projects\chrka\brain (separates Repo, eigener Git)
  • Daily Notes landen in: <vault>\05 Daily Notes\YYYY-MM-DD.md
  • Anhänge in: <vault>\07 Anhänge\ (Default in attachments_dir)

CLI

.venv/Scripts/python.exe -m journal_bot ingest    # Telegram → queue
.venv/Scripts/python.exe -m journal_bot process   # queue → vault (via LM Studio)
.venv/Scripts/python.exe -m journal_bot both      # ingest dann process
.venv/Scripts/python.exe -m journal_bot write --target-path "05 Daily Notes/2026-06-15.md" --entry-file tmp.md

write ist Helper für den Claude-Skill: schreibt einen vorbereiteten Eintrag ohne LLM-Aufruf.

Konfiguration (.env)

Aus .env.example. Pflichtfelder ohne Defaults:

  • TELEGRAM_TOKEN — von @BotFather
  • ALLOWED_USER_ID — eigene User-ID (via @userinfobot). Filter im TelegramClient verwirft alle anderen Sender.
  • VAULT_PATHD:\projects\chrka\brain
  • JOURNAL_BOT_HOMED:\projects\chrka\journal-bot\runtime (queue/state/logs)

Optional:

  • LMSTUDIO_URL — Default http://localhost:1234/v1
  • LMSTUDIO_MODEL — z.B. qwen/qwen3-vl-8b (vision-capable)
  • WHISPER_MODEL — Default large-v3
  • WHISPER_DEVICEcpu oder cuda

Tests

Python 3.12+, uv-managed venv unter .venv/. uv ist nicht auf PATH — venv-Python direkt verwenden:

.venv/Scripts/python.exe -m pytest -v
  • 38 Tests, alle Mocks (kein echter Telegram/LM-Studio-Call).
  • pytest-asyncio im Auto-Mode (pyproject.tomlasyncio_mode = "auto").
  • respx mockt httpx; pytest-mock für Sonstiges.
  • Prompt-Regression in tests/test_prompt_regression.py mit YAML-Fixtures.

Deployment

  1. .env füllen.
  2. LM Studio starten, Modell laden, Server auf Port 1234.
  3. Service + Tray installieren (supersedes the old install-task.ps1): siehe Service + Tray weiter unten.
  4. Trigger manuell: scripts/run.ps1 both.

Service + Tray (tools/)

Two thin .NET 10 shells around the Python CLI (solution: tools/JournalBot.slnx):

  • JournalBot.Service — Windows Service (LocalSystem), PeriodicTimer runs python -m journal_bot ingest every IntervalMinutes (default 15). Config in appsettings.json next to the EXE. Logs to runtime/logs/service.log + Event Log. Only ingest — no LM Studio needed.
  • JournalBot.Tray — WPF tray app (user process, autostart @logon). Tray icon + window with status banner (pending/failed), Historie tab (done items, newest first, with target daily note), Log tab, and Ingest/Process/Both buttons. Runs process here because LM Studio is a user-session app. Command errors surface in the Log tab (no crash).
  • JournalBot.SharedQueueItem DTO (mirrors the pydantic model, snake_case JSON), BotRunner (subprocess wrapper, kills process tree on cancel), RuntimeReader (reads done/, counts pending/failed, tails logs). xUnit tests in JournalBot.Shared.Tests (5 tests).

Build + install (PowerShell):

scripts\build-tools.ps1            # publish self-contained EXEs to tools\publish
scripts\install-service.ps1        # (Admin) register + start JournalBotService
scripts\install-tray.ps1           # autostart tray @logon (HKCU Run key)

.NET tests: dotnet test tools/JournalBot.slnx. .NET SDK 10 required.

Gotcha: the GUI history shows target_path/written_entry/processed_at, which process now records on done items (queue.py complete() rewrites the JSON instead of a plain rename). Old done items lack these fields (all optional -> render blank).

Note: SDK 10 generates a .slnx solution file (XML), not legacy .sln. Both work with the dotnet CLI.

Known Gotchas (bei Änderungen beachten)

  • UTF-8 ist Pflicht. Windows-Default ist cp1252 → zerschießt Umlaute. Immer encoding="utf-8" beim Lesen/Schreiben. NIE PowerShell Set-Content/Get-Content ohne -Encoding UTF8 — sie zerstören UTF-8-Sequenzen (Vorfall in dieser Codebase: „Klärung" → „Klärung", BOM eingefügt). Im Code stattdessen Path.write_text(..., encoding="utf-8") oder über Write-Tool schreiben.
  • uv nicht auf PATH. Immer .venv/Scripts/python.exe direkt aufrufen.
  • LM Studio: response_format braucht json_schema, nicht json_object. LM Studio lehnt json_object mit 400 ab. Wir schicken das Pydantic-JSON-Schema von ProcessorOutput mit. Siehe processor_lmstudio.py:62-69.
  • Telegram getUpdates-Semantik. Updates bleiben auf Telegram-Server bis offset > last_update_id im nächsten Call gesendet wird. Wenn ALLOWED_USER_ID falsch ist, filtert der Client lokal alles raus, der State bleibt bei 0, aber pollt weiter — Updates sind dann seltsam „verschluckt" je nach Reihenfolge. Bei Verdacht: getUpdates?offset=-1 zeigt das letzte Update auch nach Confirmation.
  • pending_update_count aus getWebhookInfo ist ohne Webhook immer 0. Heißt nicht „keine Updates" — heißt nur „kein Webhook angedockt".
  • process_once bricht nach erstem Fail ab (process.py). Sonst würde queue.fail() das Item zurück nach pending/ legen und die while True-Schleife würde es sofort wieder claimen → Endlosschleife. Test test_process_fails_item_on_error erwartet attempts==1.
  • httpx.Client in LMStudioProcessor wird nie geschlossen. OK für Single-Run-CLI, bei Long-running-Mode später close() ergänzen.
  • Image-MIME hartcodiert image/jpeg. Telegram-Fotos kommen meist als JPEG, PNG-Screenshots werden mislabeled. Vision-Modelle tolerieren das meist.
  • Frontmatter-Parser ist minimal (kein PyYAML), liest nur flache key: value-Zeilen. Reicht für 00 Kontext/Personen/*.md.

Vault-Konventionen (relevant für Prompt)

  • Daily Note: 05 Daily Notes/YYYY-MM-DD.md mit Frontmatter tags: [daily]\ndate: YYYY-MM-DD.
  • Person verlinken: [[00 Kontext/Personen/<Display>|<Display>]]. Vorname/Nachname/Spitzname werden aus Frontmatter der Person gematched.
  • Mehrdeutige Namen (z.B. zwei „Steffen") → Klärungsfrage in clarifications + Placeholder [[?]] im Text.
  • Klärung wird als Obsidian-Callout angehängt: > [!warning] Klärung\n> - <text>.
  • Sprache: Deutsch, knapp, faktisch, keine Floskeln.

Beziehung zum Vault

Das Vault-Repo (D:\projects\chrka\brain) ist getrennt. Vom journal-bot wird darauf lesend zugegriffen (Personen, Projekte) und schreibend nur in 05 Daily Notes/ und 07 Anhänge/. Der Claude-Code-Skill /journal-sync liegt im Vault, weil er pro Vault-Session verfügbar sein muss.

Commits & Branch-Strategie

  • Feature-Branches von main, Squash-Merge.
  • Conventional Commits (feat/fix/refactor/docs/test/chore).
  • Englisch, imperativ.
  • TDD: failing test → impl → green → commit pro logischer Einheit.

Roadmap / V2-Ideen (nicht jetzt)

  • Webhook-Mode statt Long-Polling.
  • Routing nach Inbox/Projekt statt nur Daily Note.
  • Multi-User-Support.
  • Long-running-Daemon mit lifecycle-managed httpx-Client.
  • Prompt-Tuning nach echten Inputs (Regressions-Suite erweitern).