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

0
app/agent/__init__.py Normal file
View File

129
app/agent/backends.py Normal file
View File

@@ -0,0 +1,129 @@
"""LLM-Backends für den Agenten. Interne Nachrichten im Anthropic-Block-Format:
assistant: [{"type":"text"|"tool_use",...}] · user: str | [{"type":"tool_result","tool_use_id","content"}]
"""
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass, field
@dataclass
class Turn:
blocks: list[dict]
usage: dict = field(default_factory=dict)
class MessagesBackend:
"""Anthropic-Messages-Protokoll – Cortecs bietet es unter https://api.cortecs.ai/v1/messages an."""
name = "messages"
def __init__(self, api_key: str, base_url: str, model: str):
import anthropic
root = re.sub(r"/v1/?$", "", base_url.rstrip("/")) # SDK hängt /v1/messages selbst an
self.client = anthropic.Anthropic(api_key=api_key, auth_token=api_key, base_url=root, max_retries=2, timeout=180)
self.model = model
def step(self, system: str, messages: list[dict], tools: list[dict]) -> Turn:
r = self.client.messages.create(
model=self.model, max_tokens=8000, system=system, messages=messages,
tools=[{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools])
blocks = []
for b in r.content:
if b.type == "text":
blocks.append({"type": "text", "text": b.text})
elif b.type == "tool_use":
blocks.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input})
return Turn(blocks, {"in": r.usage.input_tokens, "out": r.usage.output_tokens})
class ChatBackend:
"""OpenAI-Chat-Completions (Fallback, falls ein Modell nur diesen Weg mit Tools kann)."""
name = "chat"
def __init__(self, api_key: str, base_url: str, model: str):
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=base_url, max_retries=2, timeout=180)
self.model = model
@staticmethod
def _convert(system: str, messages: list[dict]) -> list[dict]:
out = [{"role": "system", "content": system}]
for m in messages:
c = m["content"]
if isinstance(c, str):
out.append({"role": m["role"], "content": c})
elif m["role"] == "assistant":
text = "".join(b["text"] for b in c if b["type"] == "text")
calls = [{"id": b["id"], "type": "function", "function": {"name": b["name"], "arguments": json.dumps(b["input"])}}
for b in c if b["type"] == "tool_use"]
msg = {"role": "assistant", "content": text or None}
if calls:
msg["tool_calls"] = calls
out.append(msg)
else:
for b in c:
out.append({"role": "tool", "tool_call_id": b["tool_use_id"], "content": b["content"]})
return out
def step(self, system, messages, tools) -> Turn:
r = self.client.chat.completions.create(
model=self.model, max_tokens=8000, temperature=0.2, messages=self._convert(system, messages),
tools=[{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["schema"]}} for t in tools])
msg = r.choices[0].message
blocks = []
if msg.content:
blocks.append({"type": "text", "text": msg.content})
for tc in msg.tool_calls or []:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
blocks.append({"type": "tool_use", "id": tc.id, "name": tc.function.name, "input": args})
u = r.usage
return Turn(blocks, {"in": getattr(u, "prompt_tokens", 0), "out": getattr(u, "completion_tokens", 0)})
HEX = re.compile(r"#[0-9a-fA-F]{6}\b")
def _shade(h, f):
h = h.lstrip("#"); c = [max(0, min(255, int(int(h[i:i + 2], 16) * f))) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
def _tint(h, a):
h = h.lstrip("#"); c = [int(int(h[i:i + 2], 16) + (255 - int(h[i:i + 2], 16)) * a) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
class MockBackend:
"""Demo ohne Cortecs-Key: spielt über dieselben Tools zwei einfache Aufgaben durch."""
name = "mock"
model = "mock"
def step(self, system, messages, tools) -> Turn:
used = sum(1 for m in messages if m["role"] == "assistant")
user = next(m["content"] for m in messages if m["role"] == "user" and isinstance(m["content"], str))
low = user.lower()
tid = lambda: "mock_" + uuid.uuid4().hex[:8] # noqa: E731
call = lambda name, **a: {"type": "tool_use", "id": tid(), "name": name, "input": a} # noqa: E731
hexes = HEX.findall(user)
if hexes and any(w in low for w in ("farbe", "akzent", "color", "design")):
a = hexes[0].upper()
plan = [[call("set_config", key=f"colors.{k}", value=v) for k, v in
{"accent": a, "accent_dark": _shade(a, .72), "accent_mid": _shade(a, 1.12),
"accent_light": _tint(a, .88), "accent_xlight": _tint(a, .94)}.items()],
[call("validate")]]
final = f"Akzentfarbe auf {a} gesetzt und die abgeleiteten Töne angepasst."
elif "prompt" in low or "regel" in low:
rule = re.sub(r"^(bitte\s+)?(füge\s+)?(dem\s+)?prompt\s*(hinzu)?[:,]?\s*", "", user.strip(), flags=re.I)
plan = [[call("read_core_default", name="prompt.txt")], [call("__append_prompt__", rule=rule)], [call("validate")]]
final = "Zusatzregel im Prompt ergänzt."
else:
return Turn([{"type": "text", "text": "Demo-Modus – es wurde kein Cortecs-Key gefunden (geprüft: Einstellungen → Geteilte Secrets, Env/.env CORTECS_API_KEY, Key des Tenants). "
"Key setzen und die Seite neu laden. Ohne Key verstehe ich nur „Setze die Akzentfarbe auf #RRGGBB“ und „Füge dem Prompt hinzu: …“."}])
if used < len(plan):
return Turn(plan[used])
return Turn([{"type": "text", "text": final}])

175
app/agent/loop.py Normal file
View File

@@ -0,0 +1,175 @@
"""Agent-Loop: Modell ↔ Werkzeuge auf dem Entwurfs-Workspace → Vorschlag zur Freigabe."""
from __future__ import annotations
import json
import uuid
from collections import OrderedDict
from copy import deepcopy
from typing import Iterator
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.agent import backends
from app.agent.tools import TOOLS, ToolRunner
from app.agent.workspace import Workspace
from app.models import ChatMessage, Setting, Tenant
from app.services import llm, store
MAX_TURNS = 14
# Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:<run_id>"). Nur im Speicher, höchstens 24.
WORKSPACES: "OrderedDict[str, tuple[dict, dict]]" = OrderedDict()
def remember(run_id: str, ws: Workspace) -> None:
WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files))
WORKSPACES.move_to_end(run_id)
while len(WORKSPACES) > 24:
WORKSPACES.popitem(last=False)
DEFAULT_MODEL = "claude-4-6-sonnet"
SYSTEM = """Du bist der Konfigurations-Agent der Onyscribe-Admin-Plattform. Onyscribe erzeugt aus Gesprächsaufzeichnungen Protokolle
(Transkription → LLM-Protokoll → PDF/Word). Jeder Kunde ist ein „Tenant“; alles, was ihn vom Standard unterscheidet, sind DATEN:
tenant.json (Name, Farben, Kontaktdaten, Flags, STT), prompt.txt, protocol.html/protocol.css (PDF-Layout), optional overrides.py
(Hook-Code) und Zusatzdateien. Der Core-Code der Plattform ist NICHT veränderbar und nicht sichtbar – du arbeitest ausschließlich an Tenant-Daten.
Arbeitsweise:
- Du arbeitest auf einem ENTWURF im Speicher. Nichts ist live; am Ende prüft ein Mensch deinen Vorschlag und gibt ihn frei.
- Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese.
- Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht.
- Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest.
- Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes.
- Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an.
- Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden.
- Antworte am Ende kurz auf Deutsch (du-Form): was du geändert hast und worauf der Mensch beim Prüfen achten soll. Keine Markdown-Überschriften."""
def _setting(db: Session, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value else default
def make_backend(db: Session, tenant_id: str | None = None):
model = _setting(db, "agent_model", DEFAULT_MODEL)
key = llm.api_key(db, tenant_id)
if not key:
return backends.MockBackend()
api = _setting(db, "agent_api", "messages")
cls = backends.MessagesBackend if api == "messages" else backends.ChatBackend
return cls(key, llm.base_url(db), model)
def _context(tenant: Tenant, ws: Workspace) -> str:
return (f"Tenant: {tenant.id} („{ws.cfg.get('name', '')}“). Dateien: "
+ ", ".join(f['path'] for f in ws.list_files()) + ". Der Entwurf kann bereits unveröffentlichte Änderungen enthalten.")
def _history(db: Session, tenant_id: str, limit: int = 8) -> list[dict]:
rows = db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == tenant_id).order_by(ChatMessage.id.desc()).limit(limit)).all()
out = []
for m in reversed(rows):
if m.content:
out.append({"role": m.role, "content": m.content})
# Anthropic verlangt Rollenwechsel beginnend mit user
while out and out[0]["role"] != "user":
out.pop(0)
merged = []
for m in out:
if merged and merged[-1]["role"] == m["role"]:
merged[-1]["content"] += "\n" + m["content"]
else:
merged.append(dict(m))
return merged
def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterator[dict]:
"""Generator von Ereignissen: step | text | proposal | usage | error. Schreibt NICHT in den Entwurf."""
backend = backend or make_backend(db, tenant.id)
ws = Workspace(db, tenant)
run_id = uuid.uuid4().hex[:12]
remember(run_id, ws)
last_sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
runner = ToolRunner(ws)
system = SYSTEM + "\n\n" + _context(tenant, ws)
messages = _history(db, tenant.id)
messages.append({"role": "user", "content": user_message})
usage = {"in": 0, "out": 0}
final_text = ""
yield {"type": "mode", "backend": backend.name, "model": getattr(backend, "model", ""), "run": run_id}
try:
for _ in range(MAX_TURNS):
turn = backend.step(system, messages, TOOLS)
usage["in"] += turn.usage.get("in", 0)
usage["out"] += turn.usage.get("out", 0)
messages.append({"role": "assistant", "content": turn.blocks})
calls = [b for b in turn.blocks if b["type"] == "tool_use"]
texts = [b["text"] for b in turn.blocks if b["type"] == "text" and b["text"].strip()]
if texts:
final_text = "\n".join(texts)
if calls:
yield {"type": "thought", "text": final_text}
if not calls:
break
results = []
for c in calls:
name, args = c["name"], c["input"]
if name == "__append_prompt__": # nur Mock: Standard-Prompt kopieren + Regel anhängen
base = ws.read("prompt.txt") if "prompt.txt" in ws.files else (
__import__("app.settings", fromlist=["PLATFORM_DIR"]).PLATFORM_DIR / "core" / "defaults" / "prompt.txt").read_text(encoding="utf-8")
ws.write("prompt.txt", base.rstrip() + f"\n\nZusätzliche Regel:\n{args['rule'].strip()}\n")
arg, res = "prompt.txt", "Zusatzregel angehängt"
name = "edit_file"
else:
arg, res = runner.run(name, args)
short = res if len(res) < 220 else res[:220] + " …"
if name == "validate" and not res.startswith("FEHLER"):
try:
v = json.loads(res)
short = "✔ bestanden" + (f" ({len(v['warnings'])} Hinweis(e))" if v.get("warnings") else "") if v.get("ok") else "✘ " + "; ".join(v.get("errors", []))[:200]
except (ValueError, KeyError):
pass
yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")}
sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen
last_sig = sig
remember(run_id, ws)
yield {"type": "changed", "run": run_id}
results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res})
messages.append({"role": "user", "content": results})
else:
final_text = (final_text + "\n\n(Abgebrochen: maximale Schrittzahl erreicht.)").strip()
except llm.LLMNotConfigured as exc:
yield {"type": "error", "text": str(exc)}
return
except Exception as exc: # noqa: BLE001 – Provider-/Netzfehler sauber an die UI melden
yield {"type": "error", "text": f"{type(exc).__name__}: {str(exc)[:400]}"}
return
prop = ws.proposal(final_text.split("\n")[0][:140] or "Änderungsvorschlag")
if prop is None and any(True for m in messages if m["role"] == "assistant" and isinstance(m["content"], list)
and any(b.get("name") in ("write_file", "edit_file", "set_config", "delete_file") for b in m["content"] if b["type"] == "tool_use")):
final_text = (final_text + "\n\n(Keine Änderung gegenüber dem aktuellen Entwurf.)").strip()
yield {"type": "text", "text": final_text or "(keine Antwort)"}
yield {"type": "proposal", "proposal": prop}
yield {"type": "usage", "usage": usage}
def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None:
"""Freigabe: Vorschlag in den Entwurf übernehmen (nur die betroffenen Pfade)."""
if proposal.get("config") is not None:
store.set_config(db, tenant, proposal["config"])
for path, sha in (proposal.get("files") or {}).items():
store.set_file(db, tenant, path, store.get_blob(db, sha))
for path in proposal.get("deleted") or []:
store.delete_file(db, tenant, path)
def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]:
"""Inhalt, der nach Freigabe im Entwurf stünde (für die Diff-Anzeige)."""
cfg, files = store.content(db, tenant, "draft")
if proposal.get("config") is not None:
cfg = proposal["config"]
files = {**files, **(proposal.get("files") or {})}
for p in proposal.get("deleted") or []:
files.pop(p, None)
return cfg, files

