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
This commit is contained in:
parent
b8695fecd6
commit
8a6080bd7a
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"C:\\Users\\Jonny\\.superpowers"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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*
|
||||
|
|
@ -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!**
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
asyncio_mode = auto
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pytest==8.3.3
|
||||
httpx==0.27.2
|
||||
pytest-asyncio==0.24.0
|
||||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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
|
||||
|
|
@ -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]]
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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¤t=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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
dist
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue