Onyscribe Admin: Initialer Stand

Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

106
app/agent/workspace.py Normal file
View File

@@ -0,0 +1,106 @@
"""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}