131 lines
5.4 KiB
Python
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)]
|
|
|