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:
0
app/agent/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
129
app/agent/backends.py
Normal file
129
app/agent/backends.py
Normal 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
175
app/agent/loop.py
Normal 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
24
app/agent/testrun.py
Normal 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
110
app/agent/tools.py
Normal 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
106
app/agent/workspace.py
Normal 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}
|
||||
Reference in New Issue
Block a user