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