Compare commits

..

10 Commits

Author SHA1 Message Date
Jonny 8a6080bd7a chore: track previously-untracked JARVIS foundation files
Backend (init-db.sql, docker-compose.yml, migrations 002-005, tests,
deploy scripts, requirements) and frontend (package.json, vite.config.ts,
main.tsx, api.ts, CalendarWidget/EmailWidget/Login/WeatherWidget) existed
only in the working tree until now. Adds a JARVIS-specific .gitignore
(node_modules, dist, __pycache__, real .env files, local Claude settings)
so the project can be pushed to its own dedicated repo with a complete,
buildable history from this commit forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 12:27:43 +02:00
Jonny b8695fecd6 fix: fall back to settings defaults on DB error, add missing identity-line system-prompt test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 12:05:44 +02:00
Jonny effa9168c1 fix: propagate settings save into live App.tsx state instead of requiring re-login
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 08:51:28 +02:00
Jonny aec27bc136 feat: wire Settings page and dynamic assistant name into the frontend
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 08:26:06 +02:00
Jonny e6f0ce6116 feat: add Settings component
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 08:16:33 +02:00
Jonny 9505bc2eb3 feat: build chat identity line from settings instead of a static env var
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 08:11:07 +02:00
Jonny a904f2f8e0 feat: add GET/PUT /api/v1/settings endpoints
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 07:53:41 +02:00
Jonny df933d089b feat: add settings DB helpers with default fallback
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-14 07:11:32 +02:00
Jonny b1ba284ad7 feat: add settings table migration 2026-09-13 22:03:58 +02:00
Jonny 84d8288850 docs: add implementation plan for Einstellungen (settings) feature
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
2026-09-13 21:55:55 +02:00
59 changed files with 10696 additions and 14 deletions

View File

@ -0,0 +1,11 @@
### Funktionen ###
- Bestellungen erfassen. Eventuell ein KanBanboard, Nextcloud Deck und/oder eine Excel-Tabelle. Spracheingaben als Bestellung/Auftrag anlegen.
- Lernmodus für zBsp. Dokumente, Betriebsanleitungen, technische Daten usw. Also eine Wissensdatenbank aufnauen.
- Anbindung an Lexware.de über API um Angebote und Artikel anzulegen.
- Mehrbenutzerfunktion
- Multitenant Setup für mehrere Kunden.
- SSO Anbindung mittels Authentik.
- GIT Anbindung an gitea.mbo-tech-it.de Repro: https://gitea.mbo-tech-it.de/claude/jarvis-assist.git
- Einstellungen, extra Menü Punkt mit den einzelnen Einstellungen wie E-Mailadresse Firmennamen. Namen des Assistenten usw.
- Zugriff auf E-Mails und den kompletten Text um Termine zu filtern und Zusammenfassungen zu schreiben.
- E-Mails im Postfach in Unterordner verschieben.

7
.claude/settings.json Normal file
View File

@ -0,0 +1,7 @@
{
"permissions": {
"additionalDirectories": [
"C:\\Users\\Jonny\\.superpowers"
]
}
}

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Python bytecode cache
__pycache__/
*.pyc
# Node dependencies & build output
node_modules/
dist/
# Real environment files (secrets/config) - see *.example files for templates
.env
.env.local
.env.production
# Local, personal Claude Code settings (not shared)
.claude/settings.local.json

View File

