"""Entwurfs-Workspace des Agenten: Kopie des Tenant-Entwurfs im Speicher. Der Agent ändert NUR diese Kopie. Daraus entsteht ein Vorschlag (Diff), den der Mensch freigibt. Datenschutz: Dateien mit personenbezogenem Inhalt (Namenslisten) sind für den Agenten nicht lesbar. """ from __future__ import annotations import json from copy import deepcopy from sqlalchemy.orm import Session from app.models import Tenant from app.services import store PERSONAL_PREFIXES = ("namensliste", "mitglieder", "members", "personen") READ_LIMIT = 60_000 PROTECTED = {"tenant.json"} def is_personal(path: str) -> bool: name = path.rsplit("/", 1)[-1].lower() return name.startswith(PERSONAL_PREFIXES) class Workspace: def __init__(self, db: Session, tenant: Tenant): self.db, self.tenant = db, tenant cfg, files = store.content(db, tenant, "draft") self.base_cfg, self.base_files = deepcopy(cfg), dict(files) self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha} # ── Lesen ── def list_files(self) -> list[dict]: out = [] for p, sha in sorted(self.files.items()): b = self.db.get(__import__("app.models", fromlist=["Blob"]).Blob, sha) out.append({"path": p, "bytes": b.size, "text": store.is_text_path(p), "personal_data": is_personal(p), "changed": self.base_files.get(p) != sha}) return out def read(self, path: str) -> str: if path in ("tenant.json",): return json.dumps(self.cfg, indent=2, ensure_ascii=False) if path not in self.files: raise KeyError(f"Datei nicht im Tenant: {path}") if is_personal(path): n = len(store.get_blob(self.db, self.files[path]).decode("utf-8", "replace").splitlines()) return f"[Datei enthält personenbezogene Daten und ist für den Agenten gesperrt – {n} Zeilen. Inhalt nicht verfügbar.]" if not store.is_text_path(path): return "[Binärdatei]" text = store.get_blob(self.db, self.files[path]).decode("utf-8", "replace") return text[:READ_LIMIT] + ("\n[… gekürzt]" if len(text) > READ_LIMIT else "") # ── Schreiben ── def write(self, path: str, content: str) -> None: path = store.clean_path(path) if is_personal(path): raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht ändern.") if path.endswith(".py"): compile(content, path, "exec") # Syntaxfehler früh melden self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path) def edit(self, path: str, old: str, new: str) -> None: cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False) if cur.count(old) != 1: raise ValueError(f"old_str muss genau 1× vorkommen (gefunden: {cur.count(old)}×).") if path == "tenant.json": self.set_config_json(cur.replace(old, new)) else: self.write(path, cur.replace(old, new)) def delete(self, path: str) -> None: if is_personal(path): raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht löschen.") self.files.pop(path, None) def set_config_json(self, text: str) -> None: cfg = json.loads(text) cfg.pop("deploy", None) cfg["id"] = self.tenant.id self.cfg = cfg def set_config_value(self, key_path: str, value) -> None: cur = self.cfg keys = key_path.split(".") for k in keys[:-1]: cur = cur.setdefault(k, {}) cur[keys[-1]] = value # ── Ergebnis ── def changed(self) -> bool: return self.cfg != self.base_cfg or self.files != self.base_files def proposal(self, summary: str) -> dict | None: if not self.changed(): return None files = {p: s for p, s in self.files.items() if self.base_files.get(p) != s} deleted = [p for p in self.base_files if p not in self.files] items = [] if self.cfg != self.base_cfg: items.append({"path": "tenant.json", "kind": "geändert"}) items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files] items += [{"path": p, "kind": "gelöscht"} for p in deleted] return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None, "files": files, "deleted": deleted, "items": items}