160 lines
7.4 KiB
Python
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
|