Files
Onyscribe-Admin/app/agent/workspace.py
RubenRWU 40b3f687f7 KI-Agent: Änderungen erscheinen in Live-Vorschau und PDF
- Dateien des Agenten werden sofort committet (die Vorschau liest in anderer Sitzung; vorher KeyError/unverändert)
- Beispielprotokoll der PDF-Vorschau gehört zum Arbeitsstand: neues Tool set_sample, run_prompt_test setzt das LLM-Ergebnis als Beispiel; im Vorschlag und bei der Freigabe übernommen
- Vorschau wechselt bei PDF-relevanten Änderungen automatisch auf die PDF-Ansicht
- Regressionstests inkl. echter SSE-/Freigabe-Strecke

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:46:32 +02:00

128 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}
self.base_sample = self.sample = tenant.preview_sample # Beispielprotokoll der PDF-Vorschau (None = Standard)
# ── 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)
self.db.commit() # Blob sofort sichtbar machen: die Live-Vorschau liest in einer ANDEREN Sitzung, während der Agent noch läuft
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 or self.sample != self.base_sample
def snapshot(self) -> tuple[dict, dict, str | None]:
return deepcopy(self.cfg), dict(self.files), self.sample
def changed_paths(self, prev: tuple[dict, dict, str | None]) -> list[str]:
"""Was hat sich seit `prev` geändert? (für die Vorschau: PDF-relevante Änderungen → PDF-Ansicht)"""
cfg0, files0, sample0 = prev
out = [p for p in set(self.files) | set(files0) if self.files.get(p) != files0.get(p)]
for k in set(self.cfg) | set(cfg0):
if self.cfg.get(k) != cfg0.get(k):
if isinstance(self.cfg.get(k), dict) or isinstance(cfg0.get(k), dict):
out += [f"{k}.{kk}" for kk in set(self.cfg.get(k) or {}) | set(cfg0.get(k) or {}) if (self.cfg.get(k) or {}).get(kk) != (cfg0.get(k) or {}).get(kk)]
else:
out.append(k)
if self.sample != sample0:
out.append("preview_sample")
return sorted(out)
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]
if self.sample != self.base_sample:
items.append({"path": "Vorschau-Beispielprotokoll", "kind": "geändert"})
return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None,
"files": files, "deleted": deleted, "items": items, "sample": self.sample if self.sample != self.base_sample else None}