@ -0,0 +1,666 @@
# 🤖 JARVIS - Übergabe Dokumentation
**Projekt:** JARVIS KI-Assistent + Business Automation
**Erstes Deployment:** 12.09.2026
**Status:** ✅ Live auf VPS 72.61.186.98 (Postgres, Redis, Ollama, n8n, JARVIS-API, JARVIS-Web)
**Owner:** Jonny (MBO-Tech-IT)
---
## 📋 Übersicht
JARVIS ist ein **vollständig selbst gehosteter AI-Assistant + Business Automation Stack** auf einem VPS mit Docker-Orchestrierung. Chat-Zugriff läuft über eine eigene Web-Oberfläche (React) und spricht über Claude Tool Use direkt mit Kalender und E-Mail-Postfach.
**Kernkomponenten:**
- 🤖 **KI-Backend**: Claude API (Chat + Tool Use) + Ollama (lokale Embeddings)
- 💾 **Datenbank**: PostgreSQL mit `pgvector`-Extension (strukturierte Daten + Vector Search in einer DB, kein separates Milvus)
- ⚡ **Cache**: Redis (aktuell nur fürs Health-Check vorbereitet, noch nicht aktiv genutzt)
- 🔄 **Automation**: n8n (Workflow Engine) - Wetter- und E-Mail-Cache laufen bereits darüber
- 📡 **API**: FastAPI (Python Backend, `main.py`)
- 🌐 **Web-Frontend**: React/Vite, nginx-served
- 🔌 **Externe Integrationen**: Nextcloud-Kalender (CalDAV), E-Mail-Postfach (IMAP/SMTP)
- 🌐 **Reverse Proxy**: bereits vorhandener, JARVIS-fremder Traefik auf dem VPS (siehe unten)
- 🐳 **Container**: 6 Docker-Container (siehe "Docker Container Status")
---
## 🔑 Zugriffsdaten
```
VPS IP: 72.61.186.98
User: jarvis-core
SSH Key: ~/.ssh/jarvis_core_key
Domain: jarvis.mbo-tech-it.de
Location: /home/jarvis-core/jarvis
```
### SSH-Zugriff
```bash
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98
```
Die Flags `-F /dev/null -o IdentitiesOnly=yes` sind nötig, damit die globale
lokale `~/.ssh/config` nicht in die Schlüsselauswahl reinpfuscht.
**Wichtig:** `jarvis-core` muss in `/etc/ssh/sshd_config` unter `AllowUsers` stehen, sonst wird jeder Key mit "Permission denied" abgelehnt, obwohl der Key korrekt ist (passiert am 12.09.2026, gefixt durch Ergaenzen von `jarvis-core` in der `AllowUsers`-Zeile + `systemctl reload ssh`).
### n8n-Zugriff
```
URL: https://n8n.jarvis.mbo-tech-it.de
User: jonny@mbo-tech-it.de (Owner-Account, Passwort nicht in dieser Doku)
```
API-Keys fuer die n8n-REST-API werden unter Settings -> n8n API verwaltet
(pro Key eigenes Ablaufdatum). Aktuell existieren: "JARVIS Weather Workflow"
(Ablauf 12.10.2026) und "JARVIS Email Workflow Runtime" (Ablauf 13.10.2026,
fuer den E-Mail-Cache-Workflow angelegt). Die Key-Werte selbst sind nirgends
dauerhaft gespeichert (nur beim Erstellen einmalig sichtbar) - bei Bedarf
einfach einen neuen erstellen.
### Database Credentials
```
PostgreSQL User: jarvis
PostgreSQL Database: jarvis (n8n hat eine eigene DB "n8n" in derselben Instanz)
PostgreSQL Port: 5432
Password: in .env (DB_PASSWORD)
```
### Externe Dienste (Zugangsdaten in `.env` auf dem VPS)
- **Nextcloud-Kalender** (`NEXTCLOUD_CALDAV_URL`, `NEXTCLOUD_USER`, `NEXTCLOUD_APP_PASSWORD`): `https://cloud.ffw-onza.de/.../ffw-onza-alle/`, App-Passwort-Auth
- **E-Mail-Postfach** (`EMAIL_USER`, `EMAIL_PASSWORD`, `EMAIL_IMAP_HOST/PORT`, `EMAIL_SMTP_HOST/PORT`): `kontakt@mbo-tech-it.de` auf `mx2f35.netcup.net` (IMAP 143 STARTTLS, SMTP 465 implizites TLS)
- **Claude API** (`CLAUDE_API_KEY`): console.anthropic.com, separat vom Claude-Pro-Abo
---
## 📍 Services & Ports
| Service | URL | Port | Status | Notes |
|---------|-----|------|--------|-------|
| **Web-Frontend** | https://jarvis.mbo-tech-it.de | 80 (intern, nginx) | 🟢 Live | Chat + Dashboard, Shared-Secret-Login mit `API_KEY_ADMIN` |
| **API** | https://api.jarvis.mbo-tech-it.de | 8000 (intern) | 🟢 Live | FastAPI Swagger UI: `/docs`. Alle `/api/v1/*` Routen ausser `/health` brauchen Header `X-Admin-Key` |
| **n8n** | https://n8n.jarvis.mbo-tech-it.de | 5678 (intern) | 🟢 Live | Workflow Automation - 2 aktive Workflows (Wetter, E-Mail-Cache) |
| **Traefik** | - | 80/443 | 🟢 Genutzt (fremd) | Server hat bereits einen eigenen Traefik fuer andere Projekte. JARVIS-eigener Traefik-Service wurde entfernt (Port-Konflikt), stattdessen haengt JARVIS per Labels + externem Netzwerk `proxy-network` am bestehenden Traefik. TLS ueber Let's-Encrypt-Resolver `netcup` (DNS-01) |
| **PostgreSQL** | localhost | 5432 | 🟢 Live | Hauptdatenbank + Vector Search, Image `pgvector/pgvector:pg16` |
| **Redis** | localhost | 6379 | 🟢 Live | Läuft und wird im Health-Check geprüft, im Code aber noch nicht aktiv fürs Caching genutzt |
| **Ollama** | http://72.61.186.98:11434 | 11434 | 🟢 Live | Local LLM Runtime, genutzt für Embeddings (`nomic-embed-text` gepullt) |
| **Milvus** | - | - | ⛔ Nie deployt | Ersetzt durch `pgvector` direkt in Postgres (siehe Phase 3a). Platzhalter-Services `milvus`/`etcd` stehen noch in `docker-compose.yml`, laufen aber nicht |
**Traefik-Integration (12.09.2026):** JARVIS ist am bestehenden VPS-weiten Traefik
(`docker-compose.yml` Projekt `traefik`, `/data/docker/compose/traefik/`) angehaengt:
externes Docker-Netzwerk `proxy-network`, Labels an `jarvis-api`, `jarvis-web` und `n8n`
(`traefik.docker.network=proxy-network`, `entrypoints=websecure`,
`tls.certresolver=netcup`). DNS (`jarvis` A-Record + `*.jarvis` Wildcard) liegt
bei Netcup. Falls eine neue Subdomain (z.B. `foo.jarvis.mbo-tech-it.de`) mal
nicht auflöst, obwohl der Wildcard existiert: pruefen, ob ein expliziter
(auch leerer) Eintrag exakt fuer diesen Namen existiert - der blockiert laut
DNS-Wildcard-Regeln (RFC 1034) die Wildcard-Aufloesung nur fuer diesen einen
Namen. **Beobachtet (12.09.2026):** Auch ohne sichtbaren blockierenden
Eintrag im Netcup-Panel kann die Aufloesung eines frisch fuer eine
DNS-01-Challenge verwendeten Namens (z.B. `api.jarvis...`) deutlich laenger
brauchen als ein unberuehrter Wildcard-Name (bis zu ~20 Minuten beobachtet,
vermutlich Netcup-interner Nebeneffekt der ACME-TXT-Record-Erstellung) -
einfach abwarten, es loest sich von selbst.
---
## 🏗️ Architektur
```
┌───────────────────────────────────────────────────────────┐
│ Bestehender, JARVIS-fremder Traefik │
│ (VPS-weit, andere Projekte inklusive) │
└───────────────────────────────────────────────────────────┘
│ proxy-network (Labels)
┌───────────┼──────────────┬──────────────────┐
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌───────────┐
│jarvis-web│ │jarvis-api│ │ n8n │
│(nginx) │ │(FastAPI) │ │ :5678 │
└─────────┘ └────┬─────┘ └─────┬─────┘
│ jarvis-net (intern) │
┌───────────┼───────────┐ │
▼ ▼ ▼ │
┌───────────┐ ┌───────┐ ┌────────┐ │
│ Postgres │ │ Redis │ │ Ollama │ │
│ +pgvector │ │ :6379 │ │ :11434 │ │
│ :5432 │ └───────┘ └────────┘ │
└─────┬─────┘ │
└─── n8n schreibt Cache-Tabellen ─────┘
(weather_cache, email_cache)
Externe Integrationen (kein eigener Container, direkter API-Zugriff aus jarvis-api):
- Nextcloud CalDAV (Kalender lesen/schreiben)
- IMAP/SMTP-Postfach kontakt@mbo-tech-it.de (E-Mails lesen/senden)
- Claude API (Chat + Tool Use)
```
---
## 🐳 Docker Container Status
```bash
cd /home/jarvis-core/jarvis
docker compose ps
# Erwartete Ausgabe (6 Container):
# jarvis-postgres Up (healthy)
# jarvis-redis Up (healthy)
# jarvis-ollama Up
# jarvis-n8n Up
# jarvis-api Up
# jarvis-web Up
```
`milvus`/`etcd` stehen zwar noch als Service-Definitionen in
`docker-compose.yml` (Platzhalter, nie funktionsfähig konfiguriert), werden
aber mit `docker compose up -d` normalerweise nicht mit hochgezogen, wenn man
gezielt einzelne Services neu startet (`docker compose up -d jarvis-api` etc.,
so wie es in diesem Projekt durchgängig gemacht wird statt einem globalen
`up -d`).
---
## 📁 Projektstruktur
```
/home/jarvis-core/jarvis/
├── docker-compose.yml # Container-Orchestrierung (Docker Compose v2 - "docker compose", kein Bindestrich)
├── .env # Umgebungsvariablen (GEHEIM!)
├── init-db.sql # PostgreSQL Basis-Initialisierung (users/conversations/messages/tasks/documents/audit_logs)
├── migrations/ # Nachträgliche Schema-Änderungen, manuell per psql angewendet
│ ├── 002_document_chunks.sql
│ ├── 003_drop_low_data_ivfflat_index.sql
│ ├── 004_weather_cache.sql
│ └── 005_email_cache.sql
├── api/
│ ├── main.py # FastAPI Hauptanwendung (einzige Backend-Datei)
│ └── requirements.txt # Python Dependencies
├── web/ # React/Vite Frontend-Quellcode + Dockerfile + nginx.conf
├── config/ # (ungenutzt)
├── data/ # (ungenutzt - Postgres/Redis/n8n/Ollama persistieren tatsächlich über benannte Docker-Volumes, siehe docker-compose.yml `volumes:`)
└── logs/ # (ungenutzt, kein Service schreibt aktuell hierhin)
```
Lokal (Entwicklung, dieses Repo): Backend-Code liegt unter `Claude outputs/`,
Frontend unter `web/`. Kein dediziertes Git-Repo für dieses Projekt - Dateien
werden per `scp` direkt auf den VPS deployt.
---
## 🔧 Wichtige Befehle
**Hinweis:** Die installierte Docker-Compose-Version ist v2 (Plugin-Syntax
`docker compose`, ohne Bindestrich) - das alte `docker-compose` (mit
Bindestrich) ist auf dem VPS nicht garantiert vorhanden.
### Container Management
```bash
cd /home/jarvis-core/jarvis
# Alle starten
docker compose up -d
# Alle stoppen
docker compose down
# Logs anschauen
docker compose logs -f
# Spezifischen Service neustarten (Backend-Codeänderung reicht ein restart,
# da main.py per Volume gemountet ist und uvicorn mit --reload läuft)
docker compose restart jarvis-api
# Frontend-Änderung braucht einen echten Rebuild (Multi-Stage-Dockerfile)
docker compose build jarvis-web && docker compose up -d jarvis-web
# In Container gehen
docker compose exec jarvis-api bash
docker compose exec postgres bash
```
### Database Management
```bash
# PostgreSQL CLI öffnen
docker exec -it jarvis-postgres psql -U jarvis -d jarvis
# Query ausführen
docker exec jarvis-postgres psql -U jarvis -d jarvis -c "SELECT * FROM users;"
# Migration anwenden (nach scp der .sql-Datei nach migrations/)
docker exec -i jarvis-postgres psql -U jarvis -d jarvis < migrations/00X_name.sql
# Backup erstellen
docker exec jarvis-postgres pg_dump -U jarvis jarvis > jarvis_backup.sql
# Backup wiederherstellen
docker exec -i jarvis-postgres psql -U jarvis jarvis < jarvis_backup.sql
```
### API Testing
```bash
# Health Check (kein Header noetig)
curl http://localhost:8000/health
# Admin Stats (Header noetig)
curl -H "X-Admin-Key: $API_KEY_ADMIN" http://localhost:8000/api/v1/admin/stats
# Swagger UI im Browser
https://api.jarvis.mbo-tech-it.de/docs
```
### Backend-Tests lokal ausführen (vor jedem Deploy)
Lokales Python (3.14) kann `psycopg2-binary` nicht bauen (kein Wheel) - Tests
laufen deshalb in einem `python:3.11-slim`-Container per SSH:
```bash
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
"Claude outputs/main.py" "Claude outputs/requirements.txt" \
"Claude outputs/requirements-dev.txt" "Claude outputs/pytest.ini" \
jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
"Claude outputs/tests/"*.py jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c \
'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/ -v'"
```
---
## ⚙️ Konfiguration (.env, auf dem VPS unter `/home/jarvis-core/jarvis/.env`)
```
# Domain
DOMAIN=jarvis.mbo-tech-it.de
# Database
DB_PASSWORD=<generiert>
# Claude / Ollama
CLAUDE_API_KEY=<gesetzt>
CLAUDE_MODEL=claude-sonnet-5 # (Default in main.py, muss nicht in .env stehen)
OLLAMA_EMBED_MODEL=nomic-embed-text # (Default in main.py)
# Admin-Zugriff (Shared Secret fürs Frontend UND alle /api/v1/* Routen)
API_KEY_ADMIN=<generiert>
# Nextcloud-Kalender
NEXTCLOUD_APP_PASSWORD=<gesetzt>
# E-Mail-Postfach kontakt@mbo-tech-it.de
EMAIL_PASSWORD=<gesetzt>
LOG_LEVEL=info
```
**⚠️ WICHTIG:** `.env` enthält Secrets - nicht in Git committen (für dieses
Projekt ohnehin kein Git-Repo im Einsatz)!
---
## 🔌 Datenbankschema
### PostgreSQL Tables (Datenbank `jarvis`)
**users** (`init-db.sql`)
- Felder: id, username, email, password_hash, api_key, role, is_active
- Es gibt kein echtes Login-System pro Nutzer (siehe "Security" unten) - die
einzige vorhandene Zeile ist ein Seed-User `admin@jarvis.local` aus
`init-db.sql`. `ensure_default_user()` in `main.py` nimmt schlicht den
ersten User per ID als `DEFAULT_USER_ID` für alle Chat-Aktivität (in der
Praxis also dieser `admin`-Seed-User, nicht ein separat angelegter
"jarvis-service"-User, obwohl der Code-Pfad dafür existiert)
**conversations** / **messages** (`init-db.sql`)
- Chat-Verlauf, Multi-Turn über `conversation_id`
- `messages.tokens_used` trackt Claude-Token-Verbrauch pro Antwort
**tasks** (`init-db.sql`)
- Tabelle existiert, wird aber von den `/api/v1/tasks`-Endpoints noch NICHT
genutzt (die sind aktuell Stubs mit hartcodierten Werten, siehe "API
Endpoints" unten) - offener Punkt für Phase 4b
**documents** + **document_chunks** (`init-db.sql` + `migrations/002_document_chunks.sql`)
- Knowledge Base fürs RAG: `documents` hält Titel/Volltext,
`document_chunks` die 768-dim `pgvector`-Embeddings (Ollama
`nomic-embed-text`) pro Chunk
- `ivfflat`-Index wurde wieder entfernt (`003_drop_low_data_ivfflat_index.sql`),
da er bei wenig Daten 0 Treffer lieferte - aktuell Sequential Scan
**weather_cache** (`migrations/004_weather_cache.sql`)
- Wird ausschließlich vom n8n-Workflow "Wetter Crailsheim" befüllt (Cache,
kein Live-API-Call aus `main.py`)
**email_cache** (`migrations/005_email_cache.sql`)
- Wird ausschließlich vom n8n-Workflow "Neue E-Mails Cache" befüllt
(Absender/Betreff/Empfangsdatum, keine Volltexte). Der Chat greift für
Live-Anfragen separat direkt per IMAP zu, nicht über diesen Cache
**audit_logs** (`init-db.sql`)
- Tabelle existiert, wird aber aktuell von keinem Code-Pfad beschrieben
(vorbereitet für spätere Compliance-Anforderungen)
### Redis
- Läuft und wird im Health-Check geprüft (`check_redis()`), im Code aber
noch nirgends für Sessions/Caching/Queues genutzt - reiner Platzhalter für
spätere Erweiterung
---
## 🚀 API Endpoints
Alle `/api/v1/*`-Routen (ausser keine - `/health` ist die einzige offene
Route) verlangen den Header `X-Admin-Key: <API_KEY_ADMIN>`.
### Health
```
GET /health # Oeffentlich, kein Header noetig
GET /api/v1/admin/stats # Conversations/Tasks/Documents-Zaehler
GET /api/v1/admin/health/detailed # Postgres/Redis/Milvus(TCP-Check)/Ollama/n8n
```
### Chat (Claude, mit Tool Use für Kalender + E-Mail)
```
POST /api/v1/chat
Body: {"conversation_id": 1, "message": "...", "context": {}}
-> {"conversation_id", "response", "tokens_used", "timestamp"}
GET /api/v1/conversations/{id} # Voller Nachrichtenverlauf
```
Claude kann in `run_chat_completion()` bis zu `MAX_TOOL_ROUNDS = 5` Tool-Runden
hintereinander ausführen: `list_calendar_events`, `create_calendar_event`,
`update_calendar_event`, `delete_calendar_event` (Löschen nur nach expliziter
Chat-Bestätigung), `list_recent_emails`, `send_email` (Senden nur nach
expliziter Chat-Bestätigung).
### Kalender
```
GET /api/v1/calendar/events?days=14 # Naechste Termine (Nextcloud CalDAV)
```
### Wetter
```
GET /api/v1/weather # Neuester Cache-Eintrag (503 wenn leer)
```
### E-Mail
```
GET /api/v1/emails?limit=10 # Neueste gecachte Mails ([] wenn leer, kein 503)
```
### Tasks (⚠️ noch nicht mit Postgres verbunden - TODO)
```
POST /api/v1/tasks # Stub: gibt immer id=1 zurueck, speichert nichts
GET /api/v1/tasks?status=pending # Stub: gibt immer leere Liste zurueck
```
### Documents (Knowledge Base)
```
POST /api/v1/documents?title=...&content=...&document_type=...
# Chunking + Ollama-Embedding + Insert
GET /api/v1/documents?query=...&limit=10
# Vector-Similarity-Suche ueber document_chunks
```
### Workflows (⚠️ noch nicht mit n8n verbunden - TODO)
```
POST /api/v1/workflows/trigger # Stub: ruft n8n nicht wirklich auf
```
---
## 🔐 Security
Es gibt **kein** vollwertiges Auth-System mit Login pro Nutzer. Stattdessen:
- Frontend-Login = ein geteiltes Secret (`API_KEY_ADMIN`), das im Browser in
`localStorage` liegt und bei jedem Request als `X-Admin-Key`-Header
mitgeschickt wird (`require_admin_key`-Dependency in `main.py`)
- Der `admin`-User in der `users`-Tabelle (aus `init-db.sql`) ist nur ein
DB-Seed für die `user_id`-Fremdschluessel, kein aktives Login - das Feld
`password_hash` wird von keinem Code-Pfad geprüft
- Für ein echtes Multi-User-System (eigene Logins, Rollen) wäre eine
separate Architektur-Runde nötig - bewusst "Out of Scope" laut Frontend-Spec
### Secrets (alle in `.env` auf dem VPS, nicht im Repo)
- **API_KEY_ADMIN**: Shared Secret fürs Frontend-Login + alle geschützten API-Routen
- **DB_PASSWORD**: Postgres-Passwort
- **CLAUDE_API_KEY**: Anthropic API Key
- **NEXTCLOUD_APP_PASSWORD**: Nextcloud CalDAV App-Passwort
- **EMAIL_PASSWORD**: IMAP/SMTP-Passwort für kontakt@mbo-tech-it.de
---
## 📊 Monitoring & Logs
```bash
# Real-time Logs
docker compose logs -f
# Logs für spezifischen Service
docker compose logs -f jarvis-api
# Alte Logs anschauen
docker logs --tail 100 jarvis-api
# System Resources
docker stats
```
---
## 🛠️ Troubleshooting
### Container startet nicht
```bash
docker compose logs jarvis-api
docker compose restart jarvis-api
docker compose down && docker compose up -d
```
### Database Connection Error
```bash
docker exec jarvis-postgres pg_isready -U jarvis
docker exec jarvis-postgres psql -U jarvis -d jarvis -c "SELECT 1"
```
### API antwortet nicht
```bash
docker compose ps
curl -v http://localhost:8000/health
docker compose logs jarvis-api
```
### Frontend zeigt eine neue Aenderung nicht an, obwohl deployt
`web/nginx.conf` setzt `index.html` auf `no-cache` und `/assets/*` auf
`immutable` - trotzdem: harter Reload (Strg+Shift+R) probieren, bevor man
tiefer sucht. Ursache war einmal ein fehlender Cache-Header (siehe Phase 4d).
### n8n-Workflow läuft nicht wie erwartet
Executions-Tab des jeweiligen Workflows in der n8n-UI ansehen (zeigt Input/
Output pro Node) - schneller als Logs raten.
---
## 📈 Entwicklungsverlauf (chronologisch)
### Phase 1: Grundsetup ✅
- [x] Docker Stack deployed, alle Kern-Services laufen, Datenbank initialisiert, API verfügbar
### Phase 2: KI-Integration ✅
- [x] Claude API Key eingetragen (console.anthropic.com, separat vom Claude Pro Abo)
- [x] Claude Integration in API (Multi-Turn ueber `conversation_id`)
- [x] Token-Tracking implementiert (`tokens_used` pro Message + Response)
- [x] Postgres-Persistenz fuer Conversations/Messages
- [x] Echte Health-Checks (Postgres, Redis, Ollama, n8n; Milvus als TCP-Check vorbereitet)
### Phase 3a: Knowledge Base ✅
- [x] pgvector-Extension in Postgres (Image gewechselt auf `pgvector/pgvector:pg16`)
- [x] `document_chunks`-Tabelle (768-dim Embeddings, Chunking 1000/100 Zeichen Overlap)
- [x] Embeddings via Ollama `nomic-embed-text` (lokal, keine externen Kosten)
- [x] `POST/GET /api/v1/documents` funktionsfaehig
- [x] Bug gefunden+gefixt: `ivfflat`-Index lieferte bei wenig Daten 0 Treffer - Index vorerst entfernt, Sequential Scan aktiv
- [ ] Follow-up: `ivfflat`/`hnsw`-Index neu anlegen, sobald genug Dokumente vorhanden sind
### Phase 3b: Frontend ✅
- [x] React/Vite Web UI (`jarvis-web` Container, nginx-served) unter https://jarvis.mbo-tech-it.de
- [x] Shared-Secret-Login (wiederverwendet `API_KEY_ADMIN`, Header `X-Admin-Key`)
- [x] Chat Interface (Multi-Turn) + Dashboard (Stats + Health-Tabelle)
- [x] API auf eigene Subdomain umgezogen: https://api.jarvis.mbo-tech-it.de
- [x] Bug gefixt (13.09.2026): Chat verlor Verlauf bei Tab-Wechsel/Reload, da
`conversationId` nur im React-State lag. Fix: `conversationId` in
`localStorage`, Verlauf wird beim Mounten via `GET /api/v1/conversations/{id}`
wiederhergestellt (`web/src/components/Chat.tsx`)
- [ ] Vollwertiges User-/Auth-System (Login pro Nutzer, Rollen) - weiterhin offen
### Phase 4a: Wetter-Widget ✅ (erster echter n8n-Workflow)
- [x] n8n Owner-Account eingerichtet (`jonny@mbo-tech-it.de`)
- [x] n8n-Workflow "Wetter Crailsheim" (id `BwNCJ2TkuZfqUzst`): Schedule-Trigger
(alle 30 Min) -> Open-Meteo API (kein Key noetig) -> Code-Node (WMO-Code
-> deutscher Text) -> Postgres-Insert in `weather_cache`
- [x] n8n-Credential "JARVIS Postgres" (zeigt auf dieselbe `jarvis`-DB wie die API)
- [x] `GET /api/v1/weather` liest die neueste Zeile
- [x] `WeatherWidget` oben rechts in der Frontend-Nav-Leiste
### Phase 4d: Nextcloud-Kalender-Integration ✅
- [x] CalDAV-Zugriff auf `https://cloud.ffw-onza.de/.../ffw-onza-alle/` via
App-Passwort, Python-Library `caldav`
- [x] `GET /api/v1/calendar/events` + "Naechste Termine"-Widget im Dashboard
- [x] Chat kann Kalender abfragen, Termine anlegen und verschieben (direkt,
ohne Rueckfrage) sowie loeschen (nur nach Bestaetigung im Chat) -
Claude Tool Use mit `list_calendar_events` / `create_calendar_event` /
`update_calendar_event` / `delete_calendar_event`
- [x] Bug gefixt (13.09.2026): `run_chat_completion` unterstuetzte nur eine
Tool-Runde - "Termin verschieben" braucht aber zwei (erst uid per
`list_calendar_events` finden, dann `update_calendar_event`), die
zweite Runde wurde still verworfen (leere Antwort). Jetzt eine echte
Schleife (`MAX_TOOL_ROUNDS = 5`)
- [x] Bug gefixt: neu angelegte Termine landeten als UTC statt Europe/Berlin
(2h Verschiebung) - naive Datumswerte werden jetzt explizit lokalisiert
(`_as_calendar_local`)
- [x] Bug gefixt: `web/nginx.conf` hatte keine Cache-Header - `index.html`
wurde vom Browser gecacht, wodurch ein Deploy im Browser nicht ankam,
obwohl der Server bereits den neuen Build auslieferte. Jetzt:
`index.html` = `no-cache`, `/assets/*` = `immutable`
### Phase 4e: Sprach-Ein-/Ausgabe im Chat ✅
- [x] Mikrofon-Button (🎤) nutzt die Browser-eigene Web Speech API
(`SpeechRecognition`, `de-DE`) - reine Frontend-Loesung, blendet sich
selbst aus, wenn der Browser das nicht unterstuetzt (v.a. Firefox)
- [x] Assistant-Antworten werden automatisch per `SpeechSynthesis`
vorgelesen, Standard "an"; Mute-Button (🔊/🔇) merkt sich Zustand in
`localStorage` (`jarvis_speech_muted`)
- [x] Neues Modul `web/src/speech.ts` buendelt beide APIs (nach Vorbild von `api.ts`)
- [x] Bug gefixt (13.09.2026): `continuous=false` beendete die Erkennung bei
jeder Sprechpause. Jetzt `continuous=true` mit automatischem Neustart
bei Chrome's internem Session-Timeout (akkumuliert Text ueber
Neustarts hinweg, damit nichts verloren geht)
- [x] Sprachausgabe filtert vor dem Vorlesen: Emojis (inkl. Zahlen-Emoji wie
1⃣2⃣3⃣), Markdown-Formatierung (`**fett**`, `#Ueberschriften`) und
dekorative Trennlinien/Tabellenzeichen (`---`, `═══`, `|`) - siehe
`cleanForSpeech()` in `web/src/speech.ts`
### Phase 4f: E-Mail-Integration ✅
- [x] Postfach `kontakt@mbo-tech-it.de` (IMAP `mx2f35.netcup.net:143` STARTTLS,
SMTP `mx2f35.netcup.net:465` implizites TLS) via Python-Standardbibliothek
(`imaplib`, `smtplib`, `email`) angebunden - keine neue Dependency
- [x] Chat-Tools `list_recent_emails` (liest live per IMAP, `BODY.PEEK` damit
der Lese-Status nicht veraendert wird, RFC-2047-Betreffs dekodiert) und
`send_email` (SMTP_SSL) in `run_chat_completion()`, analog zum Kalender.
`send_email` verlangt wie `delete_calendar_event` immer erst eine
Bestaetigung im Chat, bevor tatsaechlich versendet wird
- [x] n8n-Workflow "Neue E-Mails Cache" (id `VaAQdw18ElYWkEfr`, aktiv):
eingebauter `Email Trigger (IMAP)`-Node (`postProcessAction: "nothing"`,
damit das Cachen den echten Lese-Status im Postfach nicht veraendert)
-> Postgres-Insert in `email_cache` (nur Metadaten, keine Volltexte)
- [x] `GET /api/v1/emails` liest die neuesten Zeilen; leerer Cache liefert
`[]` statt 503 (anders als beim Wetter kein Fehlerzustand)
- [x] `EmailWidget` ("Neue E-Mails") im Dashboard, nach demselben Muster wie
`CalendarWidget`/`WeatherWidget`
- [x] Live end-to-end getestet: Chat hat echte Inbox-Mails aufgelistet,
Testmail per Chat mit Bestaetigungsdialog versendet und im Postfach
verifiziert, n8n-Workflow hat dieselbe Mail in `email_cache` erfasst,
Dashboard-Widget zeigt sie an
- [x] Kleiner Fix waehrend der Verifikation: von JARVIS selbst versendete
Mails hatten keinen `Date`-Header (smtplib/EmailMessage setzt den nicht
automatisch) - `_send_email_sync` setzt jetzt `Date` und `Message-ID`
- Out of Scope (siehe Spec): Anhaenge, Antworten/Weiterleiten, Loeschen/
Verschieben von Mails, mehrere Postfaecher/Ordner
### Phase 4b: Weitere Automation ⏳ (offen)
- [ ] `POST/GET /api/v1/tasks` an die vorhandene `tasks`-Tabelle anbinden (aktuell Stub)
- [ ] `POST /api/v1/workflows/trigger` tatsaechlich an die n8n-API anbinden (aktuell Stub)
- [ ] Weitere n8n Business-Workflows definieren (Kundenbericht-Automation o.ä.)
### Phase 5: Production ⏳ (offen)
- [ ] Backup-Strategie (Postgres-Dumps automatisieren, aktuell nur manuell per `pg_dump`)
- [ ] Monitoring & Alerts (aktuell nur manuelles Ansehen von `/api/v1/admin/health/detailed`)
- [ ] Sicherheits-Hardening / echtes Multi-User-Auth-System
---
## 📞 Kontakt & Support
**Owner:** Jonny (Markus)
**Email:** jonny@mbo-tech-it.de
**Company:** MBO-Tech-IT
**Location:** Crailsheim, Baden-Württemberg
---
## 📚 Dokumentation & Links
- [FastAPI Docs](https://api.jarvis.mbo-tech-it.de/docs)
- [n8n Docs](https://docs.n8n.io)
- [PostgreSQL Docs](https://www.postgresql.org/docs/)
- [pgvector Docs](https://github.com/pgvector/pgvector)
- [Claude API Docs](https://docs.anthropic.com)
- [Docker Docs](https://docs.docker.com)
- [Traefik Docs](https://doc.traefik.io)
Spezifikationen und Implementierungspläne aller Phasen liegen lokal unter
`docs/superpowers/specs/` und `docs/superpowers/plans/`.
---
## 🎯 Quick Reference
### Start JARVIS
```bash
cd /home/jarvis-core/jarvis
docker compose up -d
```
### Check Status
```bash
docker compose ps
curl http://localhost:8000/health
```
### View Logs
```bash
docker compose logs -f
```
### Backend-Code aktualisieren
```bash
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
"Claude outputs/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"cd /home/jarvis-core/jarvis && docker compose restart jarvis-api"
```
### Frontend-Code aktualisieren
```bash
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key -r \
web/src jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web"
```
### Backup Database
```bash
docker exec jarvis-postgres pg_dump -U jarvis jarvis > backup.sql
```
---
**🎉 JARVIS ist bereit!**
*Letzte Aktualisierung: 13.09.2026*
*Deployment Status: ✅ LIVE*

View File

@ -0,0 +1,319 @@
# 🤖 JARVIS - KI-Assistent + Business Automation
**Vollständiges Docker-Stack für JARVIS: KI-Backend + Datenbanken + Workflow-Automation**
## Architektur-Übersicht
```
┌─────────────────────────────────────────────────────────┐
│ JARVIS CORE (VPS) │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ TRAEFIK (Reverse Proxy / Load Balancer) │ │
│ │ Ports: 80, 443, 8080 │ │
│ └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌──────────────────┬──────────────────┬──────────────┐ │
│ │ WEB │ API │ n8n │ │
│ │ (Frontend) │ (FastAPI) │ (Automation) │ │
│ │ Port: 3000 │ Port: 8000 │ Port: 5678 │ │
│ └──────────────────┴──────────────────┴──────────────┘ │
│ ▼ │
│ ┌──────────────────┬──────────────────┬──────────────┐ │
│ │ PostgreSQL │ Redis │ Milvus │ │
│ │ Port: 5432 │ Port: 6379 │ Port: 19530 │ │
│ │ (Structured) │ (Cache) │ (Vector DB) │ │
│ └──────────────────┴──────────────────┴──────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ OLLAMA (Local LLM - Optional) │ │
│ │ Port: 11434 │ │
│ │ Alternative: Claude API (via Python) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
## Schnelstart
### 1⃣ SSH-Zugriff einrichten
```bash
# Speichern der Private Key
# Putty: key → jarvis_ed25519.ppk
# SSH: ssh -i jarvis_ed25519 jarvis-core@72.61.186.98
```
### 2⃣ Setup-Script ausführen
```bash
ssh -i jarvis_ed25519 jarvis-core@72.61.186.98
# Download setup script
curl -o setup-jarvis-complete.sh https://path/to/setup-jarvis-complete.sh
chmod +x setup-jarvis-complete.sh
# Run with domain
./setup-jarvis-complete.sh jarvis.mbo-tech-it.de
```
### 3⃣ Konfigurieren
```bash
cd /home/jarvis-core/jarvis
# Edit .env - WICHTIG: API-Keys eintragen!
nano .env
# Erforderlich:
# - CLAUDE_API_KEY (für Claude API)
# - DB_PASSWORD (wird generiert, aber prüfe es)
# - Alle anderen Keys werden auto-generiert
```
### 4⃣ Docker-Stack starten
```bash
# Starten
docker-compose up -d
# Logs prüfen
docker-compose logs -f
# Status
docker-compose ps
```
## Services & Ports
| Service | URL | Port | Beschreibung |
|---------|-----|------|-------------|
| **Web Dashboard** | http://localhost:3000 | 3000 | Frontend UI |
| **API Backend** | http://localhost:8000 | 8000 | FastAPI Server |
| **n8n Workflows** | http://localhost:5678 | 5678 | Automatisierung |
| **Traefik Dashboard** | http://localhost:8080 | 8080 | Proxy Management |
| **PostgreSQL** | localhost | 5432 | Hauptdatenbank |
| **Redis** | localhost | 6379 | Cache/Sessions |
| **Milvus** | localhost | 19530 | Vector Search |
| **Ollama** | http://localhost:11434 | 11434 | Local LLM (opt.) |
## Datenbankstruktur
### PostgreSQL (jarvis)
- **users** - Benutzer & Authentifizierung
- **conversations** - Chat-Verlauf
- **messages** - Einzelne Nachrichten
- **tasks** - Automatisierungsaufgaben
- **documents** - Knowledge Base
- **audit_logs** - Audit Trail
### Milvus (Vector DB)
- Semantic Search in Dokumenten
- Embeddings für RAG (Retrieval-Augmented Generation)
### Redis
- Session Management
- Caching
- Job Queue (für n8n)
## API Endpoints
### Chat
```bash
POST /api/v1/chat
{
"conversation_id": 1,
"message": "Deine Frage",
"context": {}
}
GET /api/v1/conversations/{id}
```
### Tasks
```bash
POST /api/v1/tasks
{
"title": "Task Name",
"description": "Beschreibung",
"task_type": "automation",
"priority": 1
}
GET /api/v1/tasks?status=pending
```
### Documents (Knowledge Base)
```bash
POST /api/v1/documents
{
"title": "Dokument",
"content": "...",
"document_type": "manual"
}
GET /api/v1/documents?query=search+term
```
### Workflows (n8n)
```bash
POST /api/v1/workflows/trigger
{
"workflow_id": "workflow_name",
"data": {}
}
```
## Umgebungsvariablen (.env)
```bash
# Domain
DOMAIN=jarvis.mbo-tech-it.de
# Database
DB_PASSWORD=SecurePassword123!
# KI / LLM
CLAUDE_API_KEY=sk-ant-...
OLLAMA_MODEL=mistral
# Security
JWT_SECRET=random-secret-key
API_KEY_ADMIN=admin-api-key
# Logging
LOG_LEVEL=info
```
## Häufige Befehle
```bash
cd /home/jarvis-core/jarvis
# Alle Container starten
docker-compose up -d
# Spezifischen Service neu starten
docker-compose restart jarvis-api
# Logs ansehen
docker-compose logs -f jarvis-api
# In Container gehen
docker-compose exec jarvis-api bash
# Database mit psql verbinden
docker-compose exec postgres psql -U jarvis -d jarvis
# Alle Container stoppen
docker-compose down
# Mit Daten löschen (Vorsicht!)
docker-compose down -v
```
## Integration mit Claude API
### Python-Client Beispiel
```python
import os
import anthropic
client = anthropic.Anthropic(api_key=os.getenv("CLAUDE_API_KEY"))
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[
{"role": "user", "content": "Deine Frage"}
]
)
print(message.content[0].text)
```
## n8n Workflows
### Beispiel-Automation: Kundenbericht generieren
1. Trigger: Zeitplan (täglich)
2. Node: PostgreSQL - Daten abrufen
3. Node: Claude API - Bericht generieren
4. Node: PDF generieren
5. Node: Email versenden
## Security Best Practices
✅ **Production Ready:**
- [ ] HTTPS/SSL konfigurieren (Let's Encrypt)
- [ ] Firewall-Regeln (nur erlaubte IPs)
- [ ] Secrets in .env nicht in Git
- [ ] Regelmäßige DB-Backups
- [ ] Monitoring & Alerts einrichten
- [ ] Rate Limiting aktivieren
- [ ] API-Keys rotieren
⚠️ **Aktuell (Test):**
- HTTP nur lokal/intern
- Standard-Passwörter (ÄNDERN!)
- Keine Authentifizierung erzwungen
## Troubleshooting
### Container startet nicht
```bash
docker-compose logs jarvis-api
# → Fehlerlog prüfen
```
### Datenbank nicht erreichbar
```bash
docker-compose exec postgres psql -U jarvis -d jarvis -c "SELECT 1"
```
### Redis Cache Fehler
```bash
docker-compose exec redis redis-cli ping
# → PONG = OK
```
### Milvus Vector DB Fehler
```bash
docker-compose exec milvus curl http://localhost:9091/healthz
```
## Monitoring
```bash
# System-Stats
docker stats
# Container Logs (real-time)
docker-compose logs -f
# Health Check
curl http://localhost:8000/health
curl http://localhost:8000/api/v1/admin/health/detailed
```
## Nächste Schritte
1. ✅ Docker-Stack deployed
2. ⏳ Claude API Integration (Python)
3. ⏳ Web-Frontend (React/Vue)
4. ⏳ n8n Workflows konfigurieren
5. ⏳ Knowledge Base aufbauen
6. ⏳ Production Deployment (SSL, Backups, etc.)
## Support & Dokumentation
- [FastAPI Docs](http://localhost:8000/docs) - Swagger UI
- [n8n Docs](https://docs.n8n.io)
- [PostgreSQL Docs](https://www.postgresql.org/docs/)
- [Milvus Docs](https://milvus.io/docs)
- [Claude API Docs](https://docs.anthropic.com)
---
🚀 **JARVIS ist bereit für die KI-Integration!**

View File

@ -0,0 +1,382 @@
#!/bin/bash
set -e
echo "🤖 ============ JARVIS FULL DEPLOYMENT ============"
echo ""
# ============ COLORS ============
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
# ============ CONFIGURATION ============
DOMAIN="${1:-jarvis.local}"
JARVIS_HOME="/home/jarvis-core/jarvis"
echo -e "${BLUE}📍 Setup Directory: $JARVIS_HOME${NC}"
echo -e "${BLUE}📍 Domain: $DOMAIN${NC}"
echo ""
# ============ 1. SYSTEM UPDATE ============
echo -e "${YELLOW}[1/9] System Update...${NC}"
sudo apt-get update -qq
sudo apt-get upgrade -y -qq
sudo apt-get install -y -qq curl wget git htop net-tools openssl python3-pip > /dev/null 2>&1
echo -e "${GREEN}✅ System updated${NC}"
# ============ 2. DOCKER INSTALLATION ============
echo -e "${YELLOW}[2/9] Installing Docker...${NC}"
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com -o get-docker.sh > /dev/null 2>&1
bash get-docker.sh > /dev/null 2>&1
rm -f get-docker.sh
sudo usermod -aG docker jarvis-core
echo -e "${GREEN}✅ Docker installed${NC}"
else
echo -e "${GREEN}✅ Docker already installed${NC}"
fi
# ============ 3. DOCKER COMPOSE ============
echo -e "${YELLOW}[3/9] Installing Docker Compose...${NC}"
if ! command -v docker-compose &> /dev/null; then
COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep 'tag_name' | cut -d'"' -f4)
sudo curl -L "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose > /dev/null 2>&1
sudo chmod +x /usr/local/bin/docker-compose
echo -e "${GREEN}✅ Docker Compose installed${NC}"
else
echo -e "${GREEN}✅ Docker Compose already installed${NC}"
fi
# ============ 4. PROJECT STRUCTURE ============
echo -e "${YELLOW}[4/9] Creating project structure...${NC}"
mkdir -p $JARVIS_HOME/{config,data,logs,api,web}
cd $JARVIS_HOME
mkdir -p data/{postgres,redis,milvus,etcd,ollama,n8n}
mkdir -p logs/{api,n8n,nginx}
echo -e "${GREEN}✅ Project structure created${NC}"
# ============ 5. DOCKER NETWORK ============
echo -e "${YELLOW}[5/9] Creating Docker network...${NC}"
docker network create jarvis-net 2>/dev/null || true
echo -e "${GREEN}✅ Docker network ready${NC}"
# ============ 6. DOCKER-COMPOSE.YML ============
echo -e "${YELLOW}[6/9] Writing docker-compose.yml...${NC}"
cat > docker-compose.yml << 'EOF'
version: '3.9'
services:
traefik:
image: traefik:v2.10
container_name: jarvis-traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- jarvis-net
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: jarvis-postgres
environment:
POSTGRES_DB: jarvis
POSTGRES_USER: jarvis
POSTGRES_PASSWORD: ${DB_PASSWORD:-ChangeMe123!}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
networks:
- jarvis-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U jarvis"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: jarvis-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
networks:
- jarvis-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
milvus:
image: milvusdb/milvus:v0.4.0
container_name: jarvis-milvus
environment:
ETCD_ENDPOINTS: etcd:2379
COMMON_STORAGETYPE: local
volumes:
- milvus_data:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
etcd:
condition: service_healthy
networks:
- jarvis-net
restart: unless-stopped
etcd:
image: quay.io/coreos/etcd:v3.5.5
container_name: jarvis-etcd
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- etcd_data:/etcd
ports:
- "2379:2379"
networks:
- jarvis-net
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 10s
timeout: 5s
retries: 5
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
restart: unless-stopped
ollama:
image: ollama/ollama:latest
container_name: jarvis-ollama
environment:
- OLLAMA_HOST=0.0.0.0:11434
volumes:
- ollama_data:/root/.ollama
ports:
- "11434:11434"
networks:
- jarvis-net
restart: unless-stopped
n8n:
image: n8n:latest
container_name: jarvis-n8n
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_USER=jarvis
- DB_POSTGRESDB_PASSWORD=${DB_PASSWORD:-ChangeMe123!}
- DB_POSTGRESDB_DATABASE=n8n
- N8N_HOST=${DOMAIN:-localhost}
- N8N_PORT=5678
- WEBHOOK_TUNNEL_URL=http://n8n:5678/
- GENERIC_TIMEZONE=Europe/Berlin
volumes:
- n8n_data:/home/node/.n8n
ports:
- "5678:5678"
depends_on:
postgres:
condition: service_healthy
networks:
- jarvis-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n.rule=Host(`n8n.${DOMAIN:-localhost}`)"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
restart: unless-stopped
jarvis-api:
image: python:3.11-slim
container_name: jarvis-api
working_dir: /app
command: >
bash -c "pip install -q -r requirements.txt &&
uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
environment:
- DATABASE_URL=postgresql://jarvis:${DB_PASSWORD:-ChangeMe123!}@postgres:5432/jarvis
- REDIS_URL=redis://redis:6379
- MILVUS_HOST=milvus
- MILVUS_PORT=19530
- OLLAMA_HOST=http://ollama:11434
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- LOG_LEVEL=info
volumes:
- ./api:/app
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- jarvis-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.${DOMAIN:-localhost}`)"
- "traefik.http.services.api.loadbalancer.server.port=8000"
restart: unless-stopped
networks:
jarvis-net:
driver: bridge
volumes:
postgres_data:
redis_data:
milvus_data:
etcd_data:
ollama_data:
n8n_data:
EOF
echo -e "${GREEN}✅ docker-compose.yml created${NC}"
# ============ 7. ENV FILE ============
echo -e "${YELLOW}[7/9] Creating .env file...${NC}"
cat > .env << EOF
DOMAIN=$DOMAIN
DB_PASSWORD=$(openssl rand -base64 32)
CLAUDE_API_KEY=
OLLAMA_MODEL=mistral
LOG_LEVEL=info
JWT_SECRET=$(openssl rand -base64 32)
API_KEY_ADMIN=$(openssl rand -base64 32)
N8N_ENCRYPTION_KEY=$(openssl rand -base64 32)
EOF
chmod 600 .env
echo -e "${GREEN}✅ .env created${NC}"
# ============ 8. DATABASE & API ============
echo -e "${YELLOW}[8/9] Creating database schema...${NC}"
cat > init-db.sql << 'EOF'
CREATE DATABASE jarvis;
CREATE DATABASE n8n;
\c jarvis;
CREATE TABLE users (id SERIAL PRIMARY KEY, username VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, api_key VARCHAR(255) UNIQUE, role VARCHAR(50) DEFAULT 'user', is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE conversations (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255), context JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX idx_conversations_user_id ON conversations(user_id);
CREATE TABLE messages (id SERIAL PRIMARY KEY, conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role VARCHAR(50) NOT NULL, content TEXT NOT NULL, tokens_used INTEGER, metadata JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
CREATE TABLE tasks (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, description TEXT, task_type VARCHAR(100), status VARCHAR(50) DEFAULT 'pending', priority INTEGER DEFAULT 0, data JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP);
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE TABLE documents (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, document_type VARCHAR(100), metadata JSONB, embedding_id VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO users (username, email, password_hash, role) VALUES ('admin', 'admin@jarvis.local', '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3djPvga3jaK', 'admin') ON CONFLICT DO NOTHING;
EOF
# Create API directory and files
mkdir -p api
cat > api/requirements.txt << 'EOF'
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-dotenv==1.0.0
pydantic==2.5.0
psycopg2-binary==2.9.9
redis==5.0.1
pymilvus==2.3.4
anthropic==0.14.0
aiohttp==3.9.1
requests==2.31.0
EOF
cat > api/main.py << 'EOF'
from fastapi import FastAPI
from datetime import datetime
import os
app = FastAPI(title="JARVIS API", version="0.1.0")
@app.get("/health")
async def health():
return {"status": "healthy", "timestamp": datetime.now()}
@app.get("/api/v1/admin/stats")
async def stats():
return {"status": "ok", "timestamp": datetime.now()}
@app.post("/api/v1/chat")
async def chat(message: str):
return {"response": f"Echo: {message}", "timestamp": datetime.now()}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
EOF
echo -e "${GREEN}✅ Database schema and API created${NC}"
# ============ 9. PERMISSIONS ============
echo -e "${YELLOW}[9/9] Setting permissions...${NC}"
sudo chown -R jarvis-core:jarvis-core $JARVIS_HOME
chmod -R 755 $JARVIS_HOME
chmod 600 .env
echo -e "${GREEN}✅ Permissions set${NC}"
# ============ DEPLOYMENT ============
echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN}🚀 STARTING DOCKER CONTAINERS...${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
docker-compose up -d
echo ""
echo -e "${BLUE}Waiting for services to start... (30s)${NC}"
sleep 30
echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN}✅ JARVIS DEPLOYED SUCCESSFULLY!${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
echo -e "${BLUE}📍 Services:${NC}"
echo " 🌐 Web Dashboard: http://$DOMAIN:3000 (⏳ coming soon)"
echo " 📡 API: http://$DOMAIN:8000"
echo " 📡 API Docs: http://$DOMAIN:8000/docs"
echo " ⚙️ n8n: http://$DOMAIN:5678"
echo " 🔌 Traefik: http://$DOMAIN:8080"
echo ""
echo -e "${BLUE}📊 Database Access:${NC}"
echo " PostgreSQL: localhost:5432 (user: jarvis)"
echo " Redis: localhost:6379"
echo " Milvus: localhost:19530"
echo " Ollama: localhost:11434"
echo ""
echo -e "${BLUE}📝 Next steps:${NC}"
echo " 1. Edit .env - Add CLAUDE_API_KEY"
echo " 2. Restart API: docker-compose restart jarvis-api"
echo " 3. Check logs: docker-compose logs -f"
echo ""
echo -e "${YELLOW}⚠️ Important:${NC}"
echo " • Change default admin password in database"
echo " • Setup SSL/HTTPS in production"
echo " • Configure regular backups"
echo ""
# Status
echo -e "${BLUE}📊 Container Status:${NC}"
docker-compose ps
echo ""
echo -e "${GREEN}🎉 Ready to go! Start building with JARVIS!${NC}"

View File

@ -0,0 +1,208 @@
services:
postgres:
image: pgvector/pgvector:pg16
container_name: jarvis-postgres
environment:
POSTGRES_DB: jarvis
POSTGRES_USER: jarvis
POSTGRES_PASSWORD: ${DB_PASSWORD:-ChangeMe123!}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
networks:
- jarvis-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U jarvis"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: jarvis-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
networks:
- jarvis-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# NOTE: milvus/etcd are placeholders - milvusdb/milvus:v0.4.0 does not exist
# and this config format (etcd-only, no MinIO) doesn't match modern Milvus
# standalone anyway. Not started yet; needs a proper Phase 3 design.
milvus:
image: milvusdb/milvus:v0.4.0
container_name: jarvis-milvus
environment:
ETCD_ENDPOINTS: etcd:2379
COMMON_STORAGETYPE: local
volumes:
- milvus_data:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
etcd:
condition: service_healthy
networks:
- jarvis-net
restart: unless-stopped
etcd:
image: quay.io/coreos/etcd:v3.5.5
container_name: jarvis-etcd
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- etcd_data:/etcd
ports:
- "2379:2379"
networks:
- jarvis-net
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 10s
timeout: 5s
retries: 5
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
restart: unless-stopped
ollama:
image: ollama/ollama:latest
container_name: jarvis-ollama
environment:
- OLLAMA_HOST=0.0.0.0:11434
volumes:
- ollama_data:/root/.ollama
ports:
- "11434:11434"
networks:
- jarvis-net
restart: unless-stopped
n8n:
image: n8nio/n8n:latest
container_name: jarvis-n8n
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_USER=jarvis
- DB_POSTGRESDB_PASSWORD=${DB_PASSWORD:-ChangeMe123!}
- DB_POSTGRESDB_DATABASE=n8n
- N8N_HOST=n8n.${DOMAIN:-localhost}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- WEBHOOK_TUNNEL_URL=https://n8n.${DOMAIN:-localhost}/
- GENERIC_TIMEZONE=Europe/Berlin
volumes:
- n8n_data:/home/node/.n8n
ports:
- "5678:5678"
depends_on:
postgres:
condition: service_healthy
networks:
- jarvis-net
- proxy-network
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy-network"
- "traefik.http.routers.jarvis-n8n.rule=Host(`n8n.${DOMAIN:-localhost}`)"
- "traefik.http.routers.jarvis-n8n.entrypoints=websecure"
- "traefik.http.routers.jarvis-n8n.tls=true"
- "traefik.http.routers.jarvis-n8n.tls.certresolver=netcup"
- "traefik.http.services.jarvis-n8n.loadbalancer.server.port=5678"
restart: unless-stopped
jarvis-api:
image: python:3.11-slim
container_name: jarvis-api
working_dir: /app
command: >
bash -c "pip install -q -r requirements.txt &&
uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
environment:
- DATABASE_URL=postgresql://jarvis:${DB_PASSWORD:-ChangeMe123!}@postgres:5432/jarvis
- REDIS_URL=redis://redis:6379
- MILVUS_HOST=milvus
- MILVUS_PORT=19530
- OLLAMA_HOST=http://ollama:11434
- OLLAMA_EMBED_MODEL=nomic-embed-text
- N8N_URL=http://n8n:5678
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- CLAUDE_MODEL=claude-sonnet-5
- API_KEY_ADMIN=${API_KEY_ADMIN:-}
- NEXTCLOUD_CALDAV_URL=https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/
- NEXTCLOUD_USER=jonny
- NEXTCLOUD_APP_PASSWORD=${NEXTCLOUD_APP_PASSWORD:-}
- EMAIL_IMAP_HOST=mx2f35.netcup.net
- EMAIL_IMAP_PORT=143
- EMAIL_SMTP_HOST=mx2f35.netcup.net
- EMAIL_SMTP_PORT=465
- EMAIL_USER=kontakt@mbo-tech-it.de
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
- LOG_LEVEL=info
volumes:
- ./api:/app
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- jarvis-net
- proxy-network
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy-network"
- "traefik.http.routers.jarvis-api.rule=Host(`api.${DOMAIN:-localhost}`)"
- "traefik.http.routers.jarvis-api.entrypoints=websecure"
- "traefik.http.routers.jarvis-api.tls=true"
- "traefik.http.routers.jarvis-api.tls.certresolver=netcup"
- "traefik.http.services.jarvis-api.loadbalancer.server.port=8000"
restart: unless-stopped
jarvis-web:
build:
context: ./web
dockerfile: Dockerfile
container_name: jarvis-web
networks:
- proxy-network
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy-network"
- "traefik.http.routers.jarvis-web.rule=Host(`${DOMAIN:-localhost}`)"
- "traefik.http.routers.jarvis-web.entrypoints=websecure"
- "traefik.http.routers.jarvis-web.tls=true"
- "traefik.http.routers.jarvis-web.tls.certresolver=netcup"
- "traefik.http.services.jarvis-web.loadbalancer.server.port=80"
restart: unless-stopped
networks:
jarvis-net:
driver: bridge
proxy-network:
external: true
volumes:
postgres_data:
redis_data:
milvus_data:
etcd_data:
ollama_data:
n8n_data:

View File

@ -0,0 +1,23 @@
# ============ DOMAIN CONFIGURATION ============
DOMAIN=jarvis.local
# DOMAIN=jarvis.mbo-tech-it.de (for production)
# ============ DATABASE ============
DB_PASSWORD=SecurePassword123!JarvisDB
# ============ AI/LLM ============
CLAUDE_API_KEY=sk-ant-...your-api-key-here...
CLAUDE_MODEL=claude-sonnet-5
# Optional: Use local Ollama instead
OLLAMA_MODEL=mistral
# ============ SECURITY ============
JWT_SECRET=your-super-secret-jwt-key-here-change-this
API_KEY_ADMIN=admin-api-key-secure-change-this
# ============ LOGGING ============
LOG_LEVEL=info
# Log levels: debug, info, warn, error
# ============ N8N ============
N8N_ENCRYPTION_KEY=your-n8n-encryption-key-change-this

141
Claude outputs/init-db.sql Normal file
View File

@ -0,0 +1,141 @@
-- ============ JARVIS DATABASE INITIALIZATION ============
-- "jarvis" is already created by the postgres image via POSTGRES_DB;
-- creating it again here would abort the whole init script.
CREATE DATABASE n8n;
-- Connect to jarvis database
\c jarvis;
-- ============ CORE TABLES ============
-- Users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
api_key VARCHAR(255) UNIQUE,
role VARCHAR(50) DEFAULT 'user',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Conversations/Chat history
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255),
context JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_conversations_user_id ON conversations(user_id);
-- Chat messages
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
tokens_used INTEGER,
metadata JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
CREATE INDEX idx_messages_user_id ON messages(user_id);
-- Business automation tasks
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
task_type VARCHAR(100),
status VARCHAR(50) DEFAULT 'pending',
priority INTEGER DEFAULT 0,
data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_status ON tasks(status);
-- Knowledge base documents
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
document_type VARCHAR(100),
metadata JSONB,
embedding_id VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_documents_user_id ON documents(user_id);
CREATE INDEX idx_documents_embedding_id ON documents(embedding_id);
-- Audit log
CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(255) NOT NULL,
resource_type VARCHAR(100),
resource_id INTEGER,
changes JSONB,
ip_address INET,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
-- ============ TRIGGERS ============
-- Auto-update timestamp function
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply trigger to tables with updated_at
CREATE TRIGGER trigger_update_timestamp_users
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TRIGGER trigger_update_timestamp_conversations
BEFORE UPDATE ON conversations
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TRIGGER trigger_update_timestamp_tasks
BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TRIGGER trigger_update_timestamp_documents
BEFORE UPDATE ON documents
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
-- ============ DEFAULT DATA ============
-- Insert admin user (password: admin - CHANGE IN PRODUCTION!)
INSERT INTO users (username, email, password_hash, role)
VALUES (
'admin',
'admin@jarvis.local',
'$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3djPvga3jaK',
'admin'
) ON CONFLICT DO NOTHING;
-- Permissions/Roles would go here
-- Add your own initialization data

View File

@ -3,7 +3,7 @@ JARVIS API Backend
FastAPI application for JARVIS AI Assistant + Business Automation FastAPI application for JARVIS AI Assistant + Business Automation
""" """
from fastapi import FastAPI, HTTPException, Depends, Header from fastapi import FastAPI, HTTPException, Depends, Header, Body
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@ -56,11 +56,6 @@ EMAIL_SMTP_HOST = os.getenv("EMAIL_SMTP_HOST")
EMAIL_SMTP_PORT = int(os.getenv("EMAIL_SMTP_PORT", "465")) EMAIL_SMTP_PORT = int(os.getenv("EMAIL_SMTP_PORT", "465"))
EMAIL_USER = os.getenv("EMAIL_USER") EMAIL_USER = os.getenv("EMAIL_USER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD") EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
CLAUDE_SYSTEM_PROMPT = os.getenv(
"CLAUDE_SYSTEM_PROMPT",
"Du bist JARVIS, ein KI-Assistent fuer Business-Automatisierung.",
)
# ============ INITIALIZATION ============ # ============ INITIALIZATION ============
app = FastAPI( app = FastAPI(
title="JARVIS API", title="JARVIS API",
@ -304,6 +299,36 @@ async def get_all_memory_facts() -> list:
) )
SETTINGS_DEFAULTS = {
"assistant_name": "JARVIS",
"company_name": "MBO-Tech-IT",
"contact_email": "kontakt@mbo-tech-it.de",
}
async def get_setting(key: str) -> str:
row = await db_query("SELECT value FROM settings WHERE key = %s", (key,), fetch="one")
return row["value"] if row else SETTINGS_DEFAULTS[key]
async def get_all_settings() -> dict:
rows = await db_query("SELECT key, value FROM settings", fetch="all")
merged = dict(SETTINGS_DEFAULTS)
for row in rows:
merged[row["key"]] = row["value"]
return merged
async def set_setting(key: str, value: str):
await db_query(
"""
INSERT INTO settings (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
""",
(key, value),
)
async def upsert_conversation_summary(conversation_id: int, summary: str, embedding: list): async def upsert_conversation_summary(conversation_id: int, summary: str, embedding: list):
await db_query( await db_query(
""" """
@ -1040,12 +1065,25 @@ async def execute_tool(name: str, tool_input: dict) -> str:
MAX_TOOL_ROUNDS = 5 MAX_TOOL_ROUNDS = 5
async def _identity_system_prompt() -> str:
try:
settings = await get_all_settings()
except Exception as e:
logger.warning(f"Settings unavailable, using defaults: {e}")
settings = SETTINGS_DEFAULTS
return (
f"Du bist {settings['assistant_name']}, der KI-Assistent von "
f"{settings['company_name']}. Bei Fragen zur Erreichbarkeit kannst "
f"du auf {settings['contact_email']} verweisen."
)
async def run_chat_completion(claude_messages: list, conversation_id: int): async def run_chat_completion(claude_messages: list, conversation_id: int):
latest_user_message = claude_messages[-1]["content"] latest_user_message = claude_messages[-1]["content"]
memory_context = await build_memory_context(latest_user_message, conversation_id) memory_context = await build_memory_context(latest_user_message, conversation_id)
system_prompt = ( system_prompt = (
f"{CLAUDE_SYSTEM_PROMPT}\n\n{_current_datetime_context()}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}\n\n" f"{await _identity_system_prompt()}\n\n{_current_datetime_context()}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}\n\n"
f"{MEMORY_ASSISTANT_INSTRUCTIONS}\n\n{ORDER_ASSISTANT_INSTRUCTIONS}" f"{MEMORY_ASSISTANT_INSTRUCTIONS}\n\n{ORDER_ASSISTANT_INSTRUCTIONS}"
) )
if memory_context: if memory_context:
@ -1467,6 +1505,20 @@ async def get_orders(status: Optional[str] = None):
logger.error(f"Orders fetch error: {str(e)}") logger.error(f"Orders fetch error: {str(e)}")
raise HTTPException(status_code=503, detail=f"Orders unavailable: {str(e)}") raise HTTPException(status_code=503, detail=f"Orders unavailable: {str(e)}")
@app.get("/api/v1/settings", dependencies=[Depends(require_admin_key)])
async def get_settings_endpoint():
return await get_all_settings()
@app.put("/api/v1/settings", dependencies=[Depends(require_admin_key)])
async def update_settings_endpoint(updates: dict = Body(...)):
for key in updates:
if key not in SETTINGS_DEFAULTS:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
for key, value in updates.items():
await set_setting(key, str(value))
return await get_all_settings()
@app.get("/api/v1/emails", dependencies=[Depends(require_admin_key)]) @app.get("/api/v1/emails", dependencies=[Depends(require_admin_key)])
async def get_cached_emails(limit: int = 10): async def get_cached_emails(limit: int = 10):
"""Recently cached emails, populated by the n8n 'Neue E-Mails Cache' workflow""" """Recently cached emails, populated by the n8n 'Neue E-Mails Cache' workflow"""

View File

@ -0,0 +1,14 @@
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding vector(768) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_document_chunks_document_id ON document_chunks(document_id);
CREATE INDEX idx_document_chunks_embedding ON document_chunks
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

View File

@ -0,0 +1,12 @@
-- The ivfflat index built with lists=100 against a near-empty table has such
-- low recall that ORDER BY ... LIMIT queries silently return zero rows
-- (confirmed 2026-09-12: a single-row table returned no match under LIMIT,
-- and matched only after `SET ivfflat.probes = 10`). This is exactly what
-- Postgres warned about when creating the index ("This will cause low
-- recall... Drop the index until the table has more data").
--
-- Dropping it now: a sequential scan is correct and fast enough at this
-- data volume. Recreate an ivfflat (or hnsw) index once document_chunks
-- has enough rows for meaningful centroids (pgvector docs suggest
-- lists ~= sqrt(row_count)).
DROP INDEX IF EXISTS idx_document_chunks_embedding;

View File

@ -0,0 +1,10 @@
CREATE TABLE weather_cache (
id SERIAL PRIMARY KEY,
location VARCHAR(100) NOT NULL,
temperature_c NUMERIC NOT NULL,
condition_code INTEGER NOT NULL,
condition_text VARCHAR(100) NOT NULL,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_weather_cache_fetched_at ON weather_cache(fetched_at);

View File

@ -0,0 +1,9 @@
CREATE TABLE email_cache (
id SERIAL PRIMARY KEY,
sender VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
received_at TIMESTAMP NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_email_cache_received_at ON email_cache(received_at);

View File

@ -0,0 +1,5 @@
CREATE TABLE settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

View File

@ -0,0 +1,3 @@
pytest==8.3.3
httpx==0.27.2
pytest-asyncio==0.24.0

View File

@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
psycopg2-binary==2.9.9
redis==5.0.1
anthropic
aiohttp==3.9.1
caldav
icalendar

View File

@ -0,0 +1,126 @@
#!/bin/bash
set -e
echo "🤖 ============ JARVIS CORE SETUP ============"
echo ""
# ============ COLORS ============
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'
# ============ CONFIGURATION ============
JARVIS_HOME="/home/jarvis-core/jarvis"
DOMAIN="${1:-jarvis.local}"
echo -e "${BLUE}📍 Setup Directory: $JARVIS_HOME${NC}"
echo -e "${BLUE}📍 Domain: $DOMAIN${NC}"
echo ""
# ============ 1. SYSTEM UPDATE ============
echo -e "${YELLOW}[1/7] System Update...${NC}"
sudo apt-get update -qq
sudo apt-get upgrade -y -qq
sudo apt-get install -y -qq \
curl \
wget \
git \
htop \
net-tools \
openssl > /dev/null
echo -e "${GREEN}✅ System updated${NC}"
# ============ 2. DOCKER ============
echo -e "${YELLOW}[2/7] Installing Docker...${NC}"
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com -o get-docker.sh
sudo bash get-docker.sh > /dev/null 2>&1
rm get-docker.sh
sudo usermod -aG docker jarvis-core
echo -e "${GREEN}✅ Docker installed${NC}"
else
echo -e "${GREEN}✅ Docker already installed${NC}"
fi
# ============ 3. DOCKER COMPOSE ============
echo -e "${YELLOW}[3/7] Installing Docker Compose...${NC}"
if ! command -v docker-compose &> /dev/null; then
COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep 'tag_name' | cut -d'"' -f4)
sudo curl -L "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose > /dev/null 2>&1
sudo chmod +x /usr/local/bin/docker-compose
echo -e "${GREEN}✅ Docker Compose installed${NC}"
else
echo -e "${GREEN}✅ Docker Compose already installed${NC}"
fi
# ============ 4. PROJECT STRUCTURE ============
echo -e "${YELLOW}[4/7] Creating project structure...${NC}"
mkdir -p $JARVIS_HOME/{config,data,logs,api,web}
cd $JARVIS_HOME
# Create directories
mkdir -p data/{postgres,redis,milvus,etcd,ollama,n8n}
mkdir -p logs/{api,n8n,nginx}
echo -e "${GREEN}✅ Project structure created${NC}"
# ============ 5. CONFIGURATION FILES ============
echo -e "${YELLOW}[5/7] Creating configuration files...${NC}"
# Copy docker-compose.yml (you'll need to provide this)
# This assumes files are available locally
if [ ! -f "docker-compose.yml" ]; then
echo -e "${YELLOW}⚠️ docker-compose.yml not found. Please copy it to $JARVIS_HOME${NC}"
fi
# Create .env file
cat > .env << EOF
DOMAIN=$DOMAIN
DB_PASSWORD=$(openssl rand -base64 32)
CLAUDE_API_KEY=
OLLAMA_MODEL=mistral
LOG_LEVEL=info
JWT_SECRET=$(openssl rand -base64 32)
API_KEY_ADMIN=$(openssl rand -base64 32)
N8N_ENCRYPTION_KEY=$(openssl rand -base64 32)
EOF
echo -e "${GREEN}✅ Configuration created${NC}"
echo -e "${YELLOW} 📝 Edit .env with your API keys!${NC}"
# ============ 6. VOLUMES & PERMISSIONS ============
echo -e "${YELLOW}[6/7] Setting up volumes...${NC}"
sudo chown -R jarvis-core:jarvis-core $JARVIS_HOME
chmod -R 755 $JARVIS_HOME
chmod 600 .env
echo -e "${GREEN}✅ Volumes configured${NC}"
# ============ 7. DOCKER NETWORK ============
echo -e "${YELLOW}[7/7] Creating Docker network...${NC}"
docker network create jarvis-net 2>/dev/null || true
echo -e "${GREEN}✅ Docker network ready${NC}"
echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN}🚀 JARVIS CORE READY FOR DEPLOYMENT${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
echo -e "${BLUE}Next steps:${NC}"
echo "1. cd $JARVIS_HOME"
echo "2. Edit .env - Add your CLAUDE_API_KEY"
echo "3. docker-compose up -d"
echo ""
echo -e "${BLUE}Services:${NC}"
echo " • API: http://$DOMAIN:8000"
echo " • n8n: http://$DOMAIN:5678"
echo " • Web: http://$DOMAIN:3000"
echo " • Traefik: http://$DOMAIN:8080"
echo " • Postgres: 5432"
echo " • Redis: 6379"
echo " • Milvus: 19530"
echo " • Ollama: 11434"
echo ""
echo -e "${YELLOW}⚠️ IMPORTANT: Change default passwords in production!${NC}"
echo ""

View File

@ -0,0 +1,37 @@
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from fastapi.testclient import TestClient
import main
main.API_KEY_ADMIN = "test-secret"
@pytest.fixture()
def client():
with TestClient(main.app) as c:
yield c
def test_missing_key_returns_401(client):
response = client.get("/api/v1/admin/stats")
assert response.status_code == 401
def test_wrong_key_returns_401(client):
response = client.get("/api/v1/admin/stats", headers={"X-Admin-Key": "nope"})
assert response.status_code == 401
def test_correct_key_passes_auth_gate(client):
response = client.get("/api/v1/admin/stats", headers={"X-Admin-Key": "test-secret"})
assert response.status_code != 401
def test_health_stays_open_without_key(client):
response = client.get("/health")
assert response.status_code != 401

View File

@ -43,7 +43,9 @@ async def test_run_chat_completion_without_tool_use():
main.claude_client = MagicMock() main.claude_client = MagicMock()
main.claude_client.messages.create.return_value = completion main.claude_client.messages.create.return_value = completion
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")): with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")
):
text, output_tokens, total_tokens = await main.run_chat_completion( text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Hi"}], conversation_id=1 [{"role": "user", "content": "Hi"}], conversation_id=1
) )
@ -70,7 +72,7 @@ async def test_run_chat_completion_executes_tool_and_returns_followup():
with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[])), patch.object( with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[])), patch.object(
main, "build_memory_context", new=AsyncMock(return_value="") main, "build_memory_context", new=AsyncMock(return_value="")
): ), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
text, output_tokens, total_tokens = await main.run_chat_completion( text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Was steht diese Woche an?"}], conversation_id=1 [{"role": "user", "content": "Was steht diese Woche an?"}], conversation_id=1
) )
@ -108,7 +110,9 @@ async def test_run_chat_completion_handles_two_sequential_tool_calls():
with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[{"uid": "abc"}])), patch.object( with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[{"uid": "abc"}])), patch.object(
main, "update_event", new=AsyncMock(return_value={"uid": "abc"}) main, "update_event", new=AsyncMock(return_value={"uid": "abc"})
), patch.object(main, "build_memory_context", new=AsyncMock(return_value="")): ), patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")
):
text, output_tokens, total_tokens = await main.run_chat_completion( text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Verschiebe den Termin X"}], conversation_id=1 [{"role": "user", "content": "Verschiebe den Termin X"}], conversation_id=1
) )
@ -136,7 +140,7 @@ async def test_run_chat_completion_lists_recent_emails():
with patch.object(main, "list_recent_emails", new=AsyncMock(return_value=[])), patch.object( with patch.object(main, "list_recent_emails", new=AsyncMock(return_value=[])), patch.object(
main, "build_memory_context", new=AsyncMock(return_value="") main, "build_memory_context", new=AsyncMock(return_value="")
): ), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
text, output_tokens, total_tokens = await main.run_chat_completion( text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Was ist neu im Postfach?"}], conversation_id=1 [{"role": "user", "content": "Was ist neu im Postfach?"}], conversation_id=1
) )
@ -168,8 +172,27 @@ async def test_run_chat_completion_includes_current_datetime_in_system_prompt():
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object( with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_current_datetime_context", return_value="Aktuelles Datum und Uhrzeit: TESTMARKER" main, "_current_datetime_context", return_value="Aktuelles Datum und Uhrzeit: TESTMARKER"
): ), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
await main.run_chat_completion([{"role": "user", "content": "Hi"}], conversation_id=1) await main.run_chat_completion([{"role": "user", "content": "Hi"}], conversation_id=1)
_, kwargs = main.claude_client.messages.create.call_args _, kwargs = main.claude_client.messages.create.call_args
assert "TESTMARKER" in kwargs["system"] assert "TESTMARKER" in kwargs["system"]
@pytest.mark.asyncio
async def test_run_chat_completion_includes_identity_prompt_in_system_prompt():
completion = MagicMock()
completion.stop_reason = "end_turn"
completion.content = [_text_block("Hallo!")]
completion.usage = _usage(10, 5)
main.claude_client = MagicMock()
main.claude_client.messages.create.return_value = completion
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_identity_system_prompt", new=AsyncMock(return_value="IDENTITYMARKER Du bist JARVIS.")
):
await main.run_chat_completion([{"role": "user", "content": "Hi"}], conversation_id=1)
_, kwargs = main.claude_client.messages.create.call_args
assert "IDENTITYMARKER" in kwargs["system"]