24
app/agent/testrun.py Normal file
View File

@@ -0,0 +1,24 @@
"""Prompt-Test: finalen Prompt vom Core bauen lassen (inkl. Hooks), LLM aufrufen."""
from __future__ import annotations
from app.services import llm, preview
from app.services.samples import SAMPLE_TRANSCRIPT, ROTARY_TRANSCRIPT
def user_content(transcript: str, terminart="", projekt="", kunde="", title="Beispiel") -> str:
return (f"Termin-Art: {terminart or '(aus Gespräch erkennen)'}\nProjektname: {projekt or '(keiner)'}\n"
f"Kunde / Unternehmen: {kunde or '(aus Gespräch erkennen)'}\nAnsprechpartner Kunde: (aus Gespräch erkennen)\n"
f"Meeting-Titel: {title}\n\nGesprächsprotokoll:\n{transcript}")
def default_transcript(tenant_id: str) -> str:
return ROTARY_TRANSCRIPT if tenant_id.startswith("rotary") else SAMPLE_TRANSCRIPT
def prompt_test(db, tenant, cfg, files, transcript: str | None, terminart="", projekt="", kunde="") -> dict:
transcript = (transcript or "").strip() or default_transcript(tenant.id)
r = preview.run_content(db, tenant, cfg, files, "prompt",
args={"title": "Prompt-Test", "terminart": terminart, "projekt": projekt, "kunde": kunde})
out = llm.summarize(db, r["prompt"], user_content(transcript, terminart, projekt, kunde, "Prompt-Test"), tenant_id=tenant.id)
out["prompt_chars"] = r["chars"]
return out

