bifroest/tests/test_memory.py
2026-10-27 08:34:17 +01:00

160 lines
7.4 KiB
Python

"""Gedächtnis über Munin (→ Konzept 7.6): Anleitung im Prompt, Einstufung, Ansicht und Löschen."""
import json
import pytest
from mcp.server.mcpserver import MCPServer
from sqlalchemy import select
from app.audit.models import AuditEvent
from app.identity.models import User
from app.mcp.models import McpServer
from app.tasks.worker import Worker
from app.tools.models import ToolDefinition
from helpers import McpTestServer, connect_repo, create_workspace
INSTRUCTIONS = "Munin-Anleitung: erst recall, dann remember. Nie Geheimnisse speichern."
class FakeMunin:
"""Nachbau der Munin-Tools mit In-Memory-Einträgen."""
def __init__(self):
self.entries = {"e1": "Staging-DB wird sonntags zurückgesetzt.", "e2": "Deployment nur dienstags."}
self.forgotten = [] # die Tool-Funktion list überdeckt unten den eingebauten Namen
server = MCPServer("munin", version="9.9", instructions=INSTRUCTIONS)
@server.tool(description="Sucht Einträge.")
def recall(query: str) -> str:
return "\n".join(f"- {k}: {v}" for k, v in self.entries.items() if query.lower() in v.lower()) or "nichts"
@server.tool(description="Liest einen Eintrag.")
def get(id: str) -> str:
return self.entries.get(id, "nicht gefunden")
@server.tool(description="Listet Einträge.")
def list() -> str:
return json.dumps({"entries": [{"id": k, "text": v} for k, v in self.entries.items()]})
@server.tool(description="Verwandte Einträge.")
def related(id: str) -> str:
return "keine"
@server.tool(description="Speichert einen Eintrag.")
def remember(content: str) -> str:
key = f"e{len(self.entries) + 1}"
self.entries[key] = content
return f"gespeichert als {key}"
@server.tool(description="Löscht einen Eintrag.")
def forget(id: str) -> str:
self.forgotten.append(id)
self.entries.pop(id, None)
return "in den Papierkorb gelegt"
self.http = McpTestServer(server)
def __enter__(self):
self.http.__enter__()
return self
def __exit__(self, *exc):
self.http.__exit__(*exc)
@pytest.fixture
def munin():
with FakeMunin() as m:
yield m
@pytest.fixture
def settings(settings):
return settings.model_copy(update={"mcp_allow_local": True, "mcp_timeout_seconds": 5})
@pytest.fixture
def setup(browser, people, db, munin, settings):
with db.session() as session:
session.get(User, people["anna"]).is_admin = True
anna = browser("anna")
response = anna.post("/admin/mcp", {"name": "munin", "label": "Munin", "url": munin.http.url, "role": "memory"})
server_id = int(response.headers["location"].rsplit("/", 1)[1])
page = anna.post(f"/admin/mcp/{server_id}/discover").text
assert "Anleitung des Servers" in page and "Empfehlung übernehmen" in page
page = anna.post(f"/admin/mcp/{server_id}/memory-defaults").text
assert "6 Tools nach der Empfehlung für Munin eingestuft" in page
workspace_id = create_workspace(anna, people["team_demo"])
anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"})
# Workspace-Key (in Munin bestimmt der Key das Projekt)
assert anna.post(f"/workspaces/{workspace_id}/mcp/{server_id}", {"token": "munin-projekt-key-1"}).status_code == 303
return {"anna": anna, "ben": browser("ben"), "dora": browser("dora"), "workspace_id": workspace_id,
"server_id": server_id}
def test_empfohlene_einstufung(setup, db):
with db.session() as session:
found = {d.name: (d.risk_class, d.decision) for d in session.scalars(
select(ToolDefinition).where(ToolDefinition.name.like("munin__%")))}
server = session.scalar(select(McpServer))
assert found == {"munin__recall": ("read", "allow"), "munin__get": ("read", "allow"),
"munin__list": ("read", "allow"), "munin__related": ("read", "allow"),
"munin__remember": ("write_internal", "allow"), "munin__forget": ("destructive", "deny")}
assert server.role == "memory" and server.server_info["instructions"] == INSTRUCTIONS
def test_anleitung_im_prompt_und_forget_nie_fuer_die_ki(setup, db, settings, fake_provider, git_repo):
connect_repo(setup["anna"], setup["workspace_id"], git_repo)
chat_id = int(setup["ben"].post(f"/workspaces/{setup['workspace_id']}/chats").headers["location"].rsplit("/", 1)[1])
fake_provider.script = [[("munin__recall", {"query": "staging"}), ("munin__forget", {"id": "e1"})], "Erledigt."]
setup["ben"].post(f"/chats/{chat_id}/messages", {"content": "Was gilt für Staging?"}, headers={"HX-Request": "true"})
Worker(db, settings).run_once()
first = fake_provider.calls[0]
assert INSTRUCTIONS in first["instructions"] and "Gedächtnis dieses Workspace" in first["instructions"]
offered = {t["name"] for t in first["tools"]}
assert "munin__recall" in offered and "munin__remember" in offered and "munin__forget" not in offered
outputs = [i["output"] for i in fake_provider.calls[1]["input"] if i.get("type") == "function_call_output"]
assert "Staging-DB" in outputs[0] and "nicht verfügbar" in outputs[1]
def test_gedaechtnis_ansehen(setup, munin):
ws = setup["workspace_id"]
assert "Gedächtnis</a>" in setup["ben"].get(f"/workspaces/{ws}").text
page = setup["ben"].get(f"/workspaces/{ws}/memory").text
assert "Staging-DB wird sonntags zurückgesetzt." in page and "?entry=e1" in page
assert "Eintrag löschen" not in page
page = setup["ben"].get(f"/workspaces/{ws}/memory?q=deployment").text
assert "Deployment nur dienstags." in page and "Staging" not in page.split("Treffer")[-1]
page = setup["ben"].get(f"/workspaces/{ws}/memory?entry=e2").text
assert "Eintrag <code>e2</code>" in page
assert setup["dora"].get(f"/workspaces/{ws}/memory").status_code == 404
def test_loeschen_nur_durch_admins(setup, db, munin):
ws = setup["workspace_id"]
assert setup["ben"].post(f"/workspaces/{ws}/memory/forget", {"entry_id": "e1"}).status_code == 403
assert munin.forgotten == []
page = setup["anna"].get(f"/workspaces/{ws}/memory?entry=e1").text
assert "Eintrag löschen" in page
response = setup["anna"].post(f"/workspaces/{ws}/memory/forget", {"entry_id": "e1"})
assert response.status_code == 200 and "Eintrag e1 gelöscht" in response.text
assert munin.forgotten == ["e1"] and "Staging-DB" not in response.text
assert setup["anna"].post(f"/workspaces/{ws}/memory/forget", {"entry_id": "a b"}).status_code == 400
with db.session() as session:
event = session.scalar(select(AuditEvent).where(AuditEvent.action == "memory.forgotten"))
assert event.target_id == "e1" and event.workspace_id == ws
def test_ohne_gedaechtnis_server(browser, people, db):
anna = browser("anna")
ws = create_workspace(anna, people["team_demo"])
page = anna.get(f"/workspaces/{ws}/memory").text
assert "kein Gedächtnis-Server freigeschaltet" in page
assert ">Gedächtnis</a>" not in anna.get(f"/workspaces/{ws}").text
assert anna.post(f"/workspaces/{ws}/memory/forget", {"entry_id": "e1"}).status_code == 404
def test_unbekannte_art_abgelehnt(setup):
response = setup["anna"].post("/admin/mcp", {"name": "x1", "label": "X", "url": "http://localhost:1/mcp",
"role": "chef"})
assert response.status_code == 400 and "Unbekannte Art" in response.text