View File

@ -0,0 +1,20 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from main import chunk_text
def test_chunk_text_empty_returns_empty_list():
assert chunk_text("") == []
def test_chunk_text_shorter_than_chunk_size_returns_single_chunk():
assert chunk_text("hello", chunk_size=1000, overlap=100) == ["hello"]
def test_chunk_text_exact_multiple_of_chunk_size():
text = "a" * 20
chunks = chunk_text(text, chunk_size=10, overlap=2)
assert chunks == [text[0:10], text[8:18], text[16:20]]

View File

@ -0,0 +1,68 @@
import base64
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import main
def _encoded_subject(text: str) -> bytes:
return b"=?utf-8?B?" + base64.b64encode(text.encode("utf-8")) + b"?="
def _header_bytes(subject_bytes: bytes) -> bytes:
return (
b"From: Sender <sender@example.com>\r\n"
b"Subject: " + subject_bytes + b"\r\n"
b"Date: Fri, 11 Sep 2026 15:47:02 +0000\r\n\r\n"
)
def test_list_recent_emails_decodes_subject_and_marks_unread():
fake_conn = MagicMock()
fake_conn.search.return_value = ("OK", [b"1"])
fake_conn.fetch.return_value = (
"OK",
[(b"1 (FLAGS () BODY[HEADER.FIELDS (FROM SUBJECT DATE)] {123}", _header_bytes(_encoded_subject("Ümlaut Betreff")))],
)
with patch.object(main.imaplib, "IMAP4", return_value=fake_conn):
emails = main._list_recent_emails_sync(limit=1)
assert len(emails) == 1
assert emails[0]["subject"] == "Ümlaut Betreff"
assert emails[0]["from"] == "Sender <sender@example.com>"
assert emails[0]["unread"] is True
fake_conn.login.assert_called_once_with(main.EMAIL_USER, main.EMAIL_PASSWORD)
def test_list_recent_emails_marks_seen_messages_as_read():
fake_conn = MagicMock()
fake_conn.search.return_value = ("OK", [b"1"])
fake_conn.fetch.return_value = (
"OK",
[(b"1 (FLAGS (\\Seen) BODY[HEADER.FIELDS (FROM SUBJECT DATE)] {123}", _header_bytes(b"Plain Subject"))],
)
with patch.object(main.imaplib, "IMAP4", return_value=fake_conn):
emails = main._list_recent_emails_sync(limit=1)
assert emails[0]["unread"] is False
assert emails[0]["subject"] == "Plain Subject"
def test_send_email_calls_smtp_with_composed_message():
fake_smtp = MagicMock()
with patch.object(main.smtplib, "SMTP_SSL", return_value=fake_smtp):
result = main._send_email_sync("empfaenger@example.com", "Betreff", "Text")
fake_smtp.login.assert_called_once_with(main.EMAIL_USER, main.EMAIL_PASSWORD)
fake_smtp.send_message.assert_called_once()
sent_msg = fake_smtp.send_message.call_args[0][0]
assert sent_msg["To"] == "empfaenger@example.com"
assert sent_msg["Subject"] == "Betreff"
assert sent_msg["Date"] is not None
assert result == {"to": "empfaenger@example.com", "subject": "Betreff", "sent": True}

View File

@ -206,7 +206,7 @@ async def test_run_chat_completion_includes_memory_context_in_system_prompt():
with patch.object( with patch.object(
main, "build_memory_context", new=AsyncMock(return_value="Bekannte Fakten ueber den Nutzer:\n- Hund heisst Bruno") main, "build_memory_context", new=AsyncMock(return_value="Bekannte Fakten ueber den Nutzer:\n- Hund heisst Bruno")
): ), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
await main.run_chat_completion([{"role": "user", "content": "Wie geht es meinem Hund?"}], conversation_id=1) await main.run_chat_completion([{"role": "user", "content": "Wie geht es meinem Hund?"}], conversation_id=1)
_, kwargs = main.claude_client.messages.create.call_args _, kwargs = main.claude_client.messages.create.call_args

View File

@ -328,7 +328,9 @@ async def test_run_chat_completion_includes_order_tools_and_instructions():
main.claude_client = MagicMock() main.claude_client = MagicMock()
main.claude_client.messages.create.return_value = completion main.claude_client.messages.create.return_value = completion
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")): with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")
):
await main.run_chat_completion( await main.run_chat_completion(
[{"role": "user", "content": "Neue Bestellung fuer Feuerwehr Onza"}], conversation_id=1 [{"role": "user", "content": "Neue Bestellung fuer Feuerwehr Onza"}], conversation_id=1
) )

View File

@ -0,0 +1,138 @@
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.modules["psycopg2"] = MagicMock()
sys.modules["psycopg2.pool"] = MagicMock()
sys.modules["psycopg2.extras"] = MagicMock()
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import main
@pytest.mark.asyncio
async def test_get_setting_returns_stored_value():
with patch.object(main, "db_query", new=AsyncMock(return_value={"value": "Custom GmbH"})) as mock_query:
value = await main.get_setting("company_name")
assert value == "Custom GmbH"
args, kwargs = mock_query.call_args
assert "SELECT value FROM settings" in args[0]
assert args[1] == ("company_name",)
@pytest.mark.asyncio
async def test_get_setting_falls_back_to_default_when_missing():
with patch.object(main, "db_query", new=AsyncMock(return_value=None)):
value = await main.get_setting("assistant_name")
assert value == "JARVIS"
@pytest.mark.asyncio
async def test_get_all_settings_merges_defaults_with_stored_overrides():
rows = [{"key": "assistant_name", "value": "FRIDAY"}]
with patch.object(main, "db_query", new=AsyncMock(return_value=rows)):
settings = await main.get_all_settings()
assert settings == {
"assistant_name": "FRIDAY",
"company_name": "MBO-Tech-IT",
"contact_email": "kontakt@mbo-tech-it.de",
}
@pytest.mark.asyncio
async def test_get_all_settings_returns_defaults_when_table_empty():
with patch.object(main, "db_query", new=AsyncMock(return_value=[])):
settings = await main.get_all_settings()
assert settings == main.SETTINGS_DEFAULTS
@pytest.mark.asyncio
async def test_set_setting_upserts_value():
with patch.object(main, "db_query", new=AsyncMock(return_value=None)) as mock_query:
await main.set_setting("company_name", "Neue Firma GmbH")
args, kwargs = mock_query.call_args
assert "INSERT INTO settings" in args[0]
assert "ON CONFLICT (key) DO UPDATE" in args[0]
assert args[1] == ("company_name", "Neue Firma GmbH")
from fastapi.testclient import TestClient
main.API_KEY_ADMIN = "test-secret"
HEADERS = {"X-Admin-Key": "test-secret"}
@pytest.fixture()
def client():
with TestClient(main.app) as c:
yield c
def test_get_settings_endpoint_returns_merged_settings(client):
settings = {"assistant_name": "JARVIS", "company_name": "MBO-Tech-IT", "contact_email": "kontakt@mbo-tech-it.de"}
with patch.object(main, "get_all_settings", new=AsyncMock(return_value=settings)):
response = client.get("/api/v1/settings", headers=HEADERS)
assert response.status_code == 200
assert response.json() == settings
def test_get_settings_endpoint_requires_admin_key(client):
response = client.get("/api/v1/settings")
assert response.status_code == 401
def test_put_settings_endpoint_updates_and_returns_merged_settings(client):
updated = {"assistant_name": "FRIDAY", "company_name": "MBO-Tech-IT", "contact_email": "kontakt@mbo-tech-it.de"}
with patch.object(main, "set_setting", new=AsyncMock()) as mock_set, patch.object(
main, "get_all_settings", new=AsyncMock(return_value=updated)
):
response = client.put("/api/v1/settings", headers=HEADERS, json={"assistant_name": "FRIDAY"})
assert response.status_code == 200
assert response.json() == updated
mock_set.assert_called_once_with("assistant_name", "FRIDAY")
def test_put_settings_endpoint_rejects_unknown_key(client):
with patch.object(main, "set_setting", new=AsyncMock()) as mock_set:
response = client.put("/api/v1/settings", headers=HEADERS, json={"nonsense_key": "x"})
assert response.status_code == 400
mock_set.assert_not_called()
def test_put_settings_endpoint_requires_admin_key(client):
response = client.put("/api/v1/settings", json={"assistant_name": "X"})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_identity_system_prompt_uses_settings():
settings = {"assistant_name": "FRIDAY", "company_name": "Stark Industries", "contact_email": "info@stark.example"}
with patch.object(main, "get_all_settings", new=AsyncMock(return_value=settings)):
prompt = await main._identity_system_prompt()
assert "FRIDAY" in prompt
assert "Stark Industries" in prompt
assert "info@stark.example" in prompt
@pytest.mark.asyncio
async def test_identity_system_prompt_falls_back_to_defaults_on_db_error():
with patch.object(
main, "get_all_settings", new=AsyncMock(side_effect=RuntimeError("relation \"settings\" does not exist"))
):
prompt = await main._identity_system_prompt()
assert main.SETTINGS_DEFAULTS["assistant_name"] in prompt
assert main.SETTINGS_DEFAULTS["company_name"] in prompt
assert main.SETTINGS_DEFAULTS["contact_email"] in prompt

View File

@ -0,0 +1,48 @@
import os
import sys
from datetime import datetime
from unittest.mock import AsyncMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from fastapi.testclient import TestClient
import main
main.API_KEY_ADMIN = "test-secret"
HEADERS = {"X-Admin-Key": "test-secret"}
@pytest.fixture()
def client():
with TestClient(main.app) as c:
yield c
def test_weather_returns_503_when_no_data(client):
with patch.object(main, "pg_pool", "not-none"), patch.object(
main, "get_latest_weather", new=AsyncMock(return_value=None)
):
response = client.get("/api/v1/weather", headers=HEADERS)
assert response.status_code == 503
def test_weather_returns_latest_row(client):
row = {
"location": "Crailsheim",
"temperature_c": 18.4,
"condition_text": "Bewoelkt",
"fetched_at": datetime(2026, 9, 12, 16, 0, 3, 123456),
}
with patch.object(main, "pg_pool", "not-none"), patch.object(
main, "get_latest_weather", new=AsyncMock(return_value=row)
):
response = client.get("/api/v1/weather", headers=HEADERS)
assert response.status_code == 200
assert response.json()["location"] == "Crailsheim"
def test_weather_requires_admin_key(client):
response = client.get("/api/v1/weather")
assert response.status_code == 401