110
app/agent/tools.py Normal file
View File

@@ -0,0 +1,110 @@
"""Werkzeuge des Agenten (neutrale Definition + Ausführung auf dem Workspace)."""
from __future__ import annotations
import io
import json
from app.agent.workspace import Workspace, is_personal
from app.services import llm, preview
from app.settings import PLATFORM_DIR
TOOLS = [
{"name": "list_files", "description": "Listet alle Dateien des Tenants (inkl. tenant.json) mit Größe und Änderungsstatus.",
"schema": {"type": "object", "properties": {}}},
{"name": "read_file", "description": "Liest eine Textdatei des Tenants (prompt.txt, protocol.html, protocol.css, overrides.py, tenant.json …). "
"Fehlt eine Datei, gilt der Core-Standard – nutze dann read_core_default.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "read_core_default", "description": "Liest eine Standarddatei des Cores als Vorlage: prompt.txt, protocol.html, protocol.css oder hooks.py (Hook-Dokumentation).",
"schema": {"type": "object", "properties": {"name": {"type": "string", "enum": ["prompt.txt", "protocol.html", "protocol.css", "hooks.py"]}}, "required": ["name"]}},
{"name": "write_file", "description": "Schreibt/ersetzt eine Textdatei des Tenants vollständig (z. B. um eine eigene prompt.txt oder protocol.css anzulegen). Nur im Entwurf – ein Mensch gibt frei.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Ersetzt in einer Datei genau eine Stelle: old_str muss exakt 1× vorkommen. Bevorzuge das gegenüber write_file bei kleinen Änderungen.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_str": {"type": "string"}, "new_str": {"type": "string"}}, "required": ["path", "old_str", "new_str"]}},
{"name": "delete_file", "description": "Löscht eine Tenant-Datei (z. B. um auf den Core-Standard zurückzufallen).",
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "set_config", "description": "Setzt einen Wert in tenant.json per Punkt-Pfad, z. B. key='colors.accent', value='#0B6E4F' oder key='flags.ui_kontakt_field', value=false.",
"schema": {"type": "object", "properties": {"key": {"type": "string"}, "value": {}}, "required": ["key", "value"]}},
{"name": "validate", "description": "Prüft den aktuellen Workspace mit dem ECHTEN Core (Web-UI + PDF rendern, Prompt laden). Gibt Fehler/Warnungen zurück. Nach Änderungen immer aufrufen.",
"schema": {"type": "object", "properties": {}}},
{"name": "render_pdf", "description": "Rendert das Beispielprotokoll als PDF und gibt Seitenzahl und extrahierten Text zurück, damit du Aufbau und Inhalt prüfen kannst.",
"schema": {"type": "object", "properties": {}}},
{"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.",
"schema": {"type": "object", "properties": {}}},
{"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.",
"schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}},
]
class ToolRunner:
def __init__(self, ws: Workspace):
self.ws, self.db, self.tenant = ws, ws.db, ws.tenant
self.llm_calls = 0
def _preview(self, kind, **kw):
return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw)
def run(self, name: str, a: dict) -> tuple[str, str]:
"""→ (Kurzbeschreibung des Arguments für die Anzeige, Ergebnistext)."""
try:
return getattr(self, f"t_{name}")(a)
except (KeyError, ValueError, PermissionError, SyntaxError, json.JSONDecodeError) as exc:
return a.get("path") or a.get("key") or "", f"FEHLER: {exc}"
except preview.PreviewError as exc:
return "", f"FEHLER (Core-Rendering): {str(exc)[-900:]}"
def t_list_files(self, a):
rows = [{"path": "tenant.json", "text": True}] + self.ws.list_files()
return "", json.dumps(rows, ensure_ascii=False)
def t_read_file(self, a):
return a["path"], self.ws.read(a["path"])
def t_read_core_default(self, a):
n = a["name"]
p = (PLATFORM_DIR / "core" / ("hooks.py" if n == "hooks.py" else ("defaults" if n == "prompt.txt" else "templates")) / n) \
if n != "hooks.py" else PLATFORM_DIR / "core" / "hooks.py"
return n, p.read_text(encoding="utf-8")[:60_000]
def t_write_file(self, a):
self.ws.write(a["path"], a["content"])
return a["path"], f"geschrieben ({len(a['content'])} Zeichen)"
def t_edit_file(self, a):
self.ws.edit(a["path"], a["old_str"], a["new_str"])
return a["path"], "ersetzt"
def t_delete_file(self, a):
self.ws.delete(a["path"])
return a["path"], "gelöscht"
def t_set_config(self, a):
self.ws.set_config_value(a["key"], a["value"])
return a["key"], f"gesetzt = {json.dumps(a['value'], ensure_ascii=False)}"
def t_validate(self, a):
r = self._preview("validate")
return "Core-Rendering", json.dumps(r, ensure_ascii=False)
def t_render_pdf(self, a):
pdf = self._preview("pdf")
from pypdf import PdfReader
rd = PdfReader(io.BytesIO(pdf))
text = "\n".join((pg.extract_text() or "") for pg in rd.pages)
return "Beispielprotokoll", f"{len(rd.pages)} Seite(n)\n---\n{text[:3500]}"
def t_show_final_prompt(self, a):
r = self._preview("prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""})
p = r["prompt"]
# Namenslisten (personenbezogen) nicht an das Agenten-Modell geben
marker = "Namensregister"
if marker in p:
p = p[:p.index(marker)] + "[Namensregister mit personenbezogenen Daten ausgeblendet]"
return f"{r['chars']} Zeichen", p[:40_000]
def t_run_prompt_test(self, a):
if self.llm_calls >= 1:
return "", "FEHLER: Pro Antwort ist nur ein Prompt-Test erlaubt."
self.llm_calls += 1
from app.agent.testrun import prompt_test
res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript"))
return "Beispieltranskript", f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens]\n{res['text'][:6000]}"

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}