feat: add remember_fact and forget_fact chat tools

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
This commit is contained in:
Jonny 2026-09-13 15:35:23 +02:00
parent 9c4ad034e5
commit 72d2bc379c
2 changed files with 121 additions and 0 deletions

View File

@ -497,6 +497,21 @@ async def send_email(to: str, subject: str, body: str) -> dict:
return await asyncio.to_thread(_send_email_sync, to, subject, body)
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"]}
CALENDAR_TOOLS = [
{
"name": "list_calendar_events",
@ -590,6 +605,35 @@ CALENDAR_TOOLS = [
},
]
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"],
},
},
]
CALENDAR_ASSISTANT_INSTRUCTIONS = (
"Du hast Zugriff auf den Kalender 'FFW-Onza-Alle' ueber die Tools "
"list_calendar_events, create_calendar_event, update_calendar_event und "
@ -610,6 +654,18 @@ CALENDAR_ASSISTANT_INSTRUCTIONS = (
"versendete Mail laesst sich nicht zurueckholen."
)
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."
)
async def execute_tool(name: str, tool_input: dict) -> str:
if name == "list_calendar_events":
@ -641,6 +697,12 @@ async def execute_tool(name: str, tool_input: dict) -> str:
if name == "send_email":
result = await send_email(tool_input["to"], tool_input["subject"], tool_input["body"])
return json.dumps(result)
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)
raise ValueError(f"Unknown tool: {name}")

View File

@ -82,3 +82,62 @@ async def test_search_similar_conversation_summaries_excludes_current_conversati
args, kwargs = mock_query.call_args
assert "conversation_id != %s" in args[0]
assert args[1] == ("[0.1,0.2]", 9, 3)
@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"}