View File

@ -0,0 +1,434 @@
# JARVIS Knowledge Base (Phase 3) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the TODO-stub `POST/GET /api/v1/documents` endpoints with a real document-upload + semantic-search knowledge base, backed by pgvector in the existing Postgres instance and Ollama for embeddings.
**Architecture:** Documents are chunked (character-based, with overlap) on upload, each chunk is embedded via Ollama's `nomic-embed-text` model, and stored in a new `document_chunks` table (pgvector column). Search embeds the query the same way and ranks chunks by cosine distance (`<=>`).
**Tech Stack:** FastAPI, psycopg2 (existing `db_query` helper), pgvector Postgres extension, aiohttp (existing Ollama HTTP pattern), pytest (new, local-only dev dependency).
**Spec:** `docs/superpowers/specs/2026-09-12-knowledge-base-design.md`
## Global Constraints
- No dedicated git repository exists for this project yet (the working directory sits inside an unrelated, very large home-directory-level repo that must not be touched). **Every "Commit" step below is replaced by "confirm the file is saved" — do not run `git add`/`git commit`.**
- Runtime is the live VPS at `72.61.186.98`, container `jarvis-api` (image `python:3.11-slim`, code bind-mounted from `/home/jarvis-core/jarvis/api`, `uvicorn --reload`). SSH: `ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98`. Local files live in `Claude outputs/` (note the space — quote the path in shell commands) and must be scp'd to `/home/jarvis-core/jarvis/...` to take effect.
- Reuse the existing `db_query(query, params, fetch)` async helper in `main.py` for all DB access — do not introduce an ORM.
- Follow the existing code style in `main.py`: section comments (`# ============ X ============`), try/except around endpoint bodies that logs and raises `HTTPException`, async endpoints.
- Embedding model: `nomic-embed-text` (768 dimensions) — this fixes the `vector(768)` column width used below.
- Chunking: ~1000 characters, 100 character overlap (from the spec) — do not change these defaults without updating the spec.
---
### Task 1: pgvector extension + `document_chunks` table
**Files:**
- Modify: `Claude outputs/docker-compose.yml` (postgres image)
- Create: `Claude outputs/migrations/002_document_chunks.sql`
**Interfaces:**
- Produces: Postgres table `document_chunks(id, document_id, chunk_index, content, embedding vector(768), created_at)`, used by Task 4 and Task 5's DB helpers.
**Context:** the plain `postgres:16-alpine` image does not ship the pgvector extension. `pgvector/pgvector:pg16` is a drop-in image (built from postgres:16) with the extension precompiled — swapping the image and recreating the container is safe, the existing `postgres_data` volume is untouched.
- [ ] **Step 1: Swap the Postgres image to the pgvector-enabled build**
Edit `Claude outputs/docker-compose.yml`:
```yaml
postgres:
image: pgvector/pgvector:pg16
```
(only the `image:` line under the `postgres:` service changes — everything else stays as-is).
- [ ] **Step 2: Deploy the compose change and recreate the postgres container**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
scp $SSHOPTS "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs\docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d postgres && sleep 5 && docker compose ps postgres"
```
Expected: `jarvis-postgres` shows `Up ... (healthy)` with the new image.
- [ ] **Step 3: Write the migration file**
Create `Claude outputs/migrations/002_document_chunks.sql`:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding vector(768) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_document_chunks_document_id ON document_chunks(document_id);
CREATE INDEX idx_document_chunks_embedding ON document_chunks
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
```
- [ ] **Step 4: Apply the migration to the live database**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
scp $SSHOPTS "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs\migrations\002_document_chunks.sql" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/migrations/002_document_chunks.sql
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker exec -i jarvis-postgres psql -U jarvis -d jarvis < /home/jarvis-core/jarvis/migrations/002_document_chunks.sql"
```
Expected output ends with `CREATE INDEX` (no errors). If `CREATE EXTENSION` fails with "could not open extension control file", Step 1/2 didn't take effect — re-check the image was actually swapped (`docker inspect jarvis-postgres | grep Image`).
- [ ] **Step 5: Verify**
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker exec jarvis-postgres psql -U jarvis -d jarvis -c '\d document_chunks'"
```
Expected: column list showing `embedding | vector(768)` among others.
- [ ] **Step 6: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 2: Chunking function (TDD)
**Files:**
- Modify: `Claude outputs/main.py` (add `chunk_text`)
- Test: `Claude outputs/tests/test_chunking.py`
- Create: `Claude outputs/requirements-dev.txt`
**Interfaces:**
- Produces: `chunk_text(text: str, chunk_size: int = 1000, overlap: int = 100) -> list[str]` in `main.py`, consumed by Task 4's upload endpoint.
- [ ] **Step 1: Add pytest as a local-only dev dependency**
Create `Claude outputs/requirements-dev.txt`:
```
pytest==8.3.3
```
- [ ] **Step 2: Install it locally**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
python -m pip install -r requirements-dev.txt -r requirements.txt
```
- [ ] **Step 3: Write the failing tests**
Create `Claude outputs/tests/test_chunking.py`:
```python
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from main import chunk_text
def test_chunk_text_empty_returns_empty_list():
assert chunk_text("") == []
def test_chunk_text_shorter_than_chunk_size_returns_single_chunk():
assert chunk_text("hello", chunk_size=1000, overlap=100) == ["hello"]
def test_chunk_text_exact_multiple_of_chunk_size():
text = "a" * 20
chunks = chunk_text(text, chunk_size=10, overlap=2)
assert chunks == [text[0:10], text[8:18], text[16:20]]
```
- [ ] **Step 4: Run tests to verify they fail**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
python -m pytest tests/test_chunking.py -v
```
Expected: FAIL with `ImportError: cannot import name 'chunk_text'` (or similar — `chunk_text` doesn't exist yet). Note: importing `main.py` will also try to import `psycopg2`, `redis`, `anthropic`, `aiohttp` — these must already be installed locally via Step 2 for the import to get far enough to fail on `chunk_text` specifically, not on a missing package.
- [ ] **Step 5: Implement `chunk_text`**
In `Claude outputs/main.py`, add near the other pure helpers (e.g. above `# ============ DB HELPERS ============`):
```python
def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 100) -> list:
if not text:
return []
if len(text) <= chunk_size:
return [text]
chunks = []
step = chunk_size - overlap
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
if end >= len(text):
break
start += step
return chunks
```
- [ ] **Step 6: Run tests to verify they pass**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
python -m pytest tests/test_chunking.py -v
```
Expected: 3 passed.
- [ ] **Step 7: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 3: Ollama embedding helper
**Files:**
- Modify: `Claude outputs/main.py` (add `OLLAMA_EMBED_MODEL` config + `get_embedding`)
- Modify: `Claude outputs/docker-compose.yml` (jarvis-api environment)
**Interfaces:**
- Consumes: `OLLAMA_HOST` (already defined in `main.py`), `aiohttp` (already imported).
- Produces: `async def get_embedding(text: str) -> list` in `main.py`, consumed by Task 4 and Task 5.
- [ ] **Step 1: Add the embed-model config constant**
In `Claude outputs/main.py`, next to the other `# ============ CONFIG ============` constants:
```python
OLLAMA_EMBED_MODEL = os.getenv("OLLAMA_EMBED_MODEL", "nomic-embed-text")
```
- [ ] **Step 2: Add `get_embedding`**
In `Claude outputs/main.py`, add after `chunk_text`:
```python
async def get_embedding(text: str) -> list:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
f"{OLLAMA_HOST}/api/embeddings",
json={"model": OLLAMA_EMBED_MODEL, "prompt": text},
) as resp:
if resp.status != 200:
body = await resp.text()
raise RuntimeError(f"Ollama embedding failed ({resp.status}): {body}")
data = await resp.json()
return data["embedding"]
```
- [ ] **Step 3: Add `OLLAMA_EMBED_MODEL` to the deployed environment**
In `Claude outputs/docker-compose.yml`, under `jarvis-api: environment:`, add a line next to `OLLAMA_HOST`:
```yaml
- OLLAMA_EMBED_MODEL=nomic-embed-text
```
- [ ] **Step 4: Confirm files saved** (no automated test here — `get_embedding` is verified end-to-end in Task 4; see Global Constraints for why there's no commit step)
---
### Task 4: Wire up document upload
**Files:**
- Modify: `Claude outputs/main.py` (`insert_document`, `insert_document_chunks`, `upload_document`)
**Interfaces:**
- Consumes: `chunk_text` (Task 2), `get_embedding` (Task 3), `db_query` (existing), `DEFAULT_USER_ID` (existing global).
- Produces: DB helpers `insert_document(title, content, document_type) -> int` and `insert_document_chunks(document_id: int, chunks: list) -> None`, where `chunks` is `list[tuple[str, list]]` of `(chunk_text, embedding)` pairs.
- [ ] **Step 1: Add a vector-literal formatting helper and the DB helpers**
In `Claude outputs/main.py`, add near the other DB helpers (after `save_message`):
```python
def _vector_literal(embedding: list) -> str:
return "[" + ",".join(repr(float(x)) for x in embedding) + "]"
async def insert_document(title: str, content: str, document_type: str) -> int:
row = await db_query(
"INSERT INTO documents (user_id, title, content, document_type) VALUES (%s, %s, %s, %s) RETURNING id",
(DEFAULT_USER_ID, title, content, document_type),
fetch="one",
)
return row["id"]
async def insert_document_chunks(document_id: int, chunks: list):
for index, (content, embedding) in enumerate(chunks):
await db_query(
"""
INSERT INTO document_chunks (document_id, chunk_index, content, embedding)
VALUES (%s, %s, %s, %s::vector)
""",
(document_id, index, content, _vector_literal(embedding)),
)
```
- [ ] **Step 2: Replace the `upload_document` endpoint**
In `Claude outputs/main.py`, replace the existing stub:
```python
@app.post("/api/v1/documents")
async def upload_document(title: str, content: str, document_type: str = "general"):
"""Upload document to knowledge base"""
if not pg_pool:
raise HTTPException(status_code=503, detail="Database is not configured")
if not content.strip():
raise HTTPException(status_code=422, detail="content must not be empty")
try:
logger.info(f"Uploading document: {title}")
document_id = await insert_document(title, content, document_type)
chunks = chunk_text(content)
embedded_chunks = [(c, await get_embedding(c)) for c in chunks]
await insert_document_chunks(document_id, embedded_chunks)
return {
"document_id": document_id,
"title": title,
"status": "indexed",
"chunk_count": len(chunks),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Document upload error: {str(e)}")
raise HTTPException(status_code=503, detail=f"Embedding failed: {str(e)}")
```
- [ ] **Step 3: Deploy**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 8 && docker logs --tail 20 jarvis-api"
```
Expected: log ends with `Application startup complete.` (no traceback).
- [ ] **Step 4: Pull the embedding model**
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker exec jarvis-ollama ollama pull nomic-embed-text"
```
Expected: ends with `success`.
- [ ] **Step 5: Test the upload endpoint against the live API**
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST 'http://localhost:8000/api/v1/documents?title=Oeffnungszeiten&content=Wir%20haben%20Montag%20bis%20Freitag%20von%209%20bis%2017%20Uhr%20geoeffnet.&document_type=faq'; echo"
```
Expected: JSON with `"status":"indexed"` and `"chunk_count":1`.
- [ ] **Step 6: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 5: Wire up document search + end-to-end verification
**Files:**
- Modify: `Claude outputs/main.py` (`search_chunks`, `search_documents`)
**Interfaces:**
- Consumes: `get_embedding` (Task 3), `db_query` (existing).
- Produces: DB helper `search_chunks(query_embedding: list, limit: int) -> list[dict]`.
- [ ] **Step 1: Add the search DB helper**
In `Claude outputs/main.py`, add next to `insert_document_chunks`:
```python
async def search_chunks(query_embedding: list, limit: int):
return await db_query(
"""
SELECT dc.content, dc.chunk_index, d.id AS document_id, d.title,
dc.embedding <=> %s::vector AS distance
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
ORDER BY distance ASC
LIMIT %s
""",
(_vector_literal(query_embedding), limit),
fetch="all",
)
```
- [ ] **Step 2: Replace the `search_documents` endpoint**
In `Claude outputs/main.py`, replace the existing stub:
```python
@app.get("/api/v1/documents")
async def search_documents(query: str, limit: int = 10):
"""Search knowledge base using vector similarity"""
if not pg_pool:
raise HTTPException(status_code=503, detail="Database is not configured")
if not query.strip():
raise HTTPException(status_code=422, detail="query must not be empty")
try:
query_embedding = await get_embedding(query)
rows = await search_chunks(query_embedding, limit)
return {
"query": query,
"results": [
{
"document_id": r["document_id"],
"title": r["title"],
"content": r["content"],
"chunk_index": r["chunk_index"],
"distance": float(r["distance"]),
}
for r in rows
],
"count": len(rows),
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Document search error: {str(e)}")
raise HTTPException(status_code=503, detail=f"Search failed: {str(e)}")
```
- [ ] **Step 3: Deploy**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api && sleep 8 && docker logs --tail 20 jarvis-api"
```
Expected: log ends with `Application startup complete.` (no traceback).
- [ ] **Step 4: End-to-end test (matches spec's manual verification requirement)**
Using the document uploaded in Task 4 Step 5 ("Wir haben Montag bis Freitag von 9 bis 17 Uhr geoeffnet."), search with a thematically related query:
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s 'http://localhost:8000/api/v1/documents?query=Wann%20habt%20ihr%20auf%3F'; echo"
```
Expected: `results[0].title == "Oeffnungszeiten"` and `results[0].content` contains the opening-hours text, with the lowest `distance` of any returned result.
- [ ] **Step 5: Confirm files saved** (no git repo for this project — see Global Constraints)

View File

@ -0,0 +1,293 @@
# JARVIS Weather Widget Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Expose the weather data the new n8n workflow writes to Postgres via a protected API endpoint, and show it top-right in the frontend nav bar.
**Architecture:** `GET /api/v1/weather` reads the newest row from `weather_cache` (already populated by the "Wetter Crailsheim" n8n workflow, verified working). A new `WeatherWidget` React component polls that endpoint and renders in the nav bar.
**Tech Stack:** FastAPI (existing `db_query`/`require_admin_key` patterns), React (existing `apiFetch` pattern).
**Spec:** `docs/superpowers/specs/2026-09-12-weather-widget-design.md`
## Global Constraints
- No dedicated git repository for this project (see prior plans) — every "Commit" step is replaced by "confirm the file is saved".
- SSH: `ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98`. Local backend files: `Claude outputs/` (quote the path). Local frontend files: `web/`.
- The n8n workflow "Wetter Crailsheim" (id `BwNCJ2TkuZfqUzst`), its Postgres credential "JARVIS Postgres", and the `weather_cache` table already exist and are verified working (one row present from a manual test run) — this plan does not touch them.
- `weather_cache` schema: `id, location, temperature_c NUMERIC, condition_code INTEGER, condition_text VARCHAR(100), fetched_at TIMESTAMP`.
- The endpoint must use `dependencies=[Depends(require_admin_key)]` like every other `/api/v1/*` route (see `Claude outputs/main.py`).
---
### Task 1: `GET /api/v1/weather` endpoint
**Files:**
- Modify: `Claude outputs/main.py` (add `get_latest_weather` DB helper + `GET /api/v1/weather` endpoint)
- Test: `Claude outputs/tests/test_weather.py`
**Interfaces:**
- Produces: `GET /api/v1/weather` returning `{"location": str, "temperature_c": float, "condition_text": str, "fetched_at": str}` on success, `503` when `weather_cache` is empty. Consumed by Task 2's `WeatherWidget`.
- [ ] **Step 1: Write the failing test**
Create `Claude outputs/tests/test_weather.py`:
```python
import os
import sys
from datetime import datetime
from unittest.mock import AsyncMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from fastapi.testclient import TestClient
import main
main.API_KEY_ADMIN = "test-secret"
HEADERS = {"X-Admin-Key": "test-secret"}
@pytest.fixture()
def client():
with TestClient(main.app) as c:
yield c
def test_weather_returns_503_when_no_data(client):
with patch.object(main, "pg_pool", "not-none"), patch.object(
main, "get_latest_weather", new=AsyncMock(return_value=None)
):
response = client.get("/api/v1/weather", headers=HEADERS)
assert response.status_code == 503
def test_weather_returns_latest_row(client):
row = {
"location": "Crailsheim",
"temperature_c": 18.4,
"condition_text": "Bewoelkt",
"fetched_at": datetime(2026, 9, 12, 16, 0, 3, 123456),
}
with patch.object(main, "pg_pool", "not-none"), patch.object(
main, "get_latest_weather", new=AsyncMock(return_value=row)
):
response = client.get("/api/v1/weather", headers=HEADERS)
assert response.status_code == 200
assert response.json()["location"] == "Crailsheim"
def test_weather_requires_admin_key(client):
response = client.get("/api/v1/weather")
assert response.status_code == 401
```
Note: `patch.object(main, "pg_pool", "not-none")` stands in for "database configured" — the endpoint only checks `if not pg_pool`, so any truthy value works without needing a real connection pool.
- [ ] **Step 2: Run test to verify it fails**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_weather.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_weather.py -v'"
```
Expected: FAIL — `get_latest_weather` doesn't exist / `/api/v1/weather` is 404.
- [ ] **Step 3: Add the DB helper and endpoint**
In `Claude outputs/main.py`, add near the other DB helpers (after `search_chunks`):
```python
async def get_latest_weather():
return await db_query(
"""
SELECT location, temperature_c, condition_text, fetched_at
FROM weather_cache ORDER BY fetched_at DESC LIMIT 1
""",
fetch="one",
)
```
Add the endpoint near the other admin/status endpoints (after `detailed_health`):
```python
@app.get("/api/v1/weather", dependencies=[Depends(require_admin_key)])
async def get_weather():
"""Latest cached weather reading, populated by the n8n 'Wetter Crailsheim' workflow"""
if not pg_pool:
raise HTTPException(status_code=503, detail="Database is not configured")
row = await get_latest_weather()
if not row:
raise HTTPException(status_code=503, detail="Weather data not available yet")
return {
"location": row["location"],
"temperature_c": float(row["temperature_c"]),
"condition_text": row["condition_text"],
"fetched_at": row["fetched_at"].isoformat(),
}
```
- [ ] **Step 4: Run test to verify it passes**
Re-run the Step 2 command. Expected: 3 passed.
- [ ] **Step 5: Deploy and verify against the live API**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api && sleep 8 && docker logs --tail 15 jarvis-api"
```
Expected: log ends with `Application startup complete.` (no traceback). Then:
```bash
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -H 'X-Admin-Key: $ADMIN_KEY' http://localhost:8000/api/v1/weather; echo"
```
Expected: JSON with `"location":"Crailsheim"` and the temperature/condition from the test run done during n8n workflow setup.
- [ ] **Step 6: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 2: `WeatherWidget` in the nav bar
**Files:**
- Create: `web/src/components/WeatherWidget.tsx`
- Modify: `web/src/App.tsx` (render the widget in the nav)
- Modify: `web/src/index.css` (right-align the widget in the nav)
**Interfaces:**
- Consumes: `apiFetch` from `./api` (existing), `GET /api/v1/weather` (Task 1).
- Produces: `WeatherWidget` default export, no props, rendered inside the existing `<nav className="nav">` in `App.tsx`.
- [ ] **Step 1: Add the nav CSS for right-alignment**
In `web/src/index.css`, add after the `.nav button` rule:
```css
.weather-widget { margin-left: auto; font-size: 0.9rem; color: #94a3b8; align-self: center; }
```
- [ ] **Step 2: Create the component**
Create `web/src/components/WeatherWidget.tsx`:
```tsx
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type Weather = { location: string; temperature_c: number; condition_text: string };
const REFRESH_MS = 5 * 60 * 1000;
export default function WeatherWidget() {
const [weather, setWeather] = useState<Weather | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const response = await apiFetch("/api/v1/weather");
if (!response.ok) {
if (!cancelled) setFailed(true);
return;
}
const data = await response.json();
if (!cancelled) {
setWeather(data);
setFailed(false);
}
} catch {
if (!cancelled) setFailed(true);
}
}
load();
const interval = setInterval(load, REFRESH_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
if (failed) {
return <span className="weather-widget">Wetter nicht verfuegbar</span>;
}
if (!weather) {
return null;
}
return (
<span className="weather-widget">
{`${weather.location}: ${weather.temperature_c.toFixed(1)}\u00b0C \u00b7 ${weather.condition_text}`}
</span>
);
}
```
- [ ] **Step 3: Render it in the nav bar**
In `web/src/App.tsx`, add the import next to the other component imports:
```tsx
import WeatherWidget from "./components/WeatherWidget";
```
Change the `<nav>` block from:
```tsx
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => logout()}>Logout</button>
</nav>
```
to:
```tsx
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => logout()}>Logout</button>
<WeatherWidget />
</nav>
```
- [ ] **Step 4: Verify the build works**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build
```
Expected: exits 0.
- [ ] **Step 5: Deploy**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS"
scp $SSHOPTS -r "$OUT\web\src" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web"
```
- [ ] **Step 6: End-to-end browser verification**
Using the claude-in-chrome browser tools: open `https://jarvis.mbo-tech-it.de`, log in with the real `API_KEY_ADMIN`, confirm the weather text (e.g. "Crailsheim: 20.9°C · Bewoelkt") appears top-right of the nav bar on both the Chat and Dashboard views.
- [ ] **Step 7: Confirm files saved** (no git repo for this project — see Global Constraints)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,673 @@
# JARVIS Nextcloud Calendar Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give JARVIS read/write access to the Nextcloud "FFW-Onza-Alle" CalDAV calendar: a dashboard widget listing upcoming events, and chat-driven querying + confirmed event creation via Claude tool use.
**Architecture:** A `caldav`-backed helper pair (`list_upcoming_events`, `create_event`) wrapped in `asyncio.to_thread` (same pattern as the existing Postgres `db_query` helper). A new protected `GET /api/v1/calendar/events` endpoint serves the dashboard widget. `/api/v1/chat` gains two Claude tools that call the same helpers, with a system-prompt rule requiring explicit user confirmation before any write.
**Tech Stack:** `caldav` + `icalendar` (Python, new dependencies), FastAPI (existing `require_admin_key`/`db_query` patterns), Anthropic Messages API tool use, React (existing `apiFetch` pattern).
**Spec:** `docs/superpowers/specs/2026-09-13-calendar-integration-design.md`
## Global Constraints
- No dedicated git repository for this project — every "Commit" step is replaced by "confirm the file is saved".
- SSH: `ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98`. Backend files: `Claude outputs/`. Frontend files: `web/`.
- CalDAV connectivity is already verified working (13.09.2026): read found 11 real events, a test event was created and deleted successfully using the exact credentials below.
- Credentials (already provided, put in `.env` — never hardcode in source): `NEXTCLOUD_CALDAV_URL=https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/`, `NEXTCLOUD_USER=jonny`, `NEXTCLOUD_APP_PASSWORD=j9Ywc-9Pnbk-8rQQw-Qnkb2-ZoqQJ`.
- The DAV client root is derived from the calendar URL by splitting on `/calendars/`: `dav_root = NEXTCLOUD_CALDAV_URL.split("/calendars/")[0] + "/"` — this produces `https://cloud.ffw-onza.de/remote.php/dav/`, verified against the real server.
- An all-day event's `dtstart` is a plain `date`, not `datetime``isinstance(dtstart, datetime)` is `False` for those (datetime is a subclass of date, so this check is reliable both ways).
- `create_calendar_event` must never be called by Claude without the user first confirming in the conversation — enforced via system-prompt instruction (see spec's "Sicherheit" section), not a technical gate.
- Tool-use intermediate turns (the `tool_use`/`tool_result` exchange) are never persisted to the `messages` table — only the final visible user/assistant pair, matching existing persistence behavior.
---
### Task 1: CalDAV helpers + config + unit tests
**Files:**
- Modify: `Claude outputs/main.py` (imports, config constants, `_caldav_calendar`, `list_upcoming_events`, `create_event`)
- Modify: `Claude outputs/requirements.txt` (add `caldav`, `icalendar`)
- Modify: `Claude outputs/docker-compose.yml` (jarvis-api environment)
- Test: `Claude outputs/tests/test_calendar.py`
**Interfaces:**
- Produces: `async def list_upcoming_events(days_ahead: int) -> list` returning `[{"summary": str, "start": iso_str, "end": iso_str, "description": str, "all_day": bool}, ...]` sorted by start; `async def create_event(summary: str, start: str, end: str, description: str = "") -> dict` returning `{"summary", "start", "end", "description"}`. Consumed by Task 2 (endpoint) and Task 3 (chat tools).
- [ ] **Step 1: Add dependencies**
In `Claude outputs/requirements.txt`, add two lines:
```
caldav
icalendar
```
(Unpinned, like `anthropic` — these are new enough that a pinned version chosen without checking PyPI risks picking one that doesn't exist; pip resolves the current compatible release at container build time.)
- [ ] **Step 2: Write the failing tests**
Create `Claude outputs/tests/test_calendar.py`:
```python
import os
import sys
from datetime import datetime, date
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import main
def _fake_component(summary, dtstart_value, dtend_value, description=""):
comp = {}
comp["summary"] = summary
comp["description"] = description
class _Val:
def __init__(self, v):
self.dt = v
comp["dtstart"] = _Val(dtstart_value)
comp["dtend"] = _Val(dtend_value)
def get(key, default=None):
return comp.get(key, default)
fake = MagicMock()
fake.get = get
return fake
def test_list_upcoming_events_maps_timed_and_all_day_events():
timed = MagicMock()
timed.icalendar_component = _fake_component(
"THL FM LS", datetime(2026, 9, 14, 19, 0, 0), datetime(2026, 9, 14, 20, 0, 0)
)
all_day = MagicMock()
all_day.icalendar_component = _fake_component(
"Volksfestwache", date(2026, 9, 19), date(2026, 9, 20)
)
fake_calendar = MagicMock()
fake_calendar.search.return_value = [all_day, timed] # unsorted on purpose
with patch.object(main, "_caldav_calendar", return_value=fake_calendar):
events = main._list_upcoming_events_sync(14)
assert [e["summary"] for e in events] == ["THL FM LS", "Volksfestwache"]
assert events[0]["all_day"] is False
assert events[1]["all_day"] is True
def test_create_event_calls_save_event_with_parsed_dates():
fake_calendar = MagicMock()
with patch.object(main, "_caldav_calendar", return_value=fake_calendar):
result = main._create_event_sync(
"JARVIS Testtermin", "2026-09-20T10:00:00", "2026-09-20T11:00:00", "desc"
)
fake_calendar.save_event.assert_called_once()
_, kwargs = fake_calendar.save_event.call_args
assert kwargs["summary"] == "JARVIS Testtermin"
assert kwargs["dtstart"] == datetime(2026, 9, 20, 10, 0, 0)
assert result["summary"] == "JARVIS Testtermin"
```
- [ ] **Step 3: Run tests to verify they fail**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_calendar.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_calendar.py -v'"
```
Expected: FAIL — `_caldav_calendar`/`_list_upcoming_events_sync`/`_create_event_sync` don't exist yet.
- [ ] **Step 4: Add config constants**
In `Claude outputs/main.py`, change the datetime import line:
```python
from datetime import datetime
```
to:
```python
from datetime import datetime, timedelta
```
Add near the top, after the other `import` lines (after `import aiohttp`):
```python
import caldav
```
Add near the other `# ============ CONFIG ============` constants:
```python
NEXTCLOUD_CALDAV_URL = os.getenv("NEXTCLOUD_CALDAV_URL")
NEXTCLOUD_USER = os.getenv("NEXTCLOUD_USER")
NEXTCLOUD_APP_PASSWORD = os.getenv("NEXTCLOUD_APP_PASSWORD")
```
- [ ] **Step 5: Add the CalDAV helpers**
In `Claude outputs/main.py`, add after `get_latest_weather` (the last DB helper):
```python
def _caldav_calendar():
dav_root = NEXTCLOUD_CALDAV_URL.split("/calendars/")[0] + "/"
client = caldav.DAVClient(url=dav_root, username=NEXTCLOUD_USER, password=NEXTCLOUD_APP_PASSWORD)
return client.calendar(url=NEXTCLOUD_CALDAV_URL)
def _list_upcoming_events_sync(days_ahead: int) -> list:
calendar = _caldav_calendar()
start = datetime.now()
end = start + timedelta(days=days_ahead)
results = calendar.search(start=start, end=end, event=True, expand=True)
events = []
for result in results:
comp = result.icalendar_component
dtstart = comp.get("dtstart").dt
dtend_prop = comp.get("dtend")
dtend = dtend_prop.dt if dtend_prop else dtstart
events.append(
{
"summary": str(comp.get("summary", "")),
"start": dtstart.isoformat(),
"end": dtend.isoformat(),
"description": str(comp.get("description", "")),
"all_day": not isinstance(dtstart, datetime),
}
)
events.sort(key=lambda e: e["start"])
return events
async def list_upcoming_events(days_ahead: int) -> list:
return await asyncio.to_thread(_list_upcoming_events_sync, days_ahead)
def _create_event_sync(summary: str, start: str, end: str, description: str = "") -> dict:
calendar = _caldav_calendar()
calendar.save_event(
dtstart=datetime.fromisoformat(start),
dtend=datetime.fromisoformat(end),
summary=summary,
description=description,
)
return {"summary": summary, "start": start, "end": end, "description": description}
async def create_event(summary: str, start: str, end: str, description: str = "") -> dict:
return await asyncio.to_thread(_create_event_sync, summary, start, end, description)
```
- [ ] **Step 6: Run tests to verify they pass**
Re-run the Step 3 command. Expected: 2 passed.
- [ ] **Step 7: Wire the CalDAV env vars into the deployed container**
In `Claude outputs/docker-compose.yml`, under `jarvis-api: environment:`, add after `API_KEY_ADMIN`:
```yaml
- NEXTCLOUD_CALDAV_URL=https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/
- NEXTCLOUD_USER=jonny
- NEXTCLOUD_APP_PASSWORD=${NEXTCLOUD_APP_PASSWORD:-}
```
Then add the actual password to the deployed `.env` (not `docker-compose.yml`, which stays committed-safe with the `${...}` reference):
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "grep -q NEXTCLOUD_APP_PASSWORD /home/jarvis-core/jarvis/.env || echo 'NEXTCLOUD_APP_PASSWORD=j9Ywc-9Pnbk-8rQQw-Qnkb2-ZoqQJ' >> /home/jarvis-core/jarvis/.env"
```
- [ ] **Step 8: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 2: `GET /api/v1/calendar/events` endpoint
**Files:**
- Modify: `Claude outputs/main.py` (add the endpoint)
**Interfaces:**
- Consumes: `list_upcoming_events` (Task 1).
- Produces: `GET /api/v1/calendar/events?days=14` returning `{"events": [...]}`, `503` on CalDAV failure. Consumed by Task 4's frontend widget.
- [ ] **Step 1: Add the endpoint**
In `Claude outputs/main.py`, add after the `get_weather` endpoint (end of file, before `if __name__ == "__main__":`):
```python
@app.get("/api/v1/calendar/events", dependencies=[Depends(require_admin_key)])
async def get_calendar_events(days: int = 14):
"""Upcoming events from the FFW-Onza-Alle Nextcloud calendar"""
try:
events = await list_upcoming_events(days)
return {"events": events}
except Exception as e:
logger.error(f"Calendar fetch error: {str(e)}")
raise HTTPException(status_code=503, detail=f"Calendar unavailable: {str(e)}")
```
- [ ] **Step 2: Deploy and verify against the live API**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/
scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
```
Expected: log ends with `Application startup complete.` (no traceback — the container reinstalls `requirements.txt` on every start, so `caldav`/`icalendar` get pulled in automatically).
```bash
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -H 'X-Admin-Key: $ADMIN_KEY' 'http://localhost:8000/api/v1/calendar/events?days=30'; echo"
```
Expected: JSON with an `events` array containing real entries (e.g. "THL FM LS", "Volksfestwache" — the same events found during the connectivity check).
- [ ] **Step 3: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 3: Chat tool use (query + confirmed create)
**Files:**
- Modify: `Claude outputs/main.py` (imports, tool definitions, `execute_tool`, `run_chat_completion`, rewire `chat()`)
- Test: `Claude outputs/tests/test_chat_tools.py`
**Interfaces:**
- Consumes: `list_upcoming_events`, `create_event` (Task 1), `claude_client`, `CLAUDE_MODEL` (existing).
- Produces: `async def run_chat_completion(claude_messages: list) -> tuple[str, int, int]` returning `(response_text, output_tokens, total_tokens)`. Replaces the inline `claude_client.messages.create` call in `chat()`.
- [ ] **Step 1: Write the failing test**
Create `Claude outputs/tests/test_chat_tools.py`:
```python
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import main
def _text_block(text):
block = MagicMock()
block.type = "text"
block.text = text
return block
def _tool_use_block(name, tool_input, tool_id="tool_1"):
block = MagicMock()
block.type = "tool_use"
block.name = name
block.input = tool_input
block.id = tool_id
return block
def _usage(input_tokens, output_tokens):
usage = MagicMock()
usage.input_tokens = input_tokens
usage.output_tokens = output_tokens
return usage
@pytest.mark.asyncio
async def test_run_chat_completion_without_tool_use():
completion = MagicMock()
completion.stop_reason = "end_turn"
completion.content = [_text_block("Hallo!")]
completion.usage = _usage(10, 5)
main.claude_client = MagicMock()
main.claude_client.messages.create.return_value = completion
text, output_tokens, total_tokens = await main.run_chat_completion([{"role": "user", "content": "Hi"}])
assert text == "Hallo!"
assert output_tokens == 5
assert total_tokens == 15
@pytest.mark.asyncio
async def test_run_chat_completion_executes_tool_and_returns_followup():
first = MagicMock()
first.stop_reason = "tool_use"
first.content = [_tool_use_block("list_calendar_events", {"days_ahead": 7})]
first.usage = _usage(20, 8)
second = MagicMock()
second.stop_reason = "end_turn"
second.content = [_text_block("Naechste Woche steht nichts an.")]
second.usage = _usage(30, 12)
main.claude_client = MagicMock()
main.claude_client.messages.create.side_effect = [first, second]
with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[])):
text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Was steht diese Woche an?"}]
)
assert text == "Naechste Woche steht nichts an."
assert output_tokens == 8 + 12
assert total_tokens == 20 + 8 + 30 + 12
assert main.claude_client.messages.create.call_count == 2
```
Note: this test file needs `pytest-asyncio` to run `async def test_...` functions — add it to `Claude outputs/requirements-dev.txt` in this step (append `pytest-asyncio==0.24.0`), and create `Claude outputs/pytest.ini` with:
```ini
[pytest]
asyncio_mode = auto
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/requirements-dev.txt" "$OUT/pytest.ini" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_chat_tools.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_chat_tools.py -v'"
```
Expected: FAIL — `run_chat_completion` doesn't exist yet.
- [ ] **Step 3: Add `import json`**
In `Claude outputs/main.py`, add `import json` next to `import logging`.
- [ ] **Step 4: Add the tool definitions and execution dispatcher**
Add after the CalDAV helpers from Task 1 (after `create_event`):
```python
CALENDAR_TOOLS = [
{
"name": "list_calendar_events",
"description": "List upcoming events from the FFW-Onza-Alle calendar within the next N days.",
"input_schema": {
"type": "object",
"properties": {
"days_ahead": {
"type": "integer",
"description": "How many days ahead to look, e.g. 7 for the next week",
}
},
"required": ["days_ahead"],
},
},
{
"name": "create_calendar_event",
"description": (
"Create a new event in the FFW-Onza-Alle calendar. Only call this "
"after the user has explicitly confirmed the event details "
"(title, date, time) in the conversation."
),
"input_schema": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "Event title"},
"start": {"type": "string", "description": "Start date/time in ISO 8601, e.g. 2026-09-20T10:00:00"},
"end": {"type": "string", "description": "End date/time in ISO 8601, e.g. 2026-09-20T11:00:00"},
"description": {"type": "string", "description": "Optional longer description"},
},
"required": ["summary", "start", "end"],
},
},
]
CALENDAR_ASSISTANT_INSTRUCTIONS = (
"Du hast Zugriff auf den Kalender 'FFW-Onza-Alle' ueber die Tools "
"list_calendar_events und create_calendar_event. Bevor du "
"create_calendar_event aufrufst, frage den Nutzer immer explizit im "
"Klartext nach Bestaetigung der Termindetails (Titel, Datum, Uhrzeit) "
"und rufe das Tool erst auf, nachdem der Nutzer im naechsten "
"Chat-Beitrag zugestimmt hat."
)
async def execute_tool(name: str, tool_input: dict) -> str:
if name == "list_calendar_events":
events = await list_upcoming_events(tool_input.get("days_ahead", 14))
return json.dumps(events)
if name == "create_calendar_event":
result = await create_event(
tool_input["summary"],
tool_input["start"],
tool_input["end"],
tool_input.get("description", ""),
)
return json.dumps(result)
raise ValueError(f"Unknown tool: {name}")
```
- [ ] **Step 5: Add `run_chat_completion`**
Add directly after `execute_tool`:
```python
async def run_chat_completion(claude_messages: list):
system_prompt = f"{CLAUDE_SYSTEM_PROMPT}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}"
completion = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=1024,
system=system_prompt,
tools=CALENDAR_TOOLS,
messages=claude_messages,
)
total_input = completion.usage.input_tokens
total_output = completion.usage.output_tokens
if completion.stop_reason != "tool_use":
response_text = "".join(b.text for b in completion.content if b.type == "text")
return response_text, total_output, total_input + total_output
tool_results = []
for block in completion.content:
if block.type != "tool_use":
continue
try:
result_text = await execute_tool(block.name, block.input)
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result_text})
except Exception as e:
tool_results.append(
{"type": "tool_result", "tool_use_id": block.id, "content": str(e), "is_error": True}
)
followup_messages = claude_messages + [
{"role": "assistant", "content": completion.content},
{"role": "user", "content": tool_results},
]
followup = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=1024,
system=system_prompt,
tools=CALENDAR_TOOLS,
messages=followup_messages,
)
total_input += followup.usage.input_tokens
total_output += followup.usage.output_tokens
response_text = "".join(b.text for b in followup.content if b.type == "text")
return response_text, total_output, total_input + total_output
```
- [ ] **Step 6: Run tests to verify they pass**
Re-run the Step 2 command. Expected: 2 passed.
- [ ] **Step 7: Rewire `chat()` to use it**
In `Claude outputs/main.py`, inside `chat()`, replace:
```python
completion = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=1024,
system=CLAUDE_SYSTEM_PROMPT,
messages=claude_messages,
)
response_text = "".join(block.text for block in completion.content if block.type == "text")
input_tokens = completion.usage.input_tokens
output_tokens = completion.usage.output_tokens
tokens_used = input_tokens + output_tokens
await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens)
```
with:
```python
response_text, output_tokens, tokens_used = await run_chat_completion(claude_messages)
await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens)
```
- [ ] **Step 8: Deploy and verify against the live API**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
```
Expected: log ends with `Application startup complete.` (no traceback). Then:
```bash
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Was steht in den naechsten 30 Tagen im Kalender an?\"}'; echo"
```
Expected: a natural-language response mentioning real events from the calendar (e.g. "THL FM LS", "Volksfestwache") — proof the tool round-trip actually executed against the live Nextcloud calendar, not a hallucinated answer.
- [ ] **Step 9: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 4: "Nächste Termine" dashboard widget
**Files:**
- Create: `web/src/components/CalendarWidget.tsx`
- Modify: `web/src/components/Dashboard.tsx` (render it)
**Interfaces:**
- Consumes: `apiFetch` (existing), `GET /api/v1/calendar/events` (Task 2).
- Produces: `CalendarWidget` default export, no props, rendered inside `Dashboard`.
- [ ] **Step 1: Create the widget**
Create `web/src/components/CalendarWidget.tsx`:
```tsx
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type CalendarEvent = { summary: string; start: string; all_day: boolean };
export default function CalendarWidget() {
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/calendar/events?days=14");
if (!response.ok) {
setError("Kalender nicht verfuegbar");
return;
}
const data = await response.json();
setEvents(data.events);
} catch {
setError("Kalender nicht verfuegbar");
}
}
load();
}, []);
function formatEvent(e: CalendarEvent): string {
const date = new Date(e.start);
const dateStr = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
if (e.all_day) return `${dateStr}: ${e.summary}`;
const timeStr = date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return `${dateStr} ${timeStr}: ${e.summary}`;
}
return (
<div className="calendar-widget">
<h3>N\u00e4chste Termine</h3>
{error && <p className="error">{error}</p>}
{events && events.length === 0 && <p>Keine Termine in den n\u00e4chsten 14 Tagen.</p>}
{events && events.length > 0 && (
<ul>
{events.map((e, i) => (
<li key={i}>{formatEvent(e)}</li>
))}
</ul>
)}
</div>
);
}
```
- [ ] **Step 2: Render it in the Dashboard**
In `web/src/components/Dashboard.tsx`, add the import next to the other imports:
```tsx
import CalendarWidget from "./CalendarWidget";
```
Add `<CalendarWidget />` right after the closing `</table>` of the health table, still inside the outer `<div className="dashboard">`.
- [ ] **Step 3: Verify the build works**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build
```
Expected: exits 0.
- [ ] **Step 4: Deploy**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS"
scp $SSHOPTS -r "$OUT\web\src" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web"
```
- [ ] **Step 5: End-to-end browser verification**
Using the claude-in-chrome browser tools: log in at `https://jarvis.mbo-tech-it.de`, open Dashboard, confirm "Nächste Termine" lists real events (e.g. "THL FM LS"). Then in Chat, ask "Was steht diese Woche im Kalender an?" and confirm a real-data answer. Then say something like "Leg einen Termin 'JARVIS Verbindungstest' am 25.09.2026 von 14 bis 15 Uhr an" and confirm JARVIS asks for confirmation before creating anything; confirm it, then verify the event actually appears (re-query the widget or the calendar endpoint) — afterwards delete that test event directly against the calendar (reuse the connectivity-check pattern: a one-off `caldav` script via `docker run python:3.11-slim`, searching by summary and calling `.delete()`) so the real organisation calendar stays clean.
- [ ] **Step 6: Confirm files saved** (no git repo for this project — see Global Constraints)

View File

@ -0,0 +1,677 @@
# JARVIS Email Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give JARVIS read/send access to the `kontakt@mbo-tech-it.de` mailbox: chat-driven live reading and confirmed sending, plus an n8n-cached "new emails" dashboard widget.
**Architecture:** `imaplib`/`smtplib` (Python stdlib, no new dependency) power two new Claude tools in `run_chat_completion` (`list_recent_emails`, `send_email`). A separate n8n workflow using the built-in `Email Trigger (IMAP)` node caches new-message metadata into a new `email_cache` table for a dashboard widget, independent of the chat tools.
**Tech Stack:** `imaplib`, `smtplib`, `email` (all Python stdlib), FastAPI (existing patterns), Anthropic tool use (existing `run_chat_completion` loop), n8n REST API, React.
**Spec:** `docs/superpowers/specs/2026-09-13-email-integration-design.md`
## Global Constraints
- No dedicated git repository — every "Commit" step is replaced by "confirm the file is saved".
- SSH: `ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98`. Backend: `Claude outputs/`. Frontend: `web/`.
- Credentials (already verified working 13.09.2026): `EMAIL_IMAP_HOST=mx2f35.netcup.net`, `EMAIL_IMAP_PORT=143` (STARTTLS), `EMAIL_SMTP_HOST=mx2f35.netcup.net`, `EMAIL_SMTP_PORT=465` (implicit SSL), `EMAIL_USER=kontakt@mbo-tech-it.de`, `EMAIL_PASSWORD=E%21gq10i` (literal string, not URL-encoded).
- `send_email` must never be called by Claude without the user first confirming recipient/subject/body in the conversation — enforced via system-prompt instruction, same pattern as `delete_calendar_event`.
- n8n already has a credential "JARVIS Postgres" (id `8b6g9X080UhxUgLv`) pointing at the `jarvis` database, and a fresh credential "JARVIS Email IMAP" (id `4rU5KYIv1sKCJkAd`) already created with the above IMAP settings (`secure: false`, since STARTTLS on port 143 is used, not implicit TLS) — reuse both, don't recreate.
- The n8n API key from the weather workflow setup is expired/inaccessible; a new one ("JARVIS Email Workflow") was created during spec verification but its value wasn't persisted anywhere durable — generate a fresh one via the n8n UI (Settings → n8n API) when Task 4 needs it, same procedure as the weather workflow.
- n8n's `n8n-nodes-base.emailReadImap` (Email Trigger IMAP) node is confirmed present in this n8n version, with a `postProcessAction: "nothing"` option that must be used so caching doesn't mark real business emails as read.
---
### Task 1: IMAP/SMTP helpers + config + unit tests
**Files:**
- Modify: `Claude outputs/main.py` (imports, config constants, `_decode_mime_words`, `_list_recent_emails_sync`/`list_recent_emails`, `_send_email_sync`/`send_email`)
- Modify: `Claude outputs/docker-compose.yml` (jarvis-api environment)
- Test: `Claude outputs/tests/test_email.py`
**Interfaces:**
- Produces: `async def list_recent_emails(limit: int = 10) -> list` returning `[{"from": str, "subject": str, "date": str, "unread": bool}, ...]`, newest first; `async def send_email(to: str, subject: str, body: str) -> dict` returning `{"to", "subject", "sent": True}`. Consumed by Task 3 (chat tools).
- [ ] **Step 1: Write the failing tests**
Create `Claude outputs/tests/test_email.py`:
```python
import base64
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import main
def _encoded_subject(text: str) -> bytes:
return b"=?utf-8?B?" + base64.b64encode(text.encode("utf-8")) + b"?="
def _header_bytes(subject_bytes: bytes) -> bytes:
return (
b"From: Sender <sender@example.com>\r\n"
b"Subject: " + subject_bytes + b"\r\n"
b"Date: Fri, 11 Sep 2026 15:47:02 +0000\r\n\r\n"
)
def test_list_recent_emails_decodes_subject_and_marks_unread():
fake_conn = MagicMock()
fake_conn.search.return_value = ("OK", [b"1"])
fake_conn.fetch.return_value = (
"OK",
[(b"1 (FLAGS () BODY[HEADER.FIELDS (FROM SUBJECT DATE)] {123}", _header_bytes(_encoded_subject("Ümlaut Betreff")))],
)
with patch.object(main.imaplib, "IMAP4", return_value=fake_conn):
emails = main._list_recent_emails_sync(limit=1)
assert len(emails) == 1
assert emails[0]["subject"] == "Ümlaut Betreff"
assert emails[0]["from"] == "Sender <sender@example.com>"
assert emails[0]["unread"] is True
fake_conn.login.assert_called_once_with(main.EMAIL_USER, main.EMAIL_PASSWORD)
def test_list_recent_emails_marks_seen_messages_as_read():
fake_conn = MagicMock()
fake_conn.search.return_value = ("OK", [b"1"])
fake_conn.fetch.return_value = (
"OK",
[(b"1 (FLAGS (\\Seen) BODY[HEADER.FIELDS (FROM SUBJECT DATE)] {123}", _header_bytes(b"Plain Subject"))],
)
with patch.object(main.imaplib, "IMAP4", return_value=fake_conn):
emails = main._list_recent_emails_sync(limit=1)
assert emails[0]["unread"] is False
assert emails[0]["subject"] == "Plain Subject"
def test_send_email_calls_smtp_with_composed_message():
fake_smtp = MagicMock()
with patch.object(main.smtplib, "SMTP_SSL", return_value=fake_smtp):
result = main._send_email_sync("empfaenger@example.com", "Betreff", "Text")
fake_smtp.login.assert_called_once_with(main.EMAIL_USER, main.EMAIL_PASSWORD)
fake_smtp.send_message.assert_called_once()
sent_msg = fake_smtp.send_message.call_args[0][0]
assert sent_msg["To"] == "empfaenger@example.com"
assert sent_msg["Subject"] == "Betreff"
assert result == {"to": "empfaenger@example.com", "subject": "Betreff", "sent": True}
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" "$OUT/pytest.ini" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_email.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_email.py -v'"
```
Expected: FAIL — `_list_recent_emails_sync`/`_send_email_sync` don't exist yet.
- [ ] **Step 3: Add imports and config constants**
In `Claude outputs/main.py`, add near the other imports (after `import caldav`):
```python
import imaplib
import smtplib
import ssl
from email import message_from_bytes
from email.header import decode_header
from email.message import EmailMessage
```
Add near the other `# ============ CONFIG ============` constants:
```python
EMAIL_IMAP_HOST = os.getenv("EMAIL_IMAP_HOST")
EMAIL_IMAP_PORT = int(os.getenv("EMAIL_IMAP_PORT", "143"))
EMAIL_SMTP_HOST = os.getenv("EMAIL_SMTP_HOST")
EMAIL_SMTP_PORT = int(os.getenv("EMAIL_SMTP_PORT", "465"))
EMAIL_USER = os.getenv("EMAIL_USER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
```
- [ ] **Step 4: Add the email helpers**
Add after the calendar helpers (`async def delete_event(...)`, end of that block):
```python
def _decode_mime_words(value: str) -> str:
parts = decode_header(value)
decoded = []
for text, charset in parts:
if isinstance(text, bytes):
decoded.append(text.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(text)
return "".join(decoded)
def _list_recent_emails_sync(limit: int = 10) -> list:
conn = imaplib.IMAP4(EMAIL_IMAP_HOST, EMAIL_IMAP_PORT)
conn.starttls(ssl.create_default_context())
conn.login(EMAIL_USER, EMAIL_PASSWORD)
conn.select("INBOX", readonly=True)
_, data = conn.search(None, "ALL")
ids = data[0].split()[-limit:]
emails = []
for msg_id in reversed(ids):
_, msg_data = conn.fetch(msg_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] FLAGS)")
flags_line = msg_data[0][0].decode("utf-8", errors="replace")
msg = message_from_bytes(msg_data[0][1])
emails.append(
{
"from": _decode_mime_words(msg.get("From", "")),
"subject": _decode_mime_words(msg.get("Subject", "")),
"date": msg.get("Date", ""),
"unread": "\\Seen" not in flags_line,
}
)
conn.logout()
return emails
async def list_recent_emails(limit: int = 10) -> list:
return await asyncio.to_thread(_list_recent_emails_sync, limit)
def _send_email_sync(to: str, subject: str, body: str) -> dict:
msg = EmailMessage()
msg["From"] = EMAIL_USER
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
conn = smtplib.SMTP_SSL(EMAIL_SMTP_HOST, EMAIL_SMTP_PORT, context=ssl.create_default_context())
conn.login(EMAIL_USER, EMAIL_PASSWORD)
conn.send_message(msg)
conn.quit()
return {"to": to, "subject": subject, "sent": True}
async def send_email(to: str, subject: str, body: str) -> dict:
return await asyncio.to_thread(_send_email_sync, to, subject, body)
```
- [ ] **Step 5: Run tests to verify they pass**
Re-run the Step 2 command. Expected: 3 passed.
- [ ] **Step 6: Wire the email env vars into the deployed container**
In `Claude outputs/docker-compose.yml`, under `jarvis-api: environment:`, add after `NEXTCLOUD_APP_PASSWORD`:
```yaml
- EMAIL_IMAP_HOST=mx2f35.netcup.net
- EMAIL_IMAP_PORT=143
- EMAIL_SMTP_HOST=mx2f35.netcup.net
- EMAIL_SMTP_PORT=465
- EMAIL_USER=kontakt@mbo-tech-it.de
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
```
Add the password to the deployed `.env`:
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "grep -q EMAIL_PASSWORD /home/jarvis-core/jarvis/.env || echo 'EMAIL_PASSWORD=E%21gq10i' >> /home/jarvis-core/jarvis/.env"
```
- [ ] **Step 7: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 2: `email_cache` table + `GET /api/v1/emails` endpoint
**Files:**
- Create: `Claude outputs/migrations/005_email_cache.sql`
- Modify: `Claude outputs/main.py` (add `get_latest_emails` DB helper + endpoint)
**Interfaces:**
- Produces: `GET /api/v1/emails?limit=10` returning `{"emails": [...]}`, each `{"sender", "subject", "received_at"}`. Consumed by Task 5's dashboard widget. Populated by Task 4's n8n workflow.
- [ ] **Step 1: Create and apply the migration**
Create `Claude outputs/migrations/005_email_cache.sql`:
```sql
CREATE TABLE email_cache (
id SERIAL PRIMARY KEY,
sender VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
received_at TIMESTAMP NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_email_cache_received_at ON email_cache(received_at);
```
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
scp $SSHOPTS "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs\migrations\005_email_cache.sql" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/migrations/005_email_cache.sql
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker exec -i jarvis-postgres psql -U jarvis -d jarvis < /home/jarvis-core/jarvis/migrations/005_email_cache.sql"
```
Expected output: `CREATE TABLE`, `CREATE INDEX`.
- [ ] **Step 2: Add the DB helper and endpoint**
In `Claude outputs/main.py`, add near the other DB helpers (after `get_latest_weather`):
```python
async def get_latest_emails(limit: int = 10):
return await db_query(
"SELECT sender, subject, received_at FROM email_cache ORDER BY received_at DESC LIMIT %s",
(limit,),
fetch="all",
)
```
Add the endpoint after `get_calendar_events`:
```python
@app.get("/api/v1/emails", dependencies=[Depends(require_admin_key)])
async def get_cached_emails(limit: int = 10):
"""Recently cached emails, populated by the n8n 'Neue E-Mails Cache' workflow"""
if not pg_pool:
raise HTTPException(status_code=503, detail="Database is not configured")
rows = await get_latest_emails(limit)
return {
"emails": [
{
"sender": row["sender"],
"subject": row["subject"],
"received_at": row["received_at"].isoformat(),
}
for row in rows
]
}
```
- [ ] **Step 3: Deploy and verify against the live API**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
```
Expected: log ends with `Application startup complete.`.
```bash
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -H 'X-Admin-Key: $ADMIN_KEY' http://localhost:8000/api/v1/emails; echo"
```
Expected: `{"emails":[]}` (table is empty until Task 4's workflow runs).
- [ ] **Step 4: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 3: Chat tool use (read + confirmed send)
**Files:**
- Modify: `Claude outputs/main.py` (tool definitions, `execute_tool`, system-prompt instructions)
- Test: `Claude outputs/tests/test_chat_tools.py` (extend)
**Interfaces:**
- Consumes: `list_recent_emails`, `send_email` (Task 1).
- Produces: two new entries in `CALENDAR_TOOLS` (kept in that list/constant name to avoid a second near-identical tools array — rename is out of scope) and matching branches in `execute_tool`.
- [ ] **Step 1: Add the tool definitions**
In `Claude outputs/main.py`, add two entries to the `CALENDAR_TOOLS` list (after `delete_calendar_event`'s closing `},`):
```python
{
"name": "list_recent_emails",
"description": "List the most recent emails in the kontakt@mbo-tech-it.de inbox.",
"input_schema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "How many recent emails to list, e.g. 10"}
},
"required": ["limit"],
},
},
{
"name": "send_email",
"description": (
"Send an email from kontakt@mbo-tech-it.de. Only call this after "
"the user has explicitly confirmed recipient, subject and body "
"in the conversation."
),
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient email address"},
"subject": {"type": "string", "description": "Email subject"},
"body": {"type": "string", "description": "Plain text email body"},
},
"required": ["to", "subject", "body"],
},
},
```
- [ ] **Step 2: Extend the system-prompt instructions**
In `Claude outputs/main.py`, change the `CALENDAR_ASSISTANT_INSTRUCTIONS` string (append to the existing text, keep everything before it unchanged):
```python
CALENDAR_ASSISTANT_INSTRUCTIONS = (
"Du hast Zugriff auf den Kalender 'FFW-Onza-Alle' ueber die Tools "
"list_calendar_events, create_calendar_event, update_calendar_event und "
"delete_calendar_event. create_calendar_event und update_calendar_event "
"kannst du direkt aufrufen, sobald du die noetigen Angaben hast - keine "
"Rueckfrage noetig. Bevor du delete_calendar_event aufrufst, frage den "
"Nutzer aber immer explizit im Klartext nach Bestaetigung und rufe das "
"Tool erst auf, nachdem der Nutzer im naechsten Chat-Beitrag zugestimmt "
"hat. Fuer update_calendar_event und delete_calendar_event brauchst du "
"die uid des Termins - ruf dafuer zuerst list_calendar_events auf, falls "
"du sie noch nicht aus dem Gespraech kennst.\n\n"
"Du hast ausserdem Zugriff auf das Postfach kontakt@mbo-tech-it.de ueber "
"die Tools list_recent_emails und send_email. list_recent_emails kannst "
"du direkt aufrufen. Bevor du send_email aufrufst, zeige dem Nutzer "
"Empfaenger, Betreff und Text zur Kontrolle und rufe das Tool erst auf, "
"nachdem der Nutzer im naechsten Chat-Beitrag zugestimmt hat - "
"kontakt@mbo-tech-it.de ist die offizielle Firmenadresse, eine "
"versendete Mail laesst sich nicht zurueckholen."
)
```
- [ ] **Step 3: Wire the dispatcher**
In `Claude outputs/main.py`, add to `execute_tool` (after the `delete_calendar_event` branch, before `raise ValueError`):
```python
if name == "list_recent_emails":
emails = await list_recent_emails(tool_input.get("limit", 10))
return json.dumps(emails)
if name == "send_email":
result = await send_email(tool_input["to"], tool_input["subject"], tool_input["body"])
return json.dumps(result)
```
- [ ] **Step 4: Extend the chat-tools regression test**
In `Claude outputs/tests/test_chat_tools.py`, add at the end of the file:
```python
@pytest.mark.asyncio
async def test_run_chat_completion_lists_recent_emails():
first = MagicMock()
first.stop_reason = "tool_use"
first.content = [_tool_use_block("list_recent_emails", {"limit": 5}, "tool_1")]
first.usage = _usage(15, 6)
second = MagicMock()
second.stop_reason = "end_turn"
second.content = [_text_block("Du hast 5 neue Mails.")]
second.usage = _usage(20, 8)
main.claude_client = MagicMock()
main.claude_client.messages.create.side_effect = [first, second]
with patch.object(main, "list_recent_emails", new=AsyncMock(return_value=[])):
text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Was ist neu im Postfach?"}]
)
assert text == "Du hast 5 neue Mails."
assert output_tokens == 6 + 8
assert total_tokens == 15 + 6 + 20 + 8
```
- [ ] **Step 5: Run all backend tests**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/main.py
scp $SSHOPTS "$OUT/tests/test_chat_tools.py" "$OUT/tests/test_email.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/ -v'"
```
Expected: all tests pass (should be 21 total: 17 previous + 3 email + 1 new chat-tools).
- [ ] **Step 6: Deploy and verify against the live API**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api"
```
Expected: log ends with `Application startup complete.`.
```bash
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Was ist neu im Postfach kontakt@mbo-tech-it.de? Zeig die letzten 5 Mails.\"}'; echo"
```
Expected: a natural-language response listing real subjects/senders from the live inbox (proof the tool round-trip actually hit IMAP).
Then test the confirmation gate:
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Schick eine Testmail an kontakt@mbo-tech-it.de mit Betreff JARVIS Test und Text Hallo aus JARVIS.\"}'; echo"
```
Expected: JARVIS asks for confirmation, does NOT send yet (no `is_error` and no immediate "gesendet" claim). Confirm in a follow-up (`conversation_id` from the response) with `{"conversation_id": <id>, "message": "Ja, senden."}` and expect a real send confirmation - then check the inbox arrived (e.g. re-run `list_recent_emails` via chat, or check via the Task 1 IMAP test pattern).
- [ ] **Step 7: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 4: n8n "Neue E-Mails Cache" workflow
**Files:** none (n8n workflow lives in n8n's own database, not in this repo)
**Interfaces:**
- Consumes: n8n credentials "JARVIS Email IMAP" (id `4rU5KYIv1sKCJkAd`) and "JARVIS Postgres" (id `8b6g9X080UhxUgLv`), both already created.
- Produces: rows in `email_cache` (Task 2's table), consumed by Task 5's widget.
- [ ] **Step 1: Generate a fresh n8n API key**
Via browser: log in at `https://n8n.jarvis.mbo-tech-it.de` (owner account `jonny@mbo-tech-it.de`), Settings → n8n API → Create API key, label "JARVIS Email Workflow Runtime", scope "All". Copy the value into a page-local JS variable immediately (it's only shown once) — same procedure as the weather workflow setup: paste into a visible input (e.g. the API-key-list search box) and read `.value` via `javascript_exec`, since the creation dialog's own field is CSS-truncated.
- [ ] **Step 2: Create the workflow via the n8n REST API**
Using `fetch` in the browser page context with `X-N8N-API-KEY` set to the key from Step 1 (POST to `/api/v1/workflows`):
```json
{
"name": "Neue E-Mails Cache",
"nodes": [
{
"id": "trigger1",
"name": "Email Trigger IMAP",
"type": "n8n-nodes-base.emailReadImap",
"typeVersion": 2,
"position": [240, 300],
"parameters": {
"mailbox": "INBOX",
"postProcessAction": "nothing",
"format": "simple",
"options": {}
},
"credentials": {
"imap": { "id": "4rU5KYIv1sKCJkAd", "name": "JARVIS Email IMAP" }
}
},
{
"id": "pg1",
"name": "Insert Email",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [460, 300],
"parameters": {
"operation": "insert",
"schema": { "__rl": true, "value": "public", "mode": "list" },
"table": { "__rl": true, "value": "email_cache", "mode": "list" },
"columns": {
"mappingMode": "defineBelow",
"value": {
"sender": "={{ $json.from }}",
"subject": "={{ $json.subject }}",
"received_at": "={{ $json.date }}"
},
"matchingColumns": [],
"schema": []
}
},
"credentials": {
"postgres": { "id": "8b6g9X080UhxUgLv", "name": "JARVIS Postgres" }
}
}
],
"connections": {
"Email Trigger IMAP": { "main": [[{ "node": "Insert Email", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" }
}
```
Note: the exact field name for the sender on the IMAP trigger's "simple" format output is unconfirmed (could be a plain string or an object like `{text, value}}` depending on the mail parser) — this is discovered and corrected in Step 4 below using a real test email, the same way the weather workflow's node parameters were verified against the live API before finalizing.
- [ ] **Step 3: Activate the workflow**
```
POST /api/v1/workflows/<id>/activate
```
with the same API key.
- [ ] **Step 4: Send a real test email and verify the cache row**
Use the already-deployed and tested `send_email` (Task 3) to send a message to the inbox itself, so the new trigger has something to catch:
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-)
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Schick eine Testmail an kontakt@mbo-tech-it.de mit Betreff JARVIS n8n Cache Test und Text Test.\"}'; echo"
```
Confirm the send in a follow-up chat message (see Task 3 Step 6's confirmation pattern). Wait ~30-60s for the IMAP trigger to poll, then check:
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker exec jarvis-postgres psql -U jarvis -d jarvis -c 'SELECT sender, subject, received_at FROM email_cache ORDER BY id DESC LIMIT 3;'"
```
Expected: a row with subject "JARVIS n8n Cache Test". If the `sender`/`subject`/`received_at` columns are empty or malformed, inspect the actual execution data in the n8n UI (Executions tab on the workflow) to see the real output shape from the "Email Trigger IMAP" node, and adjust the Postgres node's `columns.value` expressions accordingly (e.g. `$json.from.text` instead of `$json.from`) via a `PATCH /api/v1/workflows/<id>` call, then resend a test email to reverify.
- [ ] **Step 5: Confirm the workflow is saved** (lives in n8n's own DB, not this repo — no file to save)
---
### Task 5: "Neue E-Mails" dashboard widget
**Files:**
- Create: `web/src/components/EmailWidget.tsx`
- Modify: `web/src/components/Dashboard.tsx` (render it)
**Interfaces:**
- Consumes: `apiFetch` (existing), `GET /api/v1/emails` (Task 2).
- Produces: `EmailWidget` default export, no props, rendered inside `Dashboard`.
- [ ] **Step 1: Create the widget**
Create `web/src/components/EmailWidget.tsx`:
```tsx
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type CachedEmail = { sender: string; subject: string; received_at: string };
export default function EmailWidget() {
const [emails, setEmails] = useState<CachedEmail[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/emails?limit=5");
if (!response.ok) {
setError("Postfach-Cache nicht verfuegbar");
return;
}
const data = await response.json();
setEmails(data.emails);
} catch {
setError("Postfach-Cache nicht verfuegbar");
}
}
load();
}, []);
return (
<div className="email-widget">
<h3>Neue E-Mails</h3>
{error && <p className="error">{error}</p>}
{emails && emails.length === 0 && <p>Noch keine gecachten E-Mails.</p>}
{emails && emails.length > 0 && (
<ul>
{emails.map((e, i) => (
<li key={i}>
{`${new Date(e.received_at).toLocaleDateString("de-DE")}: ${e.sender} \u2013 ${e.subject}`}
</li>
))}
</ul>
)}
</div>
);
}
```
- [ ] **Step 2: Render it in the Dashboard**
In `web/src/components/Dashboard.tsx`, add the import next to `CalendarWidget`'s:
```tsx
import EmailWidget from "./EmailWidget";
```
Add `<EmailWidget />` right after `<CalendarWidget />`.
- [ ] **Step 3: Verify the build works**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build
```
Expected: exits 0.
- [ ] **Step 4: Deploy**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS"
scp $SSHOPTS -r "$OUT\web\src" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web"
```
- [ ] **Step 5: End-to-end browser verification**
Using the claude-in-chrome browser tools: log in at `https://jarvis.mbo-tech-it.de` (append a cache-busting query param like `?nocache=1` on first load after deploy if the page looks stale, per the known nginx/browser-cache behavior documented in `JARVIS_HANDOFF.md`), open Dashboard, confirm "Neue E-Mails" lists the test email(s) from Task 4.
- [ ] **Step 6: Confirm files saved** (no git repo for this project — see Global Constraints)

View File

@ -0,0 +1,756 @@
# JARVIS Einstellungen Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ein Einstellungen-Menuepunkt in JARVIS, ueber den Assistenten-Name, Firmenname und Kontakt-E-Mail (reine Branding-Werte) live aenderbar sind - sofort wirksam im Chat-System-Prompt und im Frontend (Seitentitel, Chat-Platzhalter), ohne Deploy/Neustart.
**Architecture:** Neue Postgres-Tabelle `settings` (Key-Value, fester Katalog aus drei Keys mit Code-Defaults). Zwei neue admin-key-geschuetzte REST-Endpoints (`GET`/`PUT /api/v1/settings`). Die bisher statische Identitaets-Zeile im Chat-System-Prompt (`CLAUDE_SYSTEM_PROMPT`-Env-Var) wird durch eine dynamisch aus den Settings gebaute Zeile ersetzt, analog zum bestehenden `_current_datetime_context()`-Muster. Frontend bekommt eine neue Settings-Seite plus zwei kleine dynamische Stellen (Seitentitel, Chat-Platzhalter).
**Tech Stack:** Python 3.11, FastAPI, psycopg2 (Postgres), pytest + pytest-asyncio + unittest.mock. React/Vite, TypeScript. Alles in der bestehenden einzigen Backend-Datei `Claude outputs/main.py` (etabliertes Pattern dieses Projekts).
**Spec:** `docs/superpowers/specs/2026-09-13-settings-design.md`
## Global Constraints
- E-Mailadresse ist ein reiner Anzeige-/Branding-Wert - das technische Postfach (`EMAIL_USER`/`EMAIL_PASSWORD`) bleibt unveraendert ein Deployment-Secret in `.env`.
- Einstellungen sind NUR ueber die Settings-Seite aenderbar, kein Chat-Tool.
- Alle drei Settings-Keys sind fest vorgegeben (`assistant_name`, `company_name`, `contact_email`) - `PUT` mit einem unbekannten Key wird komplett abgelehnt (`400`), auch die gueltigen Keys im selben Request werden dann NICHT gespeichert (alles oder nichts).
- `GET`/`PUT /api/v1/settings` sind wie alle bestehenden `/api/v1/*`-Routen (ausser `/health`) hinter `X-Admin-Key` - der Login-Screen (`Login.tsx`) bleibt bewusst statisch "JARVIS", da er vor dem Login keinen Zugriff auf Settings hat.
- Tests laufen nicht lokal (kein psycopg2-Wheel fuer Python 3.14) - Ausfuehrung ueber den Docker-Testcontainer-Weg aus `JARVIS_HANDOFF.md` ("Backend-Tests lokal ausfuehren").
- Frontend-Komponenten haben in diesem Projekt keine eigenen Tests (nur `web/src/api.test.ts` testet das API-Modul) - Verifikation ueber `npm run build`.
---
## Task 1: Migration `007_settings.sql`
**Files:**
- Create: `Claude outputs/migrations/007_settings.sql`
**Interfaces:**
- Produces: Tabelle `settings (key, value, updated_at)`, auf die Task 2 per SQL zugreift.
- [ ] **Step 1: Migration schreiben**
```sql
CREATE TABLE settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
- [ ] **Step 2: Kein automatisierter Test moeglich**
Reines SQL-DDL ohne lokale Postgres-Instanz - kann nicht per pytest
verifiziert werden. Anwendung und Verifikation (`\d settings`) passiert in
Task 7 gegen die echte VPS-Datenbank.
- [ ] **Step 3: Commit**
```bash
git add "Claude outputs/migrations/007_settings.sql"
git commit -m "feat: add settings table migration"
```
---
## Task 2: Settings DB-Helper
**Files:**
- Modify: `Claude outputs/main.py` (neue Konstante + Funktionen direkt nach `get_all_memory_facts()`, main.py:296-304)
- Test: `Claude outputs/tests/test_settings.py` (neu)
**Interfaces:**
- Consumes: `db_query(query, params, fetch)` (bestehend, main.py:143)
- Produces: `SETTINGS_DEFAULTS: dict`, `get_setting(key: str) -> str`, `get_all_settings() -> dict`, `set_setting(key: str, value: str) -> None` - werden von Task 3 (REST-Endpoints) und Task 4 (System-Prompt) konsumiert.
- [ ] **Step 1: Failing tests schreiben**
```python
# Claude outputs/tests/test_settings.py
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
sys.modules["psycopg2"] = MagicMock()
sys.modules["psycopg2.pool"] = MagicMock()
sys.modules["psycopg2.extras"] = MagicMock()
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import main
@pytest.mark.asyncio
async def test_get_setting_returns_stored_value():
with patch.object(main, "db_query", new=AsyncMock(return_value={"value": "Custom GmbH"})) as mock_query:
value = await main.get_setting("company_name")
assert value == "Custom GmbH"
args, kwargs = mock_query.call_args
assert "SELECT value FROM settings" in args[0]
assert args[1] == ("company_name",)
@pytest.mark.asyncio
async def test_get_setting_falls_back_to_default_when_missing():
with patch.object(main, "db_query", new=AsyncMock(return_value=None)):
value = await main.get_setting("assistant_name")
assert value == "JARVIS"
@pytest.mark.asyncio
async def test_get_all_settings_merges_defaults_with_stored_overrides():
rows = [{"key": "assistant_name", "value": "FRIDAY"}]
with patch.object(main, "db_query", new=AsyncMock(return_value=rows)):
settings = await main.get_all_settings()
assert settings == {
"assistant_name": "FRIDAY",
"company_name": "MBO-Tech-IT",
"contact_email": "kontakt@mbo-tech-it.de",
}
@pytest.mark.asyncio
async def test_get_all_settings_returns_defaults_when_table_empty():
with patch.object(main, "db_query", new=AsyncMock(return_value=[])):
settings = await main.get_all_settings()
assert settings == main.SETTINGS_DEFAULTS
@pytest.mark.asyncio
async def test_set_setting_upserts_value():
with patch.object(main, "db_query", new=AsyncMock(return_value=None)) as mock_query:
await main.set_setting("company_name", "Neue Firma GmbH")
args, kwargs = mock_query.call_args
assert "INSERT INTO settings" in args[0]
assert "ON CONFLICT (key) DO UPDATE" in args[0]
assert args[1] == ("company_name", "Neue Firma GmbH")
```
- [ ] **Step 2: Tests laufen lassen, Fehlschlag pruefen**
Run: `python -m pytest tests/test_settings.py -v` (im Docker-Testcontainer,
siehe `JARVIS_HANDOFF.md`)
Expected: FAIL mit `AttributeError: module 'main' has no attribute
'get_setting'` (und analog fuer `get_all_settings`/`set_setting`)
- [ ] **Step 3: Implementierung**
In `main.py`, direkt nach `get_all_memory_facts()`:
```python
SETTINGS_DEFAULTS = {
"assistant_name": "JARVIS",
"company_name": "MBO-Tech-IT",
"contact_email": "kontakt@mbo-tech-it.de",
}
async def get_setting(key: str) -> str:
row = await db_query("SELECT value FROM settings WHERE key = %s", (key,), fetch="one")
return row["value"] if row else SETTINGS_DEFAULTS[key]
async def get_all_settings() -> dict:
rows = await db_query("SELECT key, value FROM settings", fetch="all")
merged = dict(SETTINGS_DEFAULTS)
for row in rows:
merged[row["key"]] = row["value"]
return merged
async def set_setting(key: str, value: str):
await db_query(
"""
INSERT INTO settings (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
""",
(key, value),
)
```
- [ ] **Step 4: Tests laufen lassen, Erfolg pruefen**
Run: `python -m pytest tests/test_settings.py -v`
Expected: 5 PASS
- [ ] **Step 5: Commit**
```bash
git add "Claude outputs/main.py" "Claude outputs/tests/test_settings.py"
git commit -m "feat: add settings DB helpers with default fallback"
```
---
## Task 3: REST-Endpoints `GET`/`PUT /api/v1/settings`
**Files:**
- Modify: `Claude outputs/main.py` (Import von `Body` ergaenzen, main.py:6; neue Routen nach `get_orders`, main.py:1460-1467)
- Test: `Claude outputs/tests/test_settings.py` (erweitern)
**Interfaces:**
- Consumes: `get_all_settings`, `set_setting`, `SETTINGS_DEFAULTS` (Task 2), `require_admin_key` (bestehend)
- Produces: `GET /api/v1/settings` -> vollstaendiges Settings-Dict, `PUT /api/v1/settings` -> aktualisiertes Dict oder `400` bei unbekanntem Key.
- [ ] **Step 1: Failing tests schreiben**
An `Claude outputs/tests/test_settings.py` anhaengen:
```python
from fastapi.testclient import TestClient
main.API_KEY_ADMIN = "test-secret"
HEADERS = {"X-Admin-Key": "test-secret"}
@pytest.fixture()
def client():
with TestClient(main.app) as c:
yield c
def test_get_settings_endpoint_returns_merged_settings(client):
settings = {"assistant_name": "JARVIS", "company_name": "MBO-Tech-IT", "contact_email": "kontakt@mbo-tech-it.de"}
with patch.object(main, "get_all_settings", new=AsyncMock(return_value=settings)):
response = client.get("/api/v1/settings", headers=HEADERS)
assert response.status_code == 200
assert response.json() == settings
def test_get_settings_endpoint_requires_admin_key(client):
response = client.get("/api/v1/settings")
assert response.status_code == 401
def test_put_settings_endpoint_updates_and_returns_merged_settings(client):
updated = {"assistant_name": "FRIDAY", "company_name": "MBO-Tech-IT", "contact_email": "kontakt@mbo-tech-it.de"}
with patch.object(main, "set_setting", new=AsyncMock()) as mock_set, patch.object(
main, "get_all_settings", new=AsyncMock(return_value=updated)
):
response = client.put("/api/v1/settings", headers=HEADERS, json={"assistant_name": "FRIDAY"})
assert response.status_code == 200
assert response.json() == updated
mock_set.assert_called_once_with("assistant_name", "FRIDAY")
def test_put_settings_endpoint_rejects_unknown_key(client):
with patch.object(main, "set_setting", new=AsyncMock()) as mock_set:
response = client.put("/api/v1/settings", headers=HEADERS, json={"nonsense_key": "x"})
assert response.status_code == 400
mock_set.assert_not_called()
def test_put_settings_endpoint_requires_admin_key(client):
response = client.put("/api/v1/settings", json={"assistant_name": "X"})
assert response.status_code == 401
```
- [ ] **Step 2: Tests laufen lassen, Fehlschlag pruefen**
Run: `python -m pytest tests/test_settings.py -v`
Expected: die 5 neuen Tests FAILEN mit `404 Not Found` (Routen existieren
noch nicht)
- [ ] **Step 3: Implementierung**
Import-Zeile in `main.py` (Zeile 6) erweitern:
```python
from fastapi import FastAPI, HTTPException, Depends, Header, Body
```
Nach `get_orders` (main.py:1460-1467):
```python
@app.get("/api/v1/settings", dependencies=[Depends(require_admin_key)])
async def get_settings_endpoint():
return await get_all_settings()
@app.put("/api/v1/settings", dependencies=[Depends(require_admin_key)])
async def update_settings_endpoint(updates: dict = Body(...)):
for key in updates:
if key not in SETTINGS_DEFAULTS:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
for key, value in updates.items():
await set_setting(key, str(value))
return await get_all_settings()
```
Die Validierung laeuft in einer eigenen Schleife VOR dem Speichern, damit
bei einem unbekannten Key nichts geschrieben wird (alles oder nichts).
- [ ] **Step 4: Tests laufen lassen, Erfolg pruefen**
Run: `python -m pytest tests/test_settings.py -v`
Expected: 10 PASS insgesamt
- [ ] **Step 5: Commit**
```bash
git add "Claude outputs/main.py" "Claude outputs/tests/test_settings.py"
git commit -m "feat: add GET/PUT /api/v1/settings endpoints"
```
---
## Task 4: Dynamische Identitaets-Zeile im Chat-System-Prompt
**Files:**
- Modify: `Claude outputs/main.py` (CLAUDE_SYSTEM_PROMPT main.py:59-62 entfernen, neue Funktion vor `run_chat_completion`, Aufrufstelle main.py:1047-1050 anpassen)
- Test: `Claude outputs/tests/test_settings.py` (erweitern), `Claude outputs/tests/test_chat_tools.py`, `Claude outputs/tests/test_memory.py`, `Claude outputs/tests/test_orders.py` (bestehende `run_chat_completion`-Aufrufe anpassen)
**Interfaces:**
- Consumes: `get_all_settings` (Task 2)
- Produces: `_identity_system_prompt() -> str` (async!) - ersetzt `CLAUDE_SYSTEM_PROMPT` in `run_chat_completion`.
**Wichtig:** `_identity_system_prompt()` ruft `get_all_settings()` auf, das
intern `db_query()` nutzt. Jeder bestehende Test, der `run_chat_completion`
aufruft, muss deshalb zusaetzlich `main._identity_system_prompt` mocken -
sonst schlaegt der Test fehl, weil `pg_pool` in der Testumgebung nicht
gesetzt ist (`db_query` wirft dann `RuntimeError: Database not
configured`). Betroffen sind alle 7 bestehenden Aufrufstellen (siehe
Step 1).
- [ ] **Step 1: Failing Test fuer die neue Funktion schreiben + bestehende Tests anpassen**
An `Claude outputs/tests/test_settings.py` anhaengen:
```python
@pytest.mark.asyncio
async def test_identity_system_prompt_uses_settings():
settings = {"assistant_name": "FRIDAY", "company_name": "Stark Industries", "contact_email": "info@stark.example"}
with patch.object(main, "get_all_settings", new=AsyncMock(return_value=settings)):
prompt = await main._identity_system_prompt()
assert "FRIDAY" in prompt
assert "Stark Industries" in prompt
assert "info@stark.example" in prompt
```
In `Claude outputs/tests/test_chat_tools.py` bei allen 5 Aufrufstellen von
`main.run_chat_completion(...)` das bestehende `with patch.object(main,
"build_memory_context", ...)` um `patch.object(main,
"_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS."))`
ergaenzen. Konkret:
Zeile 46 (`test_run_chat_completion_without_tool_use`):
```python
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")
):
text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Hi"}], conversation_id=1
)
```
Zeile 71-73 (`test_run_chat_completion_executes_tool_and_returns_followup`):
```python
with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[])), patch.object(
main, "build_memory_context", new=AsyncMock(return_value="")
), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
```
Zeile 109-111 (`test_run_chat_completion_handles_two_sequential_tool_calls`):
```python
with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[{"uid": "abc"}])), patch.object(
main, "update_event", new=AsyncMock(return_value={"uid": "abc"})
), patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")
):
```
Zeile 137-139 (`test_run_chat_completion_lists_recent_emails`):
```python
with patch.object(main, "list_recent_emails", new=AsyncMock(return_value=[])), patch.object(
main, "build_memory_context", new=AsyncMock(return_value="")
), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
```
Zeile 169-171 (`test_run_chat_completion_includes_current_datetime_in_system_prompt`):
```python
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_current_datetime_context", return_value="Aktuelles Datum und Uhrzeit: TESTMARKER"
), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
```
In `Claude outputs/tests/test_memory.py`, Zeile 207-209
(`test_run_chat_completion_includes_memory_context_in_system_prompt`):
```python
with patch.object(
main, "build_memory_context", new=AsyncMock(return_value="Bekannte Fakten ueber den Nutzer:\n- Hund heisst Bruno")
), patch.object(main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")):
```
In `Claude outputs/tests/test_orders.py`, Zeile 331
(`test_run_chat_completion_includes_order_tools_and_instructions`):
```python
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")), patch.object(
main, "_identity_system_prompt", new=AsyncMock(return_value="Du bist JARVIS.")
):
```
- [ ] **Step 2: Tests laufen lassen, Fehlschlag pruefen**
Run: `python -m pytest tests/test_settings.py tests/test_chat_tools.py tests/test_memory.py tests/test_orders.py -v`
Expected: alle 8 betroffenen Tests (der neue
`test_identity_system_prompt_uses_settings` und die 7 angepassten
`run_chat_completion`-Tests) FAILEN mit `AttributeError: <module 'main'>
does not have the attribute '_identity_system_prompt'` - `patch.object`
verlangt, dass das Attribut bereits existiert, was erst nach Schritt 3 der
Fall ist.
- [ ] **Step 3: Implementierung**
In `main.py`, Zeilen 59-62 (`CLAUDE_SYSTEM_PROMPT = os.getenv(...)`)
komplett entfernen.
Direkt vor `async def run_chat_completion(...)` (main.py:1043) einfuegen:
```python
async def _identity_system_prompt() -> str:
settings = await get_all_settings()
return (
f"Du bist {settings['assistant_name']}, der KI-Assistent von "
f"{settings['company_name']}. Bei Fragen zur Erreichbarkeit kannst "
f"du auf {settings['contact_email']} verweisen."
)
```
In `run_chat_completion` die Zeilen 1047-1050 ersetzen:
```python
system_prompt = (
f"{await _identity_system_prompt()}\n\n{_current_datetime_context()}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}\n\n"
f"{MEMORY_ASSISTANT_INSTRUCTIONS}\n\n{ORDER_ASSISTANT_INSTRUCTIONS}"
)
```
- [ ] **Step 4: Tests laufen lassen, Erfolg pruefen**
Run: `python -m pytest tests/ -v` (kompletter Testcontainer-Durchlauf aus
`JARVIS_HANDOFF.md`)
Expected: alle Tests aller Dateien PASS, keine Regression
- [ ] **Step 5: Commit**
```bash
git add "Claude outputs/main.py" "Claude outputs/tests/test_settings.py" \
"Claude outputs/tests/test_chat_tools.py" "Claude outputs/tests/test_memory.py" \
"Claude outputs/tests/test_orders.py"
git commit -m "feat: build chat identity line from settings instead of a static env var"
```
---
## Task 5: Frontend `Settings.tsx`
**Files:**
- Create: `web/src/components/Settings.tsx`
**Interfaces:**
- Consumes: `apiFetch` (`web/src/api.ts`), `GET`/`PUT /api/v1/settings` (Task 3)
- Produces: `<Settings />`-Komponente, wird in Task 6 in `App.tsx` eingebunden.
**Hinweis zu Tests:** Wie `CalendarWidget`/`EmailWidget`/`OrdersWidget`
bekommt auch `Settings.tsx` keinen eigenen Komponenten-Test (Projekt-
Konvention) - Verifikation ueber `npm run build` in Task 6.
- [ ] **Step 1: Komponente erstellen**
```tsx
// web/src/components/Settings.tsx
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type SettingsData = { assistant_name: string; company_name: string; contact_email: string };
export default function Settings() {
const [settings, setSettings] = useState<SettingsData | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/settings");
if (!response.ok) {
setError("Einstellungen nicht verfuegbar");
return;
}
setSettings(await response.json());
} catch {
setError("Einstellungen nicht verfuegbar");
}
}
load();
}, []);
function updateField(field: keyof SettingsData, value: string) {
setSettings((prev) => (prev ? { ...prev, [field]: value } : prev));
setSaved(false);
}
async function save() {
if (!settings) return;
setSaving(true);
setError(null);
try {
const response = await apiFetch("/api/v1/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!response.ok) {
setError("Speichern fehlgeschlagen");
return;
}
setSettings(await response.json());
setSaved(true);
} catch {
setError("Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
}
if (!settings) {
return (
<div className="settings">
{error ? <p className="error">{error}</p> : <p>Lade Einstellungen...</p>}
</div>
);
}
return (
<div className="settings">
<h2>Einstellungen</h2>
{error && <p className="error">{error}</p>}
<label>
Name des Assistenten
<input
value={settings.assistant_name}
onChange={(e) => updateField("assistant_name", e.target.value)}
/>
</label>
<label>
Firmenname
<input
value={settings.company_name}
onChange={(e) => updateField("company_name", e.target.value)}
/>
</label>
<label>
Kontakt-E-Mail
<input
value={settings.contact_email}
onChange={(e) => updateField("contact_email", e.target.value)}
/>
</label>
<button onClick={save} disabled={saving}>
{saving ? "Speichert..." : "Speichern"}
</button>
{saved && <p>Gespeichert.</p>}
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add web/src/components/Settings.tsx
git commit -m "feat: add Settings component"
```
---
## Task 6: Frontend-Integration (`App.tsx`, `Chat.tsx`)
**Files:**
- Modify: `web/src/App.tsx`, `web/src/components/Chat.tsx`, `web/src/index.css`
**Interfaces:**
- Consumes: `<Settings />` (Task 5), `apiFetch` (bestehend)
- Produces: neuer Nav-Punkt "Einstellungen", dynamischer Seitentitel und Chat-Platzhalter aus `assistant_name`.
- [ ] **Step 1: `App.tsx` erweitern**
`View`-Type und Imports anpassen:
```tsx
import { useState, useEffect } from "react";
import { apiFetch, getStoredKey, onUnauthorized, logout } from "./api";
import { getSpeechMuted, setSpeechMuted } from "./speech";
import Login from "./components/Login";
import Chat from "./components/Chat";
import Dashboard from "./components/Dashboard";
import Settings from "./components/Settings";
import WeatherWidget from "./components/WeatherWidget";
type View = "chat" | "dashboard" | "settings";
```
Im `App`-Component-Body, nach dem bestehenden `muted`-State, den
Assistentennamen laden und `document.title` setzen:
```tsx
const [assistantName, setAssistantName] = useState<string>("JARVIS");
useEffect(() => {
if (!authenticated) return;
async function loadAssistantName() {
try {
const response = await apiFetch("/api/v1/settings");
if (!response.ok) return;
const data = await response.json();
setAssistantName(data.assistant_name);
document.title = data.assistant_name;
} catch {
// Default "JARVIS" bleibt bei Fehlern erhalten.
}
}
loadAssistantName();
}, [authenticated]);
```
Nav-Buttons und View-Rendering anpassen (bestehenden Block ersetzen):
```tsx
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => setView("settings")} disabled={view === "settings"}>Einstellungen</button>
<button onClick={toggleMuted} title={muted ? "Sprachausgabe einschalten" : "Sprachausgabe stummschalten"}>
{muted ? "🔇" : "🔊"}
</button>
<button onClick={() => logout()}>Logout</button>
<WeatherWidget />
</nav>
{view === "chat" && <Chat assistantName={assistantName} />}
{view === "dashboard" && <Dashboard />}
{view === "settings" && <Settings />}
```
- [ ] **Step 2: `Chat.tsx` um `assistantName`-Prop erweitern**
Die Funktionssignatur `export default function Chat() {` (main.py-Aequivalent
`web/src/components/Chat.tsx:40`) aendern zu:
```tsx
type ChatProps = { assistantName: string };
export default function Chat({ assistantName }: ChatProps) {
```
Den bestehenden Platzhaltertext (`web/src/components/Chat.tsx:173`,
`placeholder="Nachricht an JARVIS..."`) ersetzen durch:
```tsx
placeholder={`Nachricht an ${assistantName}...`}
```
- [ ] **Step 3: CSS ergaenzen**
An `web/src/index.css` anhaengen:
```css
.settings { padding: 1rem; max-width: 400px; }
.settings label { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 1rem; }
.settings input { padding: 0.5rem; }
```
- [ ] **Step 4: Build/Typecheck lokal pruefen**
Run: `cd web && npm run build`
Expected: kein TypeScript-Fehler, Build erfolgreich
- [ ] **Step 5: Commit**
```bash
git add web/src/App.tsx web/src/components/Chat.tsx web/src/index.css
git commit -m "feat: wire Settings page and dynamic assistant name into the frontend"
```
---
## Task 7: Deployment
**Files:**
- Keine Code-Aenderungen - reine Deployment-Aktion gegen die echte VPS-Infrastruktur (Befehle aus `JARVIS_HANDOFF.md`, Abschnitte "Database Management" und "Quick Reference").
**Interfaces:**
- Consumes: alle vorherigen Tasks (fertiger, getesteter Code + Migration)
- Produces: laufendes Feature auf `https://jarvis.mbo-tech-it.de`
- [ ] **Step 1: Migration auf die VPS kopieren und anwenden**
```bash
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
"Claude outputs/migrations/007_settings.sql" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/migrations/
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"docker exec -i jarvis-postgres psql -U jarvis -d jarvis < /home/jarvis-core/jarvis/migrations/007_settings.sql"
```
- [ ] **Step 2: Migration verifizieren**
```bash
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"docker exec jarvis-postgres psql -U jarvis -d jarvis -c '\d settings'"
```
Expected: Tabelle mit den erwarteten Spalten wird angezeigt.
- [ ] **Step 3: Backend deployen**
```bash
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
"Claude outputs/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"cd /home/jarvis-core/jarvis && docker compose restart jarvis-api"
```
- [ ] **Step 4: Frontend deployen**
```bash
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key -r \
web/src jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web"
```
- [ ] **Step 5: Manueller End-to-End-Test**
1. `https://jarvis.mbo-tech-it.de` oeffnen, einloggen, "Einstellungen" oeffnen.
2. Assistentenname auf einen Testwert (z.B. "Onza-Bot") aendern, speichern.
3. Pruefen: Browser-Tab-Titel und Chat-Eingabefeld-Platzhalter aktualisieren sich sofort.
4. Im Chat fragen "Wie heisst du?" - Antwort muss den neuen Namen nutzen.
5. Assistentenname wieder auf "JARVIS" zuruecksetzen und speichern.
- [ ] **Step 6: Logs pruefen**
```bash
ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98 \
"docker compose -f /home/jarvis-core/jarvis/docker-compose.yml logs --tail 100 jarvis-api"
```
Expected: keine unerwarteten Exceptions.

View File

@ -0,0 +1,130 @@
# JARVIS Knowledge Base (Phase 3) - Design
**Datum:** 2026-09-12
**Status:** Approved, bereit fuer Implementierungsplan
## Kontext
Phase 2 (Claude-Chat-Integration, Postgres-Persistenz, Health-Checks) ist live
auf dem VPS (72.61.186.98). Die JARVIS-API hat bereits Platzhalter-Endpunkte
fuer eine Knowledge Base (`POST/GET /api/v1/documents`), die aktuell nur
TODO-Stubs sind. Diese Spec beschreibt die echte Implementierung.
Die urspruengliche Architektur (siehe `JARVIS_HANDOFF.md`) sah Milvus als
Vector-DB vor. Das dafuer vorbereitete `docker-compose.yml` referenziert
`milvusdb/milvus:v0.4.0`, ein nicht existierendes Image, und ein veraltetes
Config-Format (nur etcd, kein MinIO) das mit modernem Milvus-Standalone nicht
kompatibel ist. Statt das nachzubauen, wird stattdessen `pgvector` in der
bereits laufenden Postgres-Instanz verwendet - siehe Entscheidung unten.
## Entscheidung: pgvector statt Milvus
| | pgvector (gewaehlt) | Milvus |
|---|---|---|
| Neue Container | keine | etcd, minio, milvus (3) |
| Betriebsaufwand | keiner (nutzt laufenden Postgres) | hoch (3 zusaetzliche Services) |
| Konsistenz | Transaktional mit `documents`/`conversations` | separates System, kein 2PC |
| Skalierung | ausreichend bis mehrere 100k Chunks | besser bei Millionen Vektoren |
Fuer eine Business-Knowledge-Base in diesem Umfang ist pgvector die
pragmatischere Wahl (YAGNI). Milvus bleibt als unbenutzter Platzhalter im
`docker-compose.yml` dokumentiert, falls spaetere Skalierung es noetig macht.
## Embeddings: Ollama (`nomic-embed-text`)
Anthropic bietet keine Embedding-API an. Da Ollama bereits im Stack laeuft,
wird lokal embedded statt ueber einen weiteren externen Anbieter (OpenAI/
Voyage) mit eigenem Key und laufenden Kosten:
- Modell: `nomic-embed-text` (768 Dimensionen), einmalig per
`docker exec jarvis-ollama ollama pull nomic-embed-text` zu laden.
- Aufruf ueber Ollama's `/api/embeddings` HTTP-Endpoint (aiohttp, analog zum
bestehenden Health-Check-Pattern in `main.py`).
## Datenmodell
Neue Tabelle `document_chunks`:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding vector(768) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_document_chunks_document_id ON document_chunks(document_id);
CREATE INDEX idx_document_chunks_embedding ON document_chunks
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
```
Die bestehende `documents`-Tabelle speichert weiterhin Titel/Volltext/
Metadaten; `embedding_id` (bisher ungenutzt) bleibt vorerst unangetastet, da
die Beziehung jetzt ueber `document_chunks.document_id` laeuft.
## Datenfluss
**Upload (`POST /api/v1/documents`):**
1. Dokument-Zeile in `documents` anlegen (title, content, document_type).
2. Text in Chunks teilen: ~1000 Zeichen, 100 Zeichen Overlap (einfaches
Character-Chunking, kein Tokenizer noetig - ausreichend fuer Business-
Dokumente in diesem Umfang).
3. Pro Chunk: Embedding via Ollama holen, Zeile in `document_chunks`
einfuegen.
4. Response: `document_id`, Anzahl Chunks, Status `indexed`.
Passiert synchron im Request (kein Background-Queue-Mechanismus) - bei den
erwarteten Dokumentgroessen (Business-FAQs, Anleitungen) unkritisch fuer die
Latenz. Wird das spaeter zum Problem, ist ein Queue-basierter Ansatz ein
separates Follow-up.
**Suche (`GET /api/v1/documents?query=...`):**
1. Suchanfrage ueber Ollama embedden.
2. `document_chunks` per Cosine-Distanz (`<=>`-Operator) sortieren, Top N
(Parameter `limit`, Default 10).
3. Pro Treffer: Chunk-Inhalt, Distanz-Score, zugehoeriger Dokumenttitel.
## Fehlerbehandlung
- Ollama nicht erreichbar oder Modell nicht gepullt -> `503` mit klarer
Fehlermeldung (analog zum bestehenden `CLAUDE_API_KEY`-Check in `chat()`).
- Leerer/zu kurzer Query-String -> `422` (Pydantic-Validierung).
- Dokument ohne Inhalt -> `422`.
## Testing
- Unit-Test fuer die Chunking-Funktion (Grenzfaelle: leerer Text, Text
kuerzer als Chunk-Groesse, Text mit exaktem Vielfachen der Chunk-Groesse).
- Manueller End-to-End-Test nach Deployment: Dokument hochladen, danach mit
einer thematisch passenden Frage suchen und pruefen, dass der richtige
Chunk als Top-Treffer kommt (analog zum manuellen Chat-Test aus Phase 2,
da auf dem VPS kein automatisierter Test-Runner etabliert ist).
## Nachtrag (gefunden bei der Umsetzung, 2026-09-12)
Der in diesem Dokument spezifizierte `ivfflat`-Index auf `document_chunks.embedding`
hat bei sehr wenig Daten (getestet mit 1 Zeile) eine so geringe Recall-Rate,
dass `ORDER BY ... LIMIT` Anfragen **keine** Treffer zurückgeben (bestätigt:
funktioniert erst mit `SET ivfflat.probes = 10`). Genau das kündigt Postgres
schon beim Anlegen des Index an ("This will cause low recall... Drop the
index until the table has more data").
**Ruling:** Index per Migration `003_drop_low_data_ivfflat_index.sql` wieder
entfernt. Ein Sequential Scan ist bei der aktuellen Datenmenge korrekt und
schnell genug. Sobald `document_chunks` eine relevante Groessenordnung an
Zeilen hat (Richtwert pgvector-Doku: `lists ~= sqrt(row_count)`), sollte der
Index mit einem zur dann aktuellen Zeilenzahl passenden `lists`-Wert neu
angelegt werden - das ist ein eigenes Follow-up, keine sofortige Aufgabe.
## Out of Scope (bewusst nicht Teil dieser Phase)
- RAG-Integration in den Chat-Endpoint (automatisches Anreichern von
Claude-Antworten mit Suchtreffern) - eigenes Follow-up.
- Re-Embedding bei Dokument-Update/-Loeschung ausserhalb von `ON DELETE
CASCADE`.
- Zugriffskontrolle pro Nutzer (kein Auth-System vorhanden, siehe Phase 3
in `JARVIS_HANDOFF.md`).

View File

@ -0,0 +1,172 @@
# JARVIS Weather Widget (Phase 4a) - Design
**Datum:** 2026-09-12
**Status:** Approved, bereit fuer Implementierungsplan
## Kontext
Phase 3b (Web-Frontend mit Chat + Dashboard) ist live. `n8n` laeuft bereits im
Stack seit Phase 2/3, wurde aber noch nie konfiguriert (kein Owner-Account,
keine Workflows). Diese Spec macht n8n zum ersten Mal produktiv nutzbar: ein
Workflow holt periodisch das Wetter fuer Crailsheim (Firmenstandort laut
`JARVIS_HANDOFF.md`) und das Frontend zeigt es oben rechts in der Nav-Leiste
an.
## Datenfluss
```
n8n (Schedule-Trigger, alle 30 Min)
-> HTTP Request: Open-Meteo API (kein API-Key noetig)
-> Code-Node: WMO-Wettercode -> deutscher Text
-> Postgres-Node: INSERT INTO weather_cache
JARVIS-API: GET /api/v1/weather liest die neueste Zeile aus weather_cache
Frontend: WeatherWidget faengt beim Laden + alle 5 Min neu ab
```
## Wetterquelle: Open-Meteo
Kein API-Key noetig (kostenlos, keine Registrierung). Endpoint:
```
https://api.open-meteo.com/v1/forecast?latitude=49.1372&longitude=10.0674&current=temperature_2m,weather_code&timezone=Europe%2FBerlin
```
Antwortformat (Auszug):
```json
{
"current": {
"time": "2026-09-12T16:00",
"temperature_2m": 18.4,
"weather_code": 3
}
}
```
WMO-Wettercode-Mapping (Open-Meteo-Standard, Teilmenge fuer die haeufigsten
Faelle - vollstaendige Tabelle im Code-Node):
| Code | Text |
|------|------|
| 0 | Klarer Himmel |
| 1-2 | Ueberwiegend klar |
| 3 | Bewoelkt |
| 45, 48 | Nebel |
| 51-57 | Nieselregen |
| 61-67 | Regen |
| 71-77 | Schnee |
| 80-82 | Regenschauer |
| 95-99 | Gewitter |
| (sonst) | Unbekannt |
## Datenmodell
Neue Tabelle `weather_cache`:
```sql
CREATE TABLE weather_cache (
id SERIAL PRIMARY KEY,
location VARCHAR(100) NOT NULL,
temperature_c NUMERIC NOT NULL,
condition_code INTEGER NOT NULL,
condition_text VARCHAR(100) NOT NULL,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_weather_cache_fetched_at ON weather_cache(fetched_at);
```
Jeder n8n-Lauf fuegt eine neue Zeile ein (kein Update/Upsert - einfacher,
und die Tabelle bleibt klein bei 30-Minuten-Intervall; alte Zeilen aufraeumen
ist ein spaeteres Follow-up, kein akutes Problem bei diesem Datenvolumen).
## n8n-Workflow: "Wetter Crailsheim"
n8n wurde bisher nie initialisiert (kein Owner-Account, kein API-Key fuer die
n8n-REST-API). Einmaliger Setup-Schritt (per Browser, da n8n das beim ersten
Aufruf erzwingt): Owner-Account anlegen, dann unter Settings -> API einen
API-Key generieren. Danach wird der Workflow per n8n-REST-API (`POST
/rest/workflows` bzw. `/api/v1/workflows` je nach n8n-Version, siehe Plan)
als JSON-Definition angelegt und aktiviert - kein manuelles Zusammenklicken
der Nodes.
Workflow-Nodes:
1. **Schedule Trigger**: Intervall 30 Minuten.
2. **HTTP Request**: GET auf die Open-Meteo-URL oben.
3. **Code** (JavaScript): mapped `weather_code` auf `condition_text` (Tabelle
oben), reicht `temperature_2m`, `weather_code` und `condition_text` weiter.
4. **Postgres**: `INSERT INTO weather_cache (location, temperature_c,
condition_code, condition_text) VALUES ('Crailsheim', ..., ..., ...)`.
n8n's Postgres-Credential zeigt auf dieselbe `jarvis`-Datenbank, die die
API auch nutzt (gleicher Host/User/Passwort wie `DATABASE_URL` der API,
siehe `.env`).
## Backend: `GET /api/v1/weather`
Neuer Endpoint in `main.py`, durch `require_admin_key` geschuetzt (wie alle
anderen `/api/v1/*`-Routen ausser `/health`):
```json
{
"location": "Crailsheim",
"temperature_c": 18.4,
"condition_text": "Bewoelkt",
"fetched_at": "2026-09-12T16:00:03.123456"
}
```
Wenn `weather_cache` leer ist (Workflow lief noch nicht): `503` mit
Fehlermeldung "Weather data not available yet" - kein stiller Platzhalter,
das Frontend zeigt das dann sichtbar als Fehler statt falscher Daten.
## Frontend: `WeatherWidget`
Kleine Komponente, in der Nav-Leiste rechtsbuendig (per CSS `margin-left:
auto` auf dem Widget-Container, bestehende Nav-Buttons bleiben linksbuendig).
Sichtbar in beiden Ansichten (Chat + Dashboard), da die Nav-Leiste immer
gerendert wird.
- Beim Mount: `GET /api/v1/weather` laden.
- Danach alle 5 Minuten neu laden (`setInterval`, aufgeraeumt in
`useEffect`-Cleanup) - haeufiger als noetig waere reine Verschwendung,
seltener als der 30-Minuten-Workflow waere unnoetig zurueckhaltend; 5
Minuten ist ein vernuenftiger Mittelweg fuer eine UI-Anzeige.
- Anzeige: `18.4°C · Bewoelkt`. Bei Fehler (503 o.ae.): unauffaellige
Kurzmeldung statt der Wetteranzeige (kein Popup/Alert), da es sich um ein
Nice-to-have-Widget handelt, keine kritische Funktion.
## Fehlerbehandlung
- Open-Meteo nicht erreichbar: n8n-Workflow-Lauf schlaegt fehl, naechster
Lauf in 30 Minuten versucht es erneut. Kein Retry innerhalb eines Laufs
(YAGNI - Schedule-Trigger uebernimmt das ohnehin).
- `weather_cache` leer: Backend liefert `503`, Frontend zeigt Kurzmeldung.
- n8n/Postgres-Verbindung fehlerhaft: sichtbar im n8n-Execution-Log (manuell
pruefbar in der n8n-UI), keine gesonderte Behandlung noetig fuer dieses
Nice-to-have-Feature.
## Testing
- Backend: Unit-Test fuer `GET /api/v1/weather` - leere Tabelle -> 503,
vorhandene Zeile -> korrektes JSON (mit einer eingefuegten Testzeile via
`TestClient` + einer echten Test-Postgres-Instanz ist hier zu viel Aufwand
fuer dieses kleine Feature; stattdessen wird die Query-Logik direkt
getestet, indem die DB-Helper-Funktion mit einer gemockten `db_query`
aufgerufen wird - siehe Plan fuer die konkrete Test-Strategie).
- Frontend: kein gesonderter Unit-Test (das Widget ist eine einfache
Fetch-und-Anzeige-Komponente, gleiches Muster wie `Dashboard.tsx`, das
auch keinen Unit-Test hat) - Build-Check (`npm run build`) reicht,
passend zum bisherigen Muster in diesem Projekt.
- Manueller End-to-End-Test nach Deployment: n8n-Workflow einmal manuell
ausloesen (n8n-UI "Execute Workflow" oder Warten auf den ersten
Schedule-Lauf), pruefen dass `weather_cache` eine Zeile hat, `GET
/api/v1/weather` testen, Widget im Browser sehen.
## Out of Scope (bewusst nicht Teil dieser Phase)
- Mehrere Standorte / nutzerkonfigurierbarer Standort.
- Wettervorhersage (nur aktuelles Wetter, kein Forecast-Display).
- Aufraeumen alter `weather_cache`-Zeilen (Retention-Policy) - spaeteres
Follow-up, kein Problem bei aktuellem Datenvolumen.
- Wetter-Icons/Grafiken - nur Text.

View File

@ -0,0 +1,139 @@
# JARVIS Web Frontend (Phase 3b) - Design
**Datum:** 2026-09-12
**Status:** Approved, bereit fuer Implementierungsplan
## Kontext
Phase 2 (Claude-Chat) und Phase 3a (Knowledge Base via pgvector) sind live auf
`https://jarvis.mbo-tech-it.de` (API) mit Traefik-Routing ueber den bereits
vorhandenen, geteilten Reverse-Proxy des VPS. `JARVIS_HANDOFF.md` listet
Phase 3 "Frontend" mit vier Punkten (React/Vue Web UI, Authentication UI,
Chat Interface, Dashboard) - das wurde bewusst aufgeteilt: eine
"Authentication UI" braucht ein Auth-System, das serverseitig nicht
existiert (kein Login/JWT-Flow). Diese Spec deckt Chat + Dashboard als eine
gemeinsame Web-App ab; ein vollwertiges Auth-System bleibt ein separates
Follow-up.
## Sicherheitsproblem, das diese Phase mitloest
`/api/v1/chat` hat aktuell keinerlei Zugriffsschutz und CORS steht auf `*`.
Eine oeffentlich erreichbare Chat-UI unter einer bekannten Domain waere ohne
Schutz von jedem nutzbar, der die Domain findet, und wuerde Claude-API-
Guthaben verbrauchen. Diese Spec fuehrt deshalb einen einfachen
Shared-Secret-Schutz ein (kein vollwertiges User-System - das ist bewusst
YAGNI fuer den aktuellen Umfang, siehe Out of Scope).
## Domain-Aufteilung
- `jarvis.mbo-tech-it.de` -> Web-Frontend (neu, dieser Service)
- `api.jarvis.mbo-tech-it.de` -> JARVIS-API (Umzug von der Hauptdomain)
Kein neuer DNS-Eintrag noetig: der Wildcard-Record `*.jarvis.mbo-tech-it.de`
(siehe Traefik-Integration in `JARVIS_HANDOFF.md`) deckt `api.jarvis...`
bereits ab.
## Zugriffsschutz: Shared Secret statt vollwertiges Auth-System
Wiederverwendung des bereits in `.env` vorhandenen `API_KEY_ADMIN` (kein
neues Secret noetig). Ablauf:
1. Neue FastAPI-Dependency `require_admin_key` prueft den Header
`X-Admin-Key` gegen `API_KEY_ADMIN`. Fehlt der Header oder stimmt der Wert
nicht -> `401 Unauthorized`.
2. Angewendet auf: `POST /api/v1/chat`, `GET /api/v1/conversations/{id}`,
`POST/GET /api/v1/documents`, `POST/GET /api/v1/tasks`,
`POST /api/v1/workflows/trigger`, `GET /api/v1/admin/stats`,
`GET /api/v1/admin/health/detailed`.
3. **Nicht** geschuetzt: `GET /health` (wird fuer einfaches Monitoring/
Uptime-Checks offen gehalten - liefert ohnehin keine sensiblen Daten).
4. CORS wird von `allow_origins=["*"]` auf
`allow_origins=["https://jarvis.mbo-tech-it.de"]` eingeschraenkt, da wir
an dieser Stelle ohnehin an der Zugriffskontrolle arbeiten.
Im Frontend: Login-Screen mit einem Passwort-Feld. Eingabe wird nicht
client-seitig validiert, sondern per Testaufruf gegen
`GET /api/v1/admin/stats` mit dem eingegebenen Wert als `X-Admin-Key`
geprueft. Bei `200` wird der Wert in `localStorage` (Key
`jarvis_admin_key`) gespeichert und die Hauptansicht angezeigt. Bei `401`
Fehlermeldung im Login-Screen. Jeder weitere API-Call haengt den
gespeicherten Wert als `X-Admin-Key`-Header an; ein `401` auf irgendeinem
Call loescht den `localStorage`-Eintrag und zeigt wieder den Login-Screen.
## Frontend-Architektur
React + Vite, kein zusaetzliches Routing (nur zwei Views, ein einfacher
State-Switch reicht - YAGNI). Struktur:
```
web/
src/
main.tsx # Einstiegspunkt
App.tsx # Login-Gate + Nav zwischen Chat/Dashboard
api.ts # fetch-Wrapper: haengt X-Admin-Key an, wirft
# bei 401 einen speziellen Fehler, den App.tsx
# abfaengt um zurueck zum Login zu wechseln
components/
Login.tsx # Passwort-Feld, Testaufruf, Fehleranzeige
Chat.tsx # Nachrichtenliste + Eingabefeld
Dashboard.tsx # Stats-Karten + Health-Tabelle
index.html
package.json
vite.config.ts
Dockerfile # Multi-Stage: node:20-slim build -> nginx:alpine serve
nginx.conf # SPA-Fallback (alle Routen -> index.html)
```
`VITE_API_URL` (Build-Zeit-Env-Var) zeigt auf
`https://api.jarvis.mbo-tech-it.de`.
### Chat (`Chat.tsx`)
- Lokaler State: `messages: {role, content}[]`, `conversationId: number | null`.
- Eingabefeld + Senden-Button (auch Enter-Taste). Waehrend eine Antwort
aussteht: Eingabe gesperrt, Ladeindikator.
- Sendet `POST /api/v1/chat` mit `{conversation_id: conversationId, message}`.
Antwort haengt User- und Assistant-Nachricht an `messages` an, setzt
`conversationId` aus der Response.
- Fehler (z.B. 503 wegen fehlendem Claude-Guthaben) werden als Systemzeile
im Chatverlauf angezeigt, nicht stillschweigend verschluckt.
### Dashboard (`Dashboard.tsx`)
- Beim Mount: `GET /api/v1/admin/stats` und
`GET /api/v1/admin/health/detailed` parallel laden.
- Stats als drei Zahlen-Karten (Conversations/Tasks/Documents).
- Health als Tabelle: Service-Name, Status (ok/error als farbiges Badge),
Response-Time. Manueller "Aktualisieren"-Button (kein Auto-Polling - fuer
den aktuellen Umfang unnoetig, YAGNI).
## Fehlerbehandlung
- Netzwerkfehler (API nicht erreichbar): Inline-Fehlermeldung statt
unbehandeltem Absturz, sowohl in Chat als auch Dashboard.
- 401 an beliebiger Stelle: zentral in `api.ts` behandelt (siehe oben),
nicht in jeder Komponente einzeln.
- Leere Eingabe im Chat: Senden-Button deaktiviert, kein Request.
## Testing
- Backend: Unit-Test fuer `require_admin_key` (gueltiger Key -> durchgelassen,
fehlender Key -> 401, falscher Key -> 401) mit FastAPI `TestClient`.
- Frontend: Unit-Test (Vitest) fuer `api.ts` - prueft, dass der
`X-Admin-Key`-Header aus `localStorage` gesetzt wird und dass eine
401-Antwort den `localStorage`-Eintrag entfernt.
- Manueller End-to-End-Test im Browser nach Deployment: Login mit falschem
Passwort (Fehler sichtbar), Login mit richtigem Passwort, eine
Chat-Nachricht senden und Antwort sehen, zum Dashboard wechseln und Stats/
Health sehen (passend zum bisherigen Muster in diesem Projekt - kein
automatisierter Browser-Test-Runner vorhanden).
## Out of Scope (bewusst nicht Teil dieser Phase)
- Vollwertiges User-/Auth-System (Login pro Benutzer, JWT, Rollen) - das ist
weiterhin offener Punkt in Phase 3 von `JARVIS_HANDOFF.md`, unabhaengig von
dieser Spec.
- Tasks/Documents-Verwaltung im Frontend (Erstellen/Anzeigen ueber die UI) -
nur Chat + Dashboard in dieser Phase.
- Auto-Refresh/Live-Updates im Dashboard.
- Mobile-optimiertes Layout ueber einfache Responsivitaet hinaus.

View File

@ -0,0 +1,132 @@
# JARVIS Nextcloud-Kalender-Integration (Phase 4c) - Design
**Datum:** 2026-09-13
**Status:** Approved, bereit fuer Implementierungsplan
## Kontext
Naechste Erweiterung nach dem Wetter-Widget: Zugriff auf einen bestehenden
Nextcloud-Kalender ("FFW-Onza-Alle", ein geteilter Organisations-Kalender der
Freiwilligen Feuerwehr Onza) via CalDAV. JARVIS soll Termine anzeigen, im
Chat abfragbar machen und - nach Bestaetigung im Chat - neue Termine anlegen
koennen.
**Verbindung verifiziert (13.09.2026):**
- CalDAV-URL: `https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/`
- Auth: HTTP Basic mit Nextcloud-App-Passwort (User `jonny`)
- Python-Library `caldav` (+ `icalendar` als Abhaengigkeit) liest und
schreibt erfolgreich gegen diesen Kalender (Testtermin angelegt und wieder
geloescht)
- Kalender-Timezone: Europe/Berlin
- Enthaelt sowohl Termine mit Uhrzeit (`dtstart` als `datetime`) als auch
ganztaegige Termine (`dtstart` als `date` ohne Uhrzeit, z.B. "Volksfestwache")
## Sicherheit: Schreibzugriff nur nach Bestaetigung
Der Kalender ist ein geteilter Organisations-Kalender, kein privater. Direkte,
unbestaetigte Termin-Erstellung durch ein LLM (Halluzinationsrisiko) waere in
einem geteilten Kalender riskanter als in einem privaten. Deshalb:
- Der Chat-System-Prompt weist Claude explizit an, vor jedem Aufruf von
`create_calendar_event` im Klartext nachzufragen ("Soll ich den Termin XY
am [Datum] anlegen?") und das Tool erst aufzurufen, nachdem der Nutzer im
naechsten Chat-Turn zugestimmt hat.
- Das ist Prompt-Steuerung, kein hartes technisches Gate (kein separates
Freigabe-UI) - Standardmuster fuer LLM-Tool-Use mit Human-in-the-loop bei
diesem Umfang. Ein hartes Gate (z.B. ein Bestaetigungs-Endpoint) waere fuer
diese Phase Overengineering (YAGNI) und ist ein moegliches Follow-up, falls
sich in der Praxis zeigt, dass die Prompt-Steuerung nicht ausreicht.
## Architektur
Neues Modul-internes CalDAV-Setup in `main.py` (kein eigenes File noetig bei
diesem Umfang - passt zur bestehenden Ein-Datei-Struktur des Projekts):
```
NEXTCLOUD_CALDAV_URL = os.getenv("NEXTCLOUD_CALDAV_URL")
NEXTCLOUD_USER = os.getenv("NEXTCLOUD_USER")
NEXTCLOUD_APP_PASSWORD = os.getenv("NEXTCLOUD_APP_PASSWORD")
```
Zwei synchrone Helper-Funktionen (die `caldav`-Library ist nicht async-nativ,
daher ueber `asyncio.to_thread` aufgerufen - gleiches Muster wie die
bestehenden Postgres-Helper via `db_query`):
- `list_upcoming_events(days_ahead: int) -> list[dict]` - liest Termine der
naechsten N Tage, sortiert nach Start, gibt `{summary, start, end,
description, all_day}` pro Termin zurueck (Datumswerte als ISO-Strings).
- `create_event(summary: str, start: str, end: str, description: str = "") ->
dict` - legt einen neuen Termin an (ISO-8601-Datums-/Zeitstrings als Input,
vom Aufrufer bzw. von Claude im Tool-Call geliefert), gibt die
gespeicherten Werte zurueck.
## Backend-Endpoint (fuer das Dashboard-Widget)
`GET /api/v1/calendar/events?days=14` (geschuetzt wie alle anderen Routen)
ruft `list_upcoming_events` auf und gibt `{"events": [...]}` zurueck. Bei
Verbindungs-/Auth-Fehler zu Nextcloud: `503` mit Fehlermeldung statt
stillem leerem Array - ein leeres Array waere nicht von "wirklich keine
Termine" unterscheidbar.
## Chat-Integration: Claude Tool Use
`/api/v1/chat` bekommt einen `tools`-Parameter mit zwei Tool-Definitionen:
1. `list_calendar_events` - Parameter `days_ahead` (Ganzzahl). Ruft
`list_upcoming_events` auf.
2. `create_calendar_event` - Parameter `summary`, `start` (ISO-8601),
`end` (ISO-8601), optional `description`. Ruft `create_event` auf.
Ablauf pro Chat-Request: Claude wird mit den Tools aufgerufen. Antwortet
Claude mit einem `tool_use`-Block, fuehrt das Backend die entsprechende
Python-Funktion aus, haengt das Ergebnis als `tool_result` an die
Konversation an und ruft Claude ein zweites Mal auf, um die finale,
natuerlichsprachige Antwort zu bekommen (ein Tool-Call-Zyklus reicht fuer die
hier vorgesehenen Anwendungsfaelle - keine mehrstufige Tool-Verkettung noetig,
YAGNI). Der System-Prompt wird um die Bestaetigungs-Regel (siehe oben)
ergaenzt.
Die vom Tool-Use erzeugten Zwischennachrichten (der `tool_use`- und
`tool_result`-Turn) werden **nicht** in der `messages`-Tabelle gespeichert -
nur die sichtbare finale Nutzer-/Assistant-Nachricht, wie bisher. Das haelt
die Persistenz-Logik unveraendert und die Chat-Historie fuer das Frontend
weiterhin einfach (reine user/assistant-Paare).
## Frontend: "Nächste Termine"-Widget im Dashboard
Neue Sektion in `Dashboard.tsx` (oder eigene Komponente `CalendarWidget`,
analog zu `WeatherWidget`): laedt `GET /api/v1/calendar/events?days=14` und
zeigt eine einfache Liste (Datum + Uhrzeit falls vorhanden + Titel). Kein
eigenes Erstellen/Bearbeiten im Dashboard (das laeuft ueber den Chat) - YAGNI
fuer diese Phase.
## Fehlerbehandlung
- Nextcloud nicht erreichbar/Auth ungueltig: `list_upcoming_events`/
`create_event` werfen eine Exception mit klarer Meldung. Im
Dashboard-Endpoint wird daraus ein `503`. Im Chat-Tool-Use-Pfad wird die
Fehlermeldung als `tool_result` mit `is_error: true` an Claude
zurueckgegeben, das sie dann verstaendlich im Chat formuliert (kein roher
Stacktrace fuer den Nutzer).
- Ganztaegige Termine (kein `dtstart`-Zeitanteil): werden mit `all_day: true`
markiert, Frontend zeigt dafuer nur das Datum ohne Uhrzeit.
## Testing
- Backend: Unit-Tests fuer `list_upcoming_events` und `create_event` mit
gemocktem `caldav.DAVClient` (keine echten Netzwerkaufrufe im Test).
- Manueller End-to-End-Test nach Deployment: Dashboard-Widget zeigt echte
Termine (bereits verifiziert: 11 vorhandene Termine im Kalender), im Chat
nach "was steht diese Woche an?" fragen, dann testweise einen Termin
anlegen lassen inkl. Bestaetigungs-Nachfrage - danach den Test-Termin
wieder aus dem echten Kalender loeschen (manuell oder per Skript), damit
der Organisations-Kalender sauber bleibt.
## Out of Scope (bewusst nicht Teil dieser Phase)
- Bearbeiten/Loeschen bestehender Termine ueber Chat oder Dashboard.
- Mehrere Kalender/Kalenderauswahl (nur der eine angegebene Kalender).
- Wiederkehrende Termine anlegen (nur einmalige Termine ueber
`create_calendar_event`).
- Hartes technisches Freigabe-Gate fuer Termin-Erstellung (siehe
"Sicherheit" oben) - Prompt-Steuerung reicht fuer den Start.

View File

@ -0,0 +1,103 @@
# JARVIS E-Mail-Integration (Phase 4f) - Design
**Datum:** 2026-09-13
**Status:** Approved, bereit fuer Implementierungsplan
## Kontext
Naechste Integration nach Wetter (n8n-Cache) und Kalender (direktes CalDAV
Tool-Use): Zugriff auf das Postfach `kontakt@mbo-tech-it.de`. JARVIS soll
E-Mails lesen/zusammenfassen koennen (Chat, live) und - nach Bestaetigung -
E-Mails versenden koennen, plus ein Dashboard-Widget fuer neu eingegangene
Mails (n8n-Cache, wie beim Wetter).
**Zugangsdaten verifiziert (13.09.2026):**
- IMAP: `mx2f35.netcup.net:143`, STARTTLS, User `kontakt@mbo-tech-it.de`
(Login + INBOX-Zugriff erfolgreich getestet, 14 vorhandene Nachrichten)
- SMTP: `mx2f35.netcup.net:465`, implizites SSL/TLS, gleiche Zugangsdaten
(Login erfolgreich getestet, kein Test-Versand)
- Passwort ist woertlich `E%21gq10i` (kein URL-encodetes `!` - beide
Interpretationen getestet, nur die woertliche Variante funktioniert)
## Sicherheit: Versand nur nach Bestaetigung
`kontakt@mbo-tech-it.de` ist die offizielle Firmenadresse. Nach aussen
versendete E-Mails sind schwerer zu widerrufen als ein interner
Kalendereintrag. Deshalb - analog zum Kalender-Loeschen -: der System-Prompt
weist Claude an, vor jedem `send_email`-Aufruf Empfaenger/Betreff/Text im
Chat zur Bestaetigung vorzulegen und das Tool erst nach Zustimmung
aufzurufen.
## Architektur: zwei Wege, wie beim Kalender/Wetter kombiniert
**Chat (live, wie beim Kalender):** Zwei neue Claude-Tools in
`run_chat_completion`:
- `list_recent_emails(limit)` - liest live per IMAP (`imaplib`, Python-
Standardbibliothek, keine neue Abhaengigkeit) die letzten N Nachrichten
aus INBOX (nur Header: From/Subject/Date, `BODY.PEEK[...]` damit der
ungelesen-Status nicht veraendert wird), gibt `{from, subject, date,
unread}` pro Mail zurueck. Betreffzeilen werden mit
`email.header.decode_header` dekodiert (RFC 2047, z.B. `=?utf-8?B?...?=`).
- `send_email(to, subject, body)` - versendet per SMTP (`smtplib`,
Standardbibliothek) ueber Port 465 (implizites TLS). Nur nach
Chat-Bestaetigung aufzurufen (siehe oben).
**Dashboard-Widget (gecacht, wie beim Wetter):** Neuer n8n-Workflow mit dem
eingebauten Trigger-Node `Email Trigger (IMAP)`
(`n8n-nodes-base.emailReadImap`, verifiziert vorhanden in dieser
n8n-Version), der selbststaendig neue Nachrichten erkennt (kein eigener
Schedule-Trigger noetig, das uebernimmt der Node):
- `postProcessAction: "nothing"` - markiert Mails NICHT als gelesen, damit
das automatische Cachen den tatsaechlichen Lese-Status im Postfach nicht
verfaelscht
- `format: "simple"` reicht (nur Header/Text, keine Anhaenge noetig)
- Schreibt pro neuer Mail eine Zeile in eine neue Tabelle `email_cache`
(sender, subject, received_at)
`GET /api/v1/emails` (geschuetzt wie alle anderen Routen) liest die
neuesten Zeilen daraus fuer das Dashboard-Widget "Neue E-Mails".
## Datenmodell
```sql
CREATE TABLE email_cache (
id SERIAL PRIMARY KEY,
sender VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
received_at TIMESTAMP NOT NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_email_cache_received_at ON email_cache(received_at);
```
Nur Metadaten (Absender/Betreff/Datum) werden dauerhaft gespeichert - keine
E-Mail-Inhalte/Volltexte, um die Menge an gespeicherter Geschaeftskorrespondenz
gering zu halten (die Chat-Tools lesen bei Bedarf live, ohne Zwischenspeicherung).
## Fehlerbehandlung
- IMAP/SMTP nicht erreichbar oder Login fehlgeschlagen: Chat-Tools werfen
eine Exception mit klarer Meldung, die als `tool_result` mit
`is_error: true` an Claude zurueckgeht (gleiches Muster wie beim Kalender).
- `GET /api/v1/emails` bei leerem Cache: gibt ein leeres Array zurueck (kein
503) - anders als beim Wetter ist "noch keine neue Mail seit Start des
Workflows" ein normaler, kein Fehlerzustand.
## Testing
- Backend: Unit-Tests fuer die IMAP-Parsing-Funktion (RFC-2047-Betreffs
dekodieren, unread-Flag aus FLAGS-Antwort ableiten) und fuer den
SMTP-Sende-Aufruf, jeweils mit gemocktem `imaplib`/`smtplib`.
- Manueller End-to-End-Test nach Deployment: Chat nach "was ist neu im
Postfach?" fragen (echte IMAP-Daten), eine Test-Mail an die eigene
Adresse per Chat versenden lassen (inkl. Bestaetigungs-Dialog), n8n-
Workflow aktivieren und pruefen, dass die Test-Mail in `email_cache`
auftaucht, Dashboard-Widget ansehen.
## Out of Scope (bewusst nicht Teil dieser Phase)
- Anhaenge (weder lesen noch versenden).
- Antworten/Weiterleiten als eigene Aktion (nur neues `send_email`).
- Loeschen/Verschieben von E-Mails.
- Mehrere Postfaecher/Ordner (nur INBOX von kontakt@mbo-tech-it.de).

2
web/.dockerignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

11
web/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JARVIS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
web/nginx.conf Normal file
View File

@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# index.html references content-hashed asset filenames - if it's cached,
# deploys silently keep serving the old JS bundle (seen 13.09.2026).
location = /index.html {
add_header Cache-Control "no-cache";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}

2929
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
web/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "jarvis-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"jsdom": "^25.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}

70
web/src/App.tsx Normal file
View File

@ -0,0 +1,70 @@
import { useState, useEffect } from "react";
import { apiFetch, getStoredKey, onUnauthorized, logout } from "./api";
import { getSpeechMuted, setSpeechMuted } from "./speech";
import Login from "./components/Login";
import Chat from "./components/Chat";
import Dashboard from "./components/Dashboard";
import Settings from "./components/Settings";
import WeatherWidget from "./components/WeatherWidget";
type View = "chat" | "dashboard" | "settings";
export default function App() {
const [authenticated, setAuthenticated] = useState<boolean>(() => getStoredKey() !== null);
const [view, setView] = useState<View>("chat");
const [muted, setMuted] = useState<boolean>(() => getSpeechMuted());
const [assistantName, setAssistantName] = useState<string>("JARVIS");
useEffect(() => onUnauthorized(() => setAuthenticated(false)), []);
useEffect(() => {
if (!authenticated) return;
async function loadAssistantName() {
try {
const response = await apiFetch("/api/v1/settings");
if (!response.ok) return;
const data = await response.json();
setAssistantName(data.assistant_name);
document.title = data.assistant_name;
} catch {
// Default "JARVIS" bleibt bei Fehlern erhalten.
}
}
loadAssistantName();
}, [authenticated]);
function toggleMuted() {
const next = !muted;
setSpeechMuted(next);
setMuted(next);
}
if (!authenticated) {
return <Login onSuccess={() => setAuthenticated(true)} />;
}
return (
<div className="app">
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => setView("settings")} disabled={view === "settings"}>Einstellungen</button>
<button onClick={toggleMuted} title={muted ? "Sprachausgabe einschalten" : "Sprachausgabe stummschalten"}>
{muted ? "🔇" : "🔊"}
</button>
<button onClick={() => logout()}>Logout</button>
<WeatherWidget />
</nav>
{view === "chat" && <Chat assistantName={assistantName} />}
{view === "dashboard" && <Dashboard />}
{view === "settings" && (
<Settings
onSaved={(settings) => {
setAssistantName(settings.assistant_name);
document.title = settings.assistant_name;
}}
/>
)}
</div>
);
}

30
web/src/api.test.ts Normal file
View File

@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { apiFetch, storeKey, getStoredKey, UnauthorizedError } from "./api";
describe("apiFetch", () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it("attaches the stored admin key as X-Admin-Key header", async () => {
storeKey("secret123");
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await apiFetch("/api/v1/admin/stats");
const [, options] = fetchMock.mock.calls[0];
const headers = options.headers as Headers;
expect(headers.get("X-Admin-Key")).toBe("secret123");
});
it("clears the stored key and throws UnauthorizedError on 401", async () => {
storeKey("wrong-key");
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 401 }));
vi.stubGlobal("fetch", fetchMock);
await expect(apiFetch("/api/v1/admin/stats")).rejects.toBeInstanceOf(UnauthorizedError);
expect(getStoredKey()).toBeNull();
});
});

49
web/src/api.ts Normal file
View File

@ -0,0 +1,49 @@
const STORAGE_KEY = "jarvis_admin_key";
const UNAUTHORIZED_EVENT = "jarvis:unauthorized";
export class UnauthorizedError extends Error {}
function getApiBase(): string {
return import.meta.env.VITE_API_URL as string;
}
export function getStoredKey(): string | null {
return localStorage.getItem(STORAGE_KEY);
}
export function storeKey(key: string): void {
localStorage.setItem(STORAGE_KEY, key);
}
export function clearKey(): void {
localStorage.removeItem(STORAGE_KEY);
}
export function logout(): void {
clearKey();
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
}
export function onUnauthorized(handler: () => void): () => void {
window.addEventListener(UNAUTHORIZED_EVENT, handler);
return () => window.removeEventListener(UNAUTHORIZED_EVENT, handler);
}
export async function apiFetch(
path: string,
options: RequestInit = {},
keyOverride?: string
): Promise<Response> {
const key = keyOverride ?? getStoredKey();
const headers = new Headers(options.headers);
if (key) {
headers.set("X-Admin-Key", key);
}
const response = await fetch(`${getApiBase()}${path}`, { ...options, headers });
if (response.status === 401) {
clearKey();
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
throw new UnauthorizedError("Invalid or missing admin key");
}
return response;
}

View File

@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type CalendarEvent = { summary: string; start: string; all_day: boolean };
export default function CalendarWidget() {
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/calendar/events?days=14");
if (!response.ok) {
setError("Kalender nicht verfuegbar");
return;
}
const data = await response.json();
setEvents(data.events);
} catch {
setError("Kalender nicht verfuegbar");
}
}
load();
}, []);
function formatEvent(e: CalendarEvent): string {
const date = new Date(e.start);
const dateStr = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
if (e.all_day) return `${dateStr}: ${e.summary}`;
const timeStr = date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
return `${dateStr} ${timeStr}: ${e.summary}`;
}
return (
<div className="calendar-widget">
<h3>Nächste Termine</h3>
{error && <p className="error">{error}</p>}
{events && events.length === 0 && <p>Keine Termine in den nächsten 14 Tagen.</p>}
{events && events.length > 0 && (
<ul>
{events.map((e, i) => (
<li key={i}>{formatEvent(e)}</li>
))}
</ul>
)}
</div>
);
}

184
web/src/components/Chat.tsx Normal file
View File

@ -0,0 +1,184 @@
import { useState, useEffect, KeyboardEvent } from "react";
import { apiFetch } from "../api";
import { isSpeechRecognitionSupported, startListening, stopListening, speak } from "../speech";
function describeSpeechError(error: string): string {
switch (error) {
case "not-allowed":
case "service-not-allowed":
return "Mikrofon-Zugriff wurde blockiert. Bitte im Browser (Schloss-Symbol in der Adressleiste) fuer diese Seite erlauben.";
case "no-speech":
return "Es wurde nichts gehoert. Bitte erneut versuchen.";
case "audio-capture":
return "Kein Mikrofon gefunden.";
case "network":
return "Netzwerkfehler bei der Spracherkennung.";
case "not-supported":
return "Dieser Browser unterstuetzt keine Spracheingabe.";
default:
return `Spracherkennung fehlgeschlagen (${error}).`;
}
}
type Message = { role: "user" | "assistant" | "system"; content: string };
const CONVERSATION_STORAGE_KEY = "jarvis_conversation_id";
function getStoredConversationId(): number | null {
const raw = localStorage.getItem(CONVERSATION_STORAGE_KEY);
return raw ? Number(raw) : null;
}
function storeConversationId(id: number): void {
localStorage.setItem(CONVERSATION_STORAGE_KEY, String(id));
}
function clearStoredConversationId(): void {
localStorage.removeItem(CONVERSATION_STORAGE_KEY);
}
type ChatProps = { assistantName: string };
export default function Chat({ assistantName }: ChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [conversationId, setConversationId] = useState<number | null>(null);
const [sending, setSending] = useState(false);
const [listening, setListening] = useState(false);
useEffect(() => {
const storedId = getStoredConversationId();
if (storedId === null) return;
async function restore() {
try {
const response = await apiFetch(`/api/v1/conversations/${storedId}`);
if (!response.ok) {
clearStoredConversationId();
return;
}
const data = await response.json();
setConversationId(data.conversation_id);
setMessages(
data.messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
}))
);
} catch {
// Server unreachable on load - leave the chat empty, storedId stays
// for the next successful load rather than being discarded here.
}
}
restore();
}, []);
async function sendMessage() {
const text = input.trim();
if (!text || sending) return;
setMessages((prev) => [...prev, { role: "user", content: text }]);
setInput("");
setSending(true);
try {
const response = await apiFetch("/api/v1/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conversation_id: conversationId, message: text }),
});
const data = await response.json();
if (!response.ok) {
setMessages((prev) => [
...prev,
{ role: "system", content: `Fehler: ${data.detail ?? response.statusText}` },
]);
return;
}
setConversationId(data.conversation_id);
storeConversationId(data.conversation_id);
setMessages((prev) => [...prev, { role: "assistant", content: data.response }]);
speak(data.response);
} catch {
setMessages((prev) => [...prev, { role: "system", content: "Server nicht erreichbar." }]);
} finally {
setSending(false);
}
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") sendMessage();
}
function startNewChat() {
if (listening) {
stopListening();
setListening(false);
}
window.speechSynthesis?.cancel();
clearStoredConversationId();
setConversationId(null);
setMessages([]);
setInput("");
}
function toggleListening() {
if (listening) {
stopListening();
setListening(false);
return;
}
setListening(true);
startListening(
(transcript) => setInput(transcript),
() => setListening(false),
(error) => {
setListening(false);
setMessages((prev) => [...prev, { role: "system", content: describeSpeechError(error) }]);
}
);
}
return (
<div className="chat">
<div className="chat-header">
<button
onClick={startNewChat}
disabled={sending || (conversationId === null && messages.length === 0)}
className="new-chat-button"
title="Neue Unterhaltung starten"
>
🆕 Neuer Chat
</button>
</div>
<div className="messages">
{messages.map((m, i) => (
<div key={i} className={`message ${m.role}`}>
{m.content}
</div>
))}
</div>
<div className="input-row">
{isSpeechRecognitionSupported() && (
<button
onClick={toggleListening}
disabled={sending}
className={listening ? "mic-button listening" : "mic-button"}
title={listening ? "Aufnahme stoppen" : "Spracheingabe starten"}
>
🎤
</button>
)}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Nachricht an ${assistantName}...`}
disabled={sending}
/>
<button onClick={sendMessage} disabled={sending || input.trim().length === 0}>
Senden
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type CachedEmail = { sender: string; subject: string; received_at: string };
export default function EmailWidget() {
const [emails, setEmails] = useState<CachedEmail[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/emails?limit=5");
if (!response.ok) {
setError("Postfach-Cache nicht verfuegbar");
return;
}
const data = await response.json();
setEmails(data.emails);
} catch {
setError("Postfach-Cache nicht verfuegbar");
}
}
load();
}, []);
function formatEmail(e: CachedEmail): string {
const date = new Date(e.received_at);
const dateStr = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
return `${dateStr}: ${e.sender} ${e.subject}`;
}
return (
<div className="email-widget">
<h3>Neue E-Mails</h3>
{error && <p className="error">{error}</p>}
{emails && emails.length === 0 && <p>Noch keine gecachten E-Mails.</p>}
{emails && emails.length > 0 && (
<ul>
{emails.map((e, i) => (
<li key={i}>{formatEmail(e)}</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
import { useState, FormEvent } from "react";
import { apiFetch, storeKey, UnauthorizedError } from "../api";
export default function Login({ onSuccess }: { onSuccess: () => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setChecking(true);
try {
const response = await apiFetch("/api/v1/admin/stats", {}, password);
if (response.ok) {
storeKey(password);
onSuccess();
} else {
setError("Login fehlgeschlagen.");
}
} catch (err) {
if (err instanceof UnauthorizedError) {
setError("Falsches Passwort.");
} else {
setError("Server nicht erreichbar.");
}
} finally {
setChecking(false);
}
}
return (
<form onSubmit={handleSubmit} className="login">
<h1>JARVIS</h1>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort"
autoFocus
/>
<button type="submit" disabled={checking || password.length === 0}>
{checking ? "Pruefe..." : "Anmelden"}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}

View File

@ -0,0 +1,100 @@
// web/src/components/Settings.tsx
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type SettingsData = { assistant_name: string; company_name: string; contact_email: string };
type SettingsProps = { onSaved?: (settings: SettingsData) => void };
export default function Settings({ onSaved }: SettingsProps) {
const [settings, setSettings] = useState<SettingsData | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
async function load() {
try {
const response = await apiFetch("/api/v1/settings");
if (!response.ok) {
setError("Einstellungen nicht verfuegbar");
return;
}
setSettings(await response.json());
} catch {
setError("Einstellungen nicht verfuegbar");
}
}
load();
}, []);
function updateField(field: keyof SettingsData, value: string) {
setSettings((prev) => (prev ? { ...prev, [field]: value } : prev));
setSaved(false);
}
async function save() {
if (!settings) return;
setSaving(true);
setError(null);
try {
const response = await apiFetch("/api/v1/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!response.ok) {
setError("Speichern fehlgeschlagen");
return;
}
const updated = await response.json();
setSettings(updated);
onSaved?.(updated);
setSaved(true);
} catch {
setError("Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
}
if (!settings) {
return (
<div className="settings">
{error ? <p className="error">{error}</p> : <p>Lade Einstellungen...</p>}
</div>
);
}
return (
<div className="settings">
<h2>Einstellungen</h2>
{error && <p className="error">{error}</p>}
<label>
Name des Assistenten
<input
value={settings.assistant_name}
onChange={(e) => updateField("assistant_name", e.target.value)}
/>
</label>
<label>
Firmenname
<input
value={settings.company_name}
onChange={(e) => updateField("company_name", e.target.value)}
/>
</label>
<label>
Kontakt-E-Mail
<input
value={settings.contact_email}
onChange={(e) => updateField("contact_email", e.target.value)}
/>
</label>
<button onClick={save} disabled={saving}>
{saving ? "Speichert..." : "Speichern"}
</button>
{saved && <p>Gespeichert.</p>}
</div>
);
}

View File

@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type Weather = { location: string; temperature_c: number; condition_text: string };
const REFRESH_MS = 5 * 60 * 1000;
export default function WeatherWidget() {
const [weather, setWeather] = useState<Weather | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const response = await apiFetch("/api/v1/weather");
if (!response.ok) {
if (!cancelled) setFailed(true);
return;
}
const data = await response.json();
if (!cancelled) {
setWeather(data);
setFailed(false);
}
} catch {
if (!cancelled) setFailed(true);
}
}
load();
const interval = setInterval(load, REFRESH_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
if (failed) {
return <span className="weather-widget">Wetter nicht verfuegbar</span>;
}
if (!weather) {
return null;
}
return (
<span className="weather-widget">
{`${weather.location}: ${weather.temperature_c.toFixed(1)}°C · ${weather.condition_text}`}
</span>
);
}

View File

@ -27,3 +27,6 @@ body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color
.badge-ok { color: #4ade80; } .badge-ok { color: #4ade80; }
.badge-error { color: #f87171; } .badge-error { color: #f87171; }
.orders-widget { margin-top: 1rem; } .orders-widget { margin-top: 1rem; }
.settings { padding: 1rem; max-width: 400px; }
.settings label { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 1rem; }
.settings input { padding: 0.5rem; }

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

125
web/src/speech.ts Normal file
View File

@ -0,0 +1,125 @@
const MUTE_STORAGE_KEY = "jarvis_speech_muted";
type RecognitionResultHandler = (transcript: string, isFinal: boolean) => void;
function getRecognitionCtor(): any {
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
}
export function isSpeechRecognitionSupported(): boolean {
return !!getRecognitionCtor();
}
export function isSpeechSynthesisSupported(): boolean {
return "speechSynthesis" in window;
}
let recognition: any = null;
let stoppedManually = false;
export function startListening(
onResult: RecognitionResultHandler,
onEnd: () => void,
onError?: (error: string) => void
): void {
const RecognitionCtor = getRecognitionCtor();
if (!RecognitionCtor) {
onError?.("not-supported");
return;
}
stoppedManually = false;
// Carries finalized text across Chrome's internal session restarts below -
// each restart gets a fresh recognition instance with empty results, so
// without this a thinking pause would wipe everything said before it.
let accumulated = "";
function createAndStart() {
recognition = new RecognitionCtor();
recognition.lang = "de-DE";
recognition.continuous = true;
recognition.interimResults = true;
let latestFinal = "";
let hadFatalError = false;
recognition.onresult = (event: any) => {
let interim = "";
let sessionFinal = "";
for (let i = 0; i < event.results.length; i++) {
const text = event.results[i][0].transcript;
if (event.results[i].isFinal) {
sessionFinal += text;
} else {
interim += text;
}
}
latestFinal = sessionFinal;
onResult((accumulated + sessionFinal + interim).trim(), false);
};
recognition.onerror = (event: any) => {
// Chrome ends each internal session with a "no-speech" error even
// while continuous=true - not a real failure, onend below restarts it.
if (event.error === "no-speech") return;
hadFatalError = true;
onError?.(event.error);
};
recognition.onend = () => {
accumulated = (accumulated + latestFinal).trim() + " ";
if (stoppedManually || hadFatalError) {
onEnd();
} else {
createAndStart();
}
};
recognition.start();
}
createAndStart();
}
export function stopListening(): void {
stoppedManually = true;
recognition?.stop();
}
export function getSpeechMuted(): boolean {
return localStorage.getItem(MUTE_STORAGE_KEY) === "true";
}
export function setSpeechMuted(muted: boolean): void {
localStorage.setItem(MUTE_STORAGE_KEY, String(muted));
if (muted && isSpeechSynthesisSupported()) {
window.speechSynthesis.cancel();
}
}
function cleanForSpeech(text: string): string {
return text
.replace(/\p{Extended_Pictographic}/gu, "")
.replace(/[\u{FE0F}\u{200D}\u{20E3}]/gu, "")
// Decorative separator runs Claude sometimes uses for "tables" -
// dashes, box-drawing, underscores etc. repeated 2+ times anywhere,
// not just full lines (a markdown table row |---|---| still has these
// after the pipe becomes a comma below otherwise).
.replace(/[-_=─━═╌╍┄┅┈┉•*~^]{2,}/g, " ")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/\*(.*?)\*/g, "$1")
.replace(/`(.*?)`/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^[-•]\s+/gm, "")
.replace(/\|/g, ",")
.replace(/,(\s*,)+/g, ",")
.replace(/\s+([,.;:])/g, "$1")
.replace(/\s{2,}/g, " ")
.trim();
}
export function speak(text: string): void {
if (!isSpeechSynthesisSupported() || getSpeechMuted()) return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(cleanForSpeech(text));
utterance.lang = "de-DE";
window.speechSynthesis.speak(utterance);
}

1
web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

18
web/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vitest/globals"]
},
"include": ["src"]
}

10
web/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
},
});