"""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)]