- 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>
128 lines
5.9 KiB
Python
128 lines
5.9 KiB
Python
"""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}
|