674 lines
26 KiB
Markdown
674 lines
26 KiB
Markdown
# 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)
|