35 KiB
Chatuebergreifendes Gedaechtnis 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: JARVIS soll sich Dinge chatuebergreifend merken - explizite Fakten ("merke dir...") und automatische Zusammenfassungen frueherer Conversations, die per Aehnlichkeitssuche in neue Chats einfliessen.
Architecture: Zwei neue Postgres-Tabellen (memory_facts, conversation_summaries, letztere mit pgvector-Embedding wie die bestehende Knowledge-Base). Zwei neue Claude-Tools (remember_fact, forget_fact) analog zu den bestehenden Kalender/E-Mail-Tools. Nach jedem Chat-Turn laeuft im Hintergrund ein Claude-Call, der die Conversation zusammenfasst und embedded. Vor jedem Chat-Turn wird der System-Prompt um alle gespeicherten Fakten (vollstaendig) und die Top-3 aehnlichsten frueheren Zusammenfassungen (per Vector-Suche) ergaenzt.
Tech Stack: Python 3.11, FastAPI, psycopg2 (Postgres), Anthropic SDK (claude_client.messages.create), aiohttp (Ollama-Embeddings), pytest + pytest-asyncio + unittest.mock. Alles in der bestehenden einzigen Backend-Datei Claude outputs/main.py (etabliertes Pattern dieses Projekts - kein Split in mehrere Module).
Spec: docs/superpowers/specs/2026-09-13-cross-chat-memory-design.md
Global Constraints
- Kein
ivfflat/hnsw-Index aufconversation_summaries.embeddingin dieser Phase (Sequential Scan reicht bei der erwarteten Datenmenge - gleiche Begruendung wie beidocument_chunks, Phase 3a). remember_factwird ohne Rueckfrage aufgerufen,forget_facterst nach expliziter Bestaetigung im Chat (gleiche Konvention wiecreate_calendar_eventvs.delete_calendar_event).- Der Hintergrund-Task fuer die Zusammenfassung darf die Chat-Antwort an den Nutzer niemals verzoegern oder durch eine Exception beeintraechtigen - jeder Fehler wird geloggt, nie weitergeworfen.
- Es gibt weiterhin nur einen Nutzer (
DEFAULT_USER_ID) - kein Multi-User-Scoping fuermemory_factsnoetig. - Alles landet in
Claude outputs/main.py; Tests inClaude outputs/tests/; neue Migration inClaude outputs/migrations/006_memory.sql(dort liegen bereits 002-005, nicht im JARVIS-Root). - Tests laufen lokal nicht per
pytestdirekt (keinpsycopg2-binary-Wheel fuer Python 3.14 lokal) - Ausfuehrung ueber den Docker-Container-Weg ausJARVIS_HANDOFF.md("Backend-Tests lokal ausfuehren").
Task 1: Migration 006_memory.sql
Files:
- Create:
Claude outputs/migrations/006_memory.sql
Interfaces:
-
Produces: Tabellen
memory_facts (id, user_id, content, created_at)undconversation_summaries (conversation_id PK, summary, embedding vector(768), updated_at), auf die alle folgenden Tasks per SQL zugreifen. -
Step 1: Migration schreiben
CREATE TABLE memory_facts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE conversation_summaries (
conversation_id INTEGER PRIMARY KEY REFERENCES conversations(id),
summary TEXT NOT NULL,
embedding vector(768) NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
- Step 2: Kein automatisierter Test moeglich
Reines SQL-DDL ohne lokale Postgres-Instanz (siehe Global Constraints) -
kann nicht per pytest verifiziert werden. Die tatsaechliche Anwendung und
Verifikation (\d memory_facts, \d conversation_summaries) passiert in
Task 8 gegen die echte VPS-Datenbank.
- Step 3: Commit
git add "Claude outputs/migrations/006_memory.sql"
git commit -m "feat: add memory_facts and conversation_summaries tables"
Task 2: memory_facts DB-Helper
Files:
- Modify:
Claude outputs/main.py(neue Funktionen direkt unterget_latest_emails, vor_caldav_calendar) - Test:
Claude outputs/tests/test_memory.py(neu)
Interfaces:
-
Consumes:
db_query(query, params, fetch)(bestehend,main.py:143) -
Produces:
insert_memory_fact(user_id: int, content: str) -> int,search_memory_facts(query: str) -> list[dict](Zeilen mitid,content),delete_memory_fact(fact_id: int) -> None,get_all_memory_facts() -> list[dict](Zeilen mitid,content) - werden von Task 4 (Tools) und Task 5 (Prompt-Context) konsumiert. -
Step 1: Failing tests schreiben
# Claude outputs/tests/test_memory.py
import os
import sys
from unittest.mock import AsyncMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import main
@pytest.mark.asyncio
async def test_insert_memory_fact_returns_new_id():
with patch.object(main, "db_query", new=AsyncMock(return_value={"id": 42})) as mock_query:
fact_id = await main.insert_memory_fact(1, "Mag lieber Tee als Kaffee")
assert fact_id == 42
args, kwargs = mock_query.call_args
assert "INSERT INTO memory_facts" in args[0]
assert args[1] == (1, "Mag lieber Tee als Kaffee")
@pytest.mark.asyncio
async def test_search_memory_facts_uses_ilike():
rows = [{"id": 1, "content": "Hund heisst Bruno"}]
with patch.object(main, "db_query", new=AsyncMock(return_value=rows)) as mock_query:
result = await main.search_memory_facts("Bruno")
assert result == rows
args, kwargs = mock_query.call_args
assert "ILIKE" in args[0]
assert args[1] == ("%Bruno%",)
@pytest.mark.asyncio
async def test_delete_memory_fact_deletes_by_id():
with patch.object(main, "db_query", new=AsyncMock(return_value=None)) as mock_query:
await main.delete_memory_fact(7)
args, kwargs = mock_query.call_args
assert "DELETE FROM memory_facts" in args[0]
assert args[1] == (7,)
@pytest.mark.asyncio
async def test_get_all_memory_facts_returns_rows():
rows = [{"id": 1, "content": "A"}, {"id": 2, "content": "B"}]
with patch.object(main, "db_query", new=AsyncMock(return_value=rows)):
result = await main.get_all_memory_facts()
assert result == rows
- Step 2: Tests laufen lassen, Fehlschlag pruefen
Run: python -m pytest tests/test_memory.py -v (im Docker-Testcontainer,
siehe JARVIS_HANDOFF.md)
Expected: FAIL mit AttributeError: module 'main' has no attribute 'insert_memory_fact' (und analog fuer die anderen drei Funktionen)
- Step 3: Implementierung
In main.py, direkt nach get_latest_emails:
async def insert_memory_fact(user_id: int, content: str) -> int:
row = await db_query(
"INSERT INTO memory_facts (user_id, content) VALUES (%s, %s) RETURNING id",
(user_id, content),
fetch="one",
)
return row["id"]
async def search_memory_facts(query: str) -> list:
return await db_query(
"SELECT id, content FROM memory_facts WHERE content ILIKE %s ORDER BY created_at ASC",
(f"%{query}%",),
fetch="all",
)
async def delete_memory_fact(fact_id: int):
await db_query("DELETE FROM memory_facts WHERE id = %s", (fact_id,))
async def get_all_memory_facts() -> list:
return await db_query(
"SELECT id, content FROM memory_facts ORDER BY created_at ASC",
fetch="all",
)
- Step 4: Tests laufen lassen, Erfolg pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 4 PASS
- Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: add memory_facts DB helpers"
Task 3: conversation_summaries DB-Helper
Files:
- Modify:
Claude outputs/main.py(neue Funktionen direkt nach den in Task 2 hinzugefuegten) - Test:
Claude outputs/tests/test_memory.py(erweitern)
Interfaces:
-
Consumes:
db_query(bestehend),_vector_literal(embedding: list) -> str(bestehend,main.py:220) -
Produces:
upsert_conversation_summary(conversation_id: int, summary: str, embedding: list) -> None,search_similar_conversation_summaries(query_embedding: list, exclude_conversation_id: int, limit: int = 3) -> list[dict](Zeilen mitconversation_id,summary,distance) - werden von Task 5 (Retrieval) und Task 7 (Hintergrund-Summary) konsumiert. -
Step 1: Failing tests schreiben
An Claude outputs/tests/test_memory.py anhaengen:
@pytest.mark.asyncio
async def test_upsert_conversation_summary_inserts_with_vector_literal():
with patch.object(main, "db_query", new=AsyncMock(return_value=None)) as mock_query:
await main.upsert_conversation_summary(5, "Kurze Zusammenfassung", [0.1, 0.2])
args, kwargs = mock_query.call_args
assert "INSERT INTO conversation_summaries" in args[0]
assert "ON CONFLICT (conversation_id) DO UPDATE" in args[0]
assert args[1] == (5, "Kurze Zusammenfassung", "[0.1,0.2]")
@pytest.mark.asyncio
async def test_search_similar_conversation_summaries_excludes_current_conversation():
rows = [{"conversation_id": 3, "summary": "Ueber Kalender", "distance": 0.1}]
with patch.object(main, "db_query", new=AsyncMock(return_value=rows)) as mock_query:
result = await main.search_similar_conversation_summaries([0.1, 0.2], exclude_conversation_id=9, limit=3)
assert result == rows
args, kwargs = mock_query.call_args
assert "conversation_id != %s" in args[0]
assert args[1] == ("[0.1,0.2]", 9, 3)
- Step 2: Tests laufen lassen, Fehlschlag pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 2 neue FAIL mit AttributeError
- Step 3: Implementierung
async def upsert_conversation_summary(conversation_id: int, summary: str, embedding: list):
await db_query(
"""
INSERT INTO conversation_summaries (conversation_id, summary, embedding)
VALUES (%s, %s, %s::vector)
ON CONFLICT (conversation_id) DO UPDATE
SET summary = EXCLUDED.summary, embedding = EXCLUDED.embedding, updated_at = CURRENT_TIMESTAMP
""",
(conversation_id, summary, _vector_literal(embedding)),
)
async def search_similar_conversation_summaries(query_embedding: list, exclude_conversation_id: int, limit: int = 3) -> list:
return await db_query(
"""
SELECT conversation_id, summary, embedding <=> %s::vector AS distance
FROM conversation_summaries
WHERE conversation_id != %s
ORDER BY distance ASC
LIMIT %s
""",
(_vector_literal(query_embedding), exclude_conversation_id, limit),
fetch="all",
)
Beachte: upsert_conversation_summary ruft db_query mit drei Positional-
Parametern auf (query, params) - die Test-Assertion auf args[1] prueft das
Tupel (conversation_id, summary, vector_literal), passend zu den drei
%s-Platzhaltern in der Query.
- Step 4: Tests laufen lassen, Erfolg pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 6 PASS insgesamt
- Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: add conversation_summaries DB helpers"
Task 4: remember_fact / forget_fact Tools
Files:
- Modify:
Claude outputs/main.py(Business-Logik nachsend_email/async def send_email, Tool-Liste nachCALENDAR_TOOLS, Instructions-Text nachCALENDAR_ASSISTANT_INSTRUCTIONS,execute_toolerweitern) - Test:
Claude outputs/tests/test_memory.py(erweitern)
Interfaces:
-
Consumes:
insert_memory_fact,search_memory_facts,delete_memory_fact(Task 2),DEFAULT_USER_ID(bestehende globale Variable) -
Produces:
remember_fact(fact: str) -> dict,forget_fact(query: str) -> dict, ListeMEMORY_TOOLS, StringMEMORY_ASSISTANT_INSTRUCTIONS,execute_toolkennt"remember_fact"/"forget_fact"- werden von Task 6 (Wiring inrun_chat_completion) konsumiert. -
Step 1: Failing tests schreiben
An Claude outputs/tests/test_memory.py anhaengen:
@pytest.mark.asyncio
async def test_remember_fact_inserts_and_returns_content():
main.DEFAULT_USER_ID = 1
with patch.object(main, "insert_memory_fact", new=AsyncMock(return_value=99)):
result = await main.remember_fact("Hund heisst Bruno")
assert result == {"id": 99, "content": "Hund heisst Bruno"}
@pytest.mark.asyncio
async def test_forget_fact_no_match_returns_message():
with patch.object(main, "search_memory_facts", new=AsyncMock(return_value=[])):
result = await main.forget_fact("Katze")
assert result["deleted"] is False
assert result["matches"] == []
@pytest.mark.asyncio
async def test_forget_fact_single_match_deletes():
matches = [{"id": 5, "content": "Hund heisst Bruno"}]
with patch.object(main, "search_memory_facts", new=AsyncMock(return_value=matches)), patch.object(
main, "delete_memory_fact", new=AsyncMock()
) as mock_delete:
result = await main.forget_fact("Bruno")
mock_delete.assert_called_once_with(5)
assert result == {"deleted": True, "content": "Hund heisst Bruno"}
@pytest.mark.asyncio
async def test_forget_fact_multiple_matches_does_not_delete():
matches = [
{"id": 5, "content": "Hund heisst Bruno"},
{"id": 6, "content": "Bruno ist der Nachbar"},
]
with patch.object(main, "search_memory_facts", new=AsyncMock(return_value=matches)), patch.object(
main, "delete_memory_fact", new=AsyncMock()
) as mock_delete:
result = await main.forget_fact("Bruno")
mock_delete.assert_not_called()
assert result["deleted"] is False
assert len(result["matches"]) == 2
@pytest.mark.asyncio
async def test_execute_tool_dispatches_remember_and_forget_fact():
import json
with patch.object(main, "remember_fact", new=AsyncMock(return_value={"id": 1, "content": "X"})):
result = await main.execute_tool("remember_fact", {"fact": "X"})
assert json.loads(result) == {"id": 1, "content": "X"}
with patch.object(main, "forget_fact", new=AsyncMock(return_value={"deleted": True, "content": "X"})):
result = await main.execute_tool("forget_fact", {"query": "X"})
assert json.loads(result) == {"deleted": True, "content": "X"}
- Step 2: Tests laufen lassen, Fehlschlag pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 5 neue FAIL (AttributeError bzw. ValueError: Unknown tool)
- Step 3: Implementierung
In main.py nach async def send_email(...):
async def remember_fact(fact: str) -> dict:
fact_id = await insert_memory_fact(DEFAULT_USER_ID, fact)
return {"id": fact_id, "content": fact}
async def forget_fact(query: str) -> dict:
matches = await search_memory_facts(query)
if not matches:
return {"deleted": False, "matches": [], "message": "Kein passender Fakt gefunden."}
if len(matches) > 1:
return {"deleted": False, "matches": [{"id": m["id"], "content": m["content"]} for m in matches]}
await delete_memory_fact(matches[0]["id"])
return {"deleted": True, "content": matches[0]["content"]}
Nach der CALENDAR_TOOLS-Liste (vor CALENDAR_ASSISTANT_INSTRUCTIONS):
MEMORY_TOOLS = [
{
"name": "remember_fact",
"description": "Store a fact the user explicitly asked to remember, so it is available in future chats.",
"input_schema": {
"type": "object",
"properties": {
"fact": {"type": "string", "description": "The fact to remember, phrased as a standalone statement"},
},
"required": ["fact"],
},
},
{
"name": "forget_fact",
"description": (
"Search remembered facts matching a query and delete it if exactly "
"one matches. Only call after the user has explicitly confirmed "
"which fact to forget in the conversation."
),
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Text to search for among remembered facts"},
},
"required": ["query"],
},
},
]
Nach CALENDAR_ASSISTANT_INSTRUCTIONS:
MEMORY_ASSISTANT_INSTRUCTIONS = (
"Du hast ausserdem Zugriff auf ein chatuebergreifendes Gedaechtnis ueber "
"die Tools remember_fact und forget_fact. Wenn der Nutzer dich explizit "
"bittet, dir etwas zu merken (z.B. 'merke dir, dass...'), rufe "
"remember_fact direkt auf - keine Rueckfrage noetig. Wenn der Nutzer "
"dich bittet, einen gemerkten Fakt zu vergessen, frage zuerst explizit "
"im Klartext nach Bestaetigung, welcher Fakt gemeint ist, und rufe "
"forget_fact erst auf, nachdem der Nutzer zugestimmt hat. Gibt "
"forget_fact mehrere moegliche Treffer zurueck, liste sie im Chat auf "
"und frage nach, welcher gemeint ist, statt den falschen zu loeschen."
)
In execute_tool, vor der abschliessenden raise ValueError-Zeile:
if name == "remember_fact":
result = await remember_fact(tool_input["fact"])
return json.dumps(result)
if name == "forget_fact":
result = await forget_fact(tool_input["query"])
return json.dumps(result)
- Step 4: Tests laufen lassen, Erfolg pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 11 PASS insgesamt
- Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: add remember_fact and forget_fact chat tools"
Task 5: build_memory_context (Prompt-Zusammensetzung)
Files:
- Modify:
Claude outputs/main.py(neue Funktion nachsearch_similar_conversation_summaries) - Test:
Claude outputs/tests/test_memory.py(erweitern)
Interfaces:
-
Consumes:
get_all_memory_facts()(Task 2),get_embedding(text: str) -> list(bestehend,main.py:128),search_similar_conversation_summaries(Task 3) -
Produces:
build_memory_context(user_message: str, conversation_id: int) -> str(leerer String, wenn nichts zu ergaenzen ist) - wird von Task 6 inrun_chat_completionkonsumiert. -
Step 1: Failing tests schreiben
An Claude outputs/tests/test_memory.py anhaengen:
@pytest.mark.asyncio
async def test_build_memory_context_empty_when_nothing_stored():
with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=[])), patch.object(
main, "get_embedding", new=AsyncMock(return_value=[0.1])
), patch.object(main, "search_similar_conversation_summaries", new=AsyncMock(return_value=[])):
context = await main.build_memory_context("Hallo", conversation_id=1)
assert context == ""
@pytest.mark.asyncio
async def test_build_memory_context_includes_facts_section():
facts = [{"id": 1, "content": "Hund heisst Bruno"}]
with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=facts)), patch.object(
main, "get_embedding", new=AsyncMock(return_value=[0.1])
), patch.object(main, "search_similar_conversation_summaries", new=AsyncMock(return_value=[])):
context = await main.build_memory_context("Wie geht es meinem Hund?", conversation_id=1)
assert "Bekannte Fakten ueber den Nutzer" in context
assert "Hund heisst Bruno" in context
assert "Relevante fruehere Gespraeche" not in context
@pytest.mark.asyncio
async def test_build_memory_context_includes_summaries_section():
summaries = [{"conversation_id": 2, "summary": "Ging um den Kalender", "distance": 0.05}]
with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=[])), patch.object(
main, "get_embedding", new=AsyncMock(return_value=[0.1])
), patch.object(main, "search_similar_conversation_summaries", new=AsyncMock(return_value=summaries)):
context = await main.build_memory_context("Was war nochmal mit dem Termin?", conversation_id=1)
assert "Relevante fruehere Gespraeche" in context
assert "Ging um den Kalender" in context
@pytest.mark.asyncio
async def test_build_memory_context_skips_summaries_when_embedding_fails():
with patch.object(main, "get_all_memory_facts", new=AsyncMock(return_value=[])), patch.object(
main, "get_embedding", new=AsyncMock(side_effect=RuntimeError("Ollama down"))
):
context = await main.build_memory_context("Hallo", conversation_id=1)
assert context == ""
- Step 2: Tests laufen lassen, Fehlschlag pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 4 neue FAIL mit AttributeError
- Step 3: Implementierung
async def build_memory_context(user_message: str, conversation_id: int) -> str:
sections = []
facts = await get_all_memory_facts()
if facts:
facts_lines = "\n".join(f"- {f['content']}" for f in facts)
sections.append(f"Bekannte Fakten ueber den Nutzer:\n{facts_lines}")
try:
query_embedding = await get_embedding(user_message)
summaries = await search_similar_conversation_summaries(query_embedding, conversation_id, limit=3)
except Exception as e:
logger.warning(f"Memory retrieval skipped, embedding/search failed: {e}")
summaries = []
if summaries:
summary_lines = "\n".join(f"- {s['summary']}" for s in summaries)
sections.append(f"Relevante fruehere Gespraeche:\n{summary_lines}")
return "\n\n".join(sections)
- Step 4: Tests laufen lassen, Erfolg pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 15 PASS insgesamt
- Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: assemble memory context from facts and similar summaries"
Task 6: Memory in run_chat_completion einbinden
Files:
- Modify:
Claude outputs/main.py(run_chat_completion-Signatur und System-Prompt-Aufbau, Aufrufstelle inchat()) - Test:
Claude outputs/tests/test_chat_tools.py(bestehende 4 Tests anpassen),Claude outputs/tests/test_memory.py(neuer Test)
Interfaces:
-
Consumes:
build_memory_context(user_message, conversation_id)(Task 5),MEMORY_TOOLS,MEMORY_ASSISTANT_INSTRUCTIONS(Task 4) -
Produces:
run_chat_completion(claude_messages: list, conversation_id: int)(neue Signatur, breaking change gegenueber vorherrun_chat_completion(claude_messages)) - Aufrufstelle inchat()(main.py:765) wird in diesem Task mit angepasst. -
Step 1: Bestehende Tests an neue Signatur anpassen + neuen Test schreiben
In Claude outputs/tests/test_chat_tools.py: bei allen 4 bestehenden
Aufrufen von main.run_chat_completion(...) das zweite Argument
conversation_id=1 ergaenzen und build_memory_context patchen, damit
kein echter Ollama-/DB-Zugriff noetig ist. Beispiel fuer die erste
Testfunktion (die anderen 3 analog):
@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
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")):
text, output_tokens, total_tokens = await main.run_chat_completion(
[{"role": "user", "content": "Hi"}], conversation_id=1
)
assert text == "Hallo!"
assert output_tokens == 5
assert total_tokens == 15
Wende dasselbe Muster (zusaetzliches conversation_id=1 Argument,
with patch.object(main, "build_memory_context", new=AsyncMock(return_value=""))
um den bestehenden with patch.object(...)-Block herum bzw. als
zusaetzlichen patch.object-Parameter) auf die restlichen 3 Tests in
dieser Datei an:
test_run_chat_completion_executes_tool_and_returns_followup,
test_run_chat_completion_handles_two_sequential_tool_calls,
test_run_chat_completion_lists_recent_emails.
Neuer Test in Claude outputs/tests/test_memory.py - dafuer zuerst den
Import am Dateianfang um MagicMock erweitern (wird ab hier und in Task 7
gebraucht):
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.mark.asyncio
async def test_run_chat_completion_includes_memory_context_in_system_prompt():
completion = MagicMock()
completion.stop_reason = "end_turn"
text_block = MagicMock()
text_block.type = "text"
text_block.text = "Bruno geht es gut."
completion.content = [text_block]
usage = MagicMock()
usage.input_tokens = 10
usage.output_tokens = 5
completion.usage = usage
main.claude_client = MagicMock()
main.claude_client.messages.create.return_value = completion
with patch.object(
main, "build_memory_context", new=AsyncMock(return_value="Bekannte Fakten ueber den Nutzer:\n- Hund heisst Bruno")
):
await main.run_chat_completion([{"role": "user", "content": "Wie geht es meinem Hund?"}], conversation_id=1)
_, kwargs = main.claude_client.messages.create.call_args
assert "Hund heisst Bruno" in kwargs["system"]
assert kwargs["tools"] == main.CALENDAR_TOOLS + main.MEMORY_TOOLS
- Step 2: Tests laufen lassen, Fehlschlag pruefen
Run: python -m pytest tests/test_chat_tools.py tests/test_memory.py -v
Expected: die 4 angepassten Tests FAILEN mit TypeError: run_chat_completion() takes 1 positional argument but 2 were given; der
neue Test FAILT ebenso.
- Step 3: Implementierung
run_chat_completion in main.py ersetzen durch:
async def run_chat_completion(claude_messages: list, conversation_id: int):
latest_user_message = claude_messages[-1]["content"]
memory_context = await build_memory_context(latest_user_message, conversation_id)
system_prompt = f"{CLAUDE_SYSTEM_PROMPT}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}\n\n{MEMORY_ASSISTANT_INSTRUCTIONS}"
if memory_context:
system_prompt = f"{system_prompt}\n\n{memory_context}"
messages = list(claude_messages)
total_input = 0
total_output = 0
for _ in range(MAX_TOOL_ROUNDS):
completion = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=1024,
system=system_prompt,
tools=CALENDAR_TOOLS + MEMORY_TOOLS,
messages=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}
)
messages = messages + [
{"role": "assistant", "content": completion.content},
{"role": "user", "content": tool_results},
]
return (
"Entschuldigung, das hat zu viele Zwischenschritte gebraucht. Bitte formuliere die Anfrage neu.",
total_output,
total_input + total_output,
)
(Einzige Aenderungen gegenueber vorher: neuer Parameter
conversation_id, die zwei neuen Zeilen fuer memory_context, der um
MEMORY_ASSISTANT_INSTRUCTIONS erweiterte system_prompt und
tools=CALENDAR_TOOLS + MEMORY_TOOLS statt tools=CALENDAR_TOOLS.)
In chat() die Aufrufstelle anpassen (main.py:765):
response_text, output_tokens, tokens_used = await run_chat_completion(claude_messages, conversation_id)
- Step 4: Tests laufen lassen, Erfolg pruefen
Run: python -m pytest tests/test_chat_tools.py tests/test_memory.py -v
Expected: alle PASS (4 angepasste + 16 aus test_memory.py)
- Step 5: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_chat_tools.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: inject remembered facts and similar summaries into chat system prompt"
Task 7: Automatische Zusammenfassung im Hintergrund
Files:
- Modify:
Claude outputs/main.py(neue Funktionen nachrun_chat_completion, Aufruf inchat()) - Test:
Claude outputs/tests/test_memory.py(erweitern)
Interfaces:
-
Consumes:
get_messages(conversation_id)(bestehend),claude_client.messages.create(bestehend),get_embedding(bestehend),upsert_conversation_summary(Task 3) -
Produces:
update_conversation_summary(conversation_id: int) -> None(faengt alle Exceptions intern ab),_spawn_background_task(coro) -> asyncio.Task- wird inchat()nach dem Speichern der Assistant-Antwort aufgerufen. -
Step 1: Failing tests schreiben
An Claude outputs/tests/test_memory.py anhaengen:
@pytest.mark.asyncio
async def test_update_conversation_summary_upserts_summary_and_embedding():
history = [
{"role": "user", "content": "Wie geht es meinem Hund?", "tokens_used": None, "created_at": None},
{"role": "assistant", "content": "Bruno geht es gut.", "tokens_used": 5, "created_at": None},
]
completion = MagicMock()
text_block = MagicMock()
text_block.type = "text"
text_block.text = "Nutzer fragte nach seinem Hund Bruno."
completion.content = [text_block]
main.claude_client = MagicMock()
main.claude_client.messages.create.return_value = completion
with patch.object(main, "get_messages", new=AsyncMock(return_value=history)), patch.object(
main, "get_embedding", new=AsyncMock(return_value=[0.1, 0.2])
), patch.object(main, "upsert_conversation_summary", new=AsyncMock()) as mock_upsert:
await main.update_conversation_summary(conversation_id=3)
mock_upsert.assert_called_once_with(3, "Nutzer fragte nach seinem Hund Bruno.", [0.1, 0.2])
@pytest.mark.asyncio
async def test_update_conversation_summary_swallows_exceptions():
with patch.object(main, "get_messages", new=AsyncMock(side_effect=RuntimeError("DB down"))):
await main.update_conversation_summary(conversation_id=3)
# kein Raise - das ist der Test
def test_spawn_background_task_tracks_and_releases_task():
import asyncio as aio
async def _run():
async def noop():
return "done"
task = main._spawn_background_task(noop())
assert task in main._background_tasks
result = await task
assert result == "done"
assert task not in main._background_tasks
aio.run(_run())
- Step 2: Tests laufen lassen, Fehlschlag pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: 3 neue FAIL mit AttributeError
- Step 3: Implementierung
In main.py nach run_chat_completion:
SUMMARY_SYSTEM_PROMPT = (
"Fasse das folgende Gespraech in 2-3 Saetzen auf Deutsch zusammen, damit "
"ein spaeterer Chat den Kontext wiedererkennt. Gib nur die Zusammenfassung "
"aus, ohne Einleitung."
)
async def update_conversation_summary(conversation_id: int):
try:
history = await get_messages(conversation_id)
claude_messages = [{"role": m["role"], "content": m["content"]} for m in history]
completion = await asyncio.to_thread(
claude_client.messages.create,
model=CLAUDE_MODEL,
max_tokens=200,
system=SUMMARY_SYSTEM_PROMPT,
messages=claude_messages,
)
summary_text = "".join(b.text for b in completion.content if b.type == "text")
embedding = await get_embedding(summary_text)
await upsert_conversation_summary(conversation_id, summary_text, embedding)
except Exception as e:
logger.error(f"Conversation summary update failed for conversation {conversation_id}: {e}")
_background_tasks: set = set()
def _spawn_background_task(coro):
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
In chat(), direkt nach
await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens):
_spawn_background_task(update_conversation_summary(conversation_id))
- Step 4: Tests laufen lassen, Erfolg pruefen
Run: python -m pytest tests/test_memory.py -v
Expected: alle PASS (19 insgesamt)
- Step 5: Gesamten Testlauf pruefen
Run: python -m pytest tests/ -v (kompletter Testcontainer-Durchlauf aus
JARVIS_HANDOFF.md)
Expected: alle Tests aller Dateien PASS, keine Regression
- Step 6: Commit
git add "Claude outputs/main.py" "Claude outputs/tests/test_memory.py"
git commit -m "feat: summarize conversations in the background after each turn"
Task 8: Migration anwenden & Deployment
Files:
- Keine Code-Aenderungen - reine Deployment-Aktion gegen die echte VPS-Infrastruktur (Befehle aus
JARVIS_HANDOFF.md, Abschnitte "Database Management" und "Backend-Code aktualisieren").
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
scp -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key \
"Claude outputs/migrations/006_memory.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/006_memory.sql"
- Step 2: Migration verifizieren
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 memory_facts' -c '\d conversation_summaries'"
Expected: beide Tabellen mit den erwarteten Spalten werden angezeigt.
- Step 3: Backend deployen
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: Manueller End-to-End-Test
Im Chat (Web-UI):
- "Merke dir, dass mein Hund Bruno heisst" - Antwort sollte die Speicherung bestaetigen.
- Ueber den "Neuer Chat"-Button einen neuen Chat starten.
- "Wie heisst mein Hund?" fragen - Antwort sollte "Bruno" korrekt verwenden (Fakten-Pfad).
- Ein thematisch verwandtes, aber nicht identisches Thema aus dem ersten Chat ansprechen und pruefen, dass JARVIS darauf Bezug nehmen kann (Zusammenfassungs-Retrieval-Pfad - ggf. kurz warten, da die Zusammenfassung erst nach der ersten Antwort im Hintergrund entsteht).
- In einem dritten Chat "Vergiss, dass mein Hund Bruno heisst" sagen, die Rueckfrage bestaetigen, und in einem vierten Chat verifizieren, dass der Fakt nicht mehr bekannt ist.
- Step 5: Logs pruefen
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, insbesondere keine "Conversation summary update failed" ausser bei absichtlich simulierten Fehlern.