bifroest/app/mcp/memory.py
2026-10-27 08:34:17 +01:00

131 lines
5.4 KiB
Python

"""Gedächtnis über einen MCP-Server wie Munin (→ Konzept 7.6).
Die KI nutzt die eingestuften Tools wie bei jedem MCP-Server. Hier kommt dazu,
was Menschen brauchen: Einträge ansehen und löschen (forget nur durch Menschen,
nie durch die KI) sowie die empfohlene Einstufung der Munin-Tools.
Die Argumentnamen werden aus dem entdeckten Schema abgeleitet, nicht fest
angenommen; die Antworten werden als Text gezeigt.
"""
import json
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from app import audit
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.errors import Invalid
from app.identity import Principal
from app.mcp import client
from app.mcp import service as mcp
from app.mcp.models import McpServer, McpTool
from app.tools import service as tools
from app.tools.models import ALLOW, DENY
from app.workspaces import service as workspaces
MEMORY = "memory"
# Empfehlung aus Konzept 7.6/10.7: forget bekommt die KI nie
DEFAULTS = {"recall": ("read", ALLOW), "get": ("read", ALLOW), "list": ("read", ALLOW), "related": ("read", ALLOW),
"remember": ("write_internal", ALLOW), "forget": ("destructive", DENY)}
ID_NAMES = ("id", "entry_id", "key", "memory_id", "uid")
QUERY_NAMES = ("query", "q", "text", "question", "search")
def apply_defaults(session: Session, principal: Principal, server_id: int) -> int:
"""Empfohlene Einstufung für die gefundenen Munin-Tools; bewusste, auditierte Admin-Aktion."""
server = mcp.get(session, server_id)
tools.sync(session)
count = 0
for tool in mcp.tools_of(session, server.id):
if tool.remote_name in DEFAULTS:
risk, decision = DEFAULTS[tool.remote_name]
tools.classify(session, principal, tool.name, risk, decision)
count += 1
return count
@dataclass(frozen=True)
class Memory:
server: McpServer
target: client.Target
tools: dict[str, McpTool] # remote_name -> Tool
def call(self, remote: str, arguments: dict[str, Any]) -> str:
if remote not in self.tools:
raise Invalid(f"Der Gedächtnis-Server bietet kein Tool {remote!r} an.")
return client.call(self.target, remote, arguments)
def argument(self, remote: str, preferred: tuple[str, ...]) -> str:
"""Name des Arguments für eine ID bzw. Suchanfrage, aus dem Schema des Tools."""
schema = self.tools[remote].input_schema if remote in self.tools else {}
properties = schema.get("properties") or {}
for name in preferred:
if name in properties:
return name
required = schema.get("required") or []
if required:
return required[0]
strings = [k for k, v in properties.items() if isinstance(v, dict) and v.get("type") == "string"]
if strings:
return strings[0]
raise Invalid(f"Unklar, welches Argument {remote} erwartet.")
def for_workspace(session: Session, vault: Vault, settings: Settings, workspace_id: int) -> Memory | None:
"""Freigeschalteter Gedächtnis-Server des Workspace (der erste, falls mehrere)."""
for server, link in mcp.enabled(session, workspace_id):
if server.role == MEMORY:
found = {t.remote_name: t for t in mcp.tools_of(session, server.id)}
return Memory(server, mcp.target(server, vault, settings, link.secret if link else None), found)
return None
def entries(memory: Memory, query: str = "") -> str:
if query.strip():
return memory.call("recall", {memory.argument("recall", QUERY_NAMES): query.strip()[:500]})
return memory.call("list", {})
def entry(memory: Memory, entry_id: str) -> str:
return memory.call("get", {memory.argument("get", ID_NAMES): check_id(entry_id)})
def forget(session: Session, principal: Principal, memory: Memory, workspace_id: int, entry_id: str) -> str:
"""Eintrag löschen: nur Workspace-Admins, nie die KI. In Munin 30 Tage wiederherstellbar."""
workspaces.require_admin(session, principal, workspace_id)
entry_id = check_id(entry_id)
output = memory.call("forget", {memory.argument("forget", ID_NAMES): entry_id})
audit.record(session, "memory.forgotten", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="memory_entry", target_id=entry_id[:100], server=memory.server.name)
return output
def check_id(value: str) -> str:
value = value.strip()
if not value or len(value) > 200 or any(c.isspace() for c in value):
raise Invalid("Bitte eine gültige Eintrags-ID angeben.")
return value
def items(text: str) -> list[dict[str, Any]] | None:
"""Falls die Antwort JSON mit einer Liste von Einträgen ist: die Einträge (für eine Tabelle)."""
try:
value = json.loads(text)
except (ValueError, TypeError):
return None
if isinstance(value, dict):
value = next((v for k, v in value.items() if k in ("entries", "items", "results", "memories")
and isinstance(v, list)), None)
if isinstance(value, list) and value and all(isinstance(v, dict) for v in value):
return value[:200]
return None
def prompt_notes(session: Session, workspace_id: int) -> list[tuple[McpServer, str]]:
"""Anleitungen der freigeschalteten Server für den System-Prompt."""
return [(server, (server.server_info or {}).get("instructions", ""))
for server, _ in mcp.enabled(session, workspace_id)]