"""Demo-Workspaces für Vorführungen und den PoC (`aiw demo`): Repository, Datei zur Analyse, Dokumentenordner, Beispielfragen; oder ein Workspace ganz ohne Daten (nur Chat) zum Vergleich. Jede Demo legt alles reproduzierbar an (gleiche Daten bei jedem Lauf); die Inhalte stehen in den Modulen dieses Pakets, hier nur der gemeinsame Ablauf. """ import io import os import shutil import subprocess import tempfile from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime, timedelta from pathlib import Path from sqlalchemy import select from app.core.config import Settings from app.core.crypto import Vault from app.core.database import Database # Demo-Nutzer (aiw seed-demo) heißen nach ihrer Rolle; so auch die Autoren der Commits ADMIN = "admin" MEMBERS = {"entwicklung": "member", "fachbereich": "member", "freigabe": "approver"} AUTHORS = {"entwicklung": ("Entwicklung", "entwicklung@demo.example"), "fachbereich": ("Fachbereich", "fachbereich@demo.example"), "admin": ("Admin", "admin@demo.example")} LOGIN = "Anmelden als admin, entwicklung, fachbereich oder freigabe (Passwort AIW_DEMO_PASSWORD)." # Commit: (Tage zurück, Autor, Nachricht, {Pfad: Inhalt}); Branch: (Name, Tage zurück, Autor, Nachricht, Dateien), # abgezweigt vom vorletzten Commit auf main Commit = tuple[int, str, str, dict[str, str]] def day(days: int) -> date: """Datum relativ zu heute: Protokolle passen so zeitlich zu den Commits, egal wann die Demo angelegt wird.""" return date.today() - timedelta(days=days) @dataclass(frozen=True) class Demo: key: str name: str description: str examples: list[str] # Alles Folgende optional: ohne Repository, Datei und Ordner ist es ein Workspace nur für den Chat repo: str = "" commits: tuple[Commit, ...] | list[Commit] = () branch: tuple[str, int, str, str, dict[str, str]] | None = None upload: tuple[str, Callable[[], bytes]] | None = None folder: str = "" documents: dict[str, str] | None = None # Zusätzlich anbinden (z. B. Beispieldatenbank): (db, settings, user_id, workspace_id), auch bei vorhandenem # Workspace extra: Callable[[Database, Settings, int, int], None] | None = None def git(cwd: Path, *args: str, env: dict | None = None) -> None: subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, env={**os.environ, **(env or {})}) def write_files(root: Path, files: dict[str, str]) -> None: for path, content in files.items(): target = root / path target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content) def commit(work: Path, days: int, author: str, message: str) -> None: name, email = AUTHORS[author] when = (datetime.now().astimezone() - timedelta(days=days)).replace(hour=10, minute=15, second=0, microsecond=0).isoformat() git(work, "add", "-A") git(work, "commit", "-q", "-m", message, env={ "GIT_AUTHOR_NAME": name, "GIT_AUTHOR_EMAIL": email, "GIT_AUTHOR_DATE": when, "GIT_COMMITTER_NAME": name, "GIT_COMMITTER_EMAIL": email, "GIT_COMMITTER_DATE": when}) def build_repo(demo: Demo, target: Path) -> None: """Bare-Repository mit Verlauf und einem Feature-Branch.""" if target.exists(): shutil.rmtree(target) target.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) / "work" work.mkdir() git(work, "init", "-q", "-b", "main") for days, author, message, files in demo.commits: write_files(work, files) commit(work, days, author, message) branch, days, author, message, files = demo.branch git(work, "checkout", "-q", "-b", branch, "main~1") write_files(work, files) commit(work, days, author, message) git(work, "checkout", "-q", "main") git(work.parent, "clone", "-q", "--bare", str(work), str(target.resolve())) def repo_path(settings: Settings, demo: Demo) -> Path: return Path(settings.git_cache_dir).parent / "demo" / f"{demo.repo}.git" def all_demos() -> dict[str, Demo]: from app.demo import bestellservice, chat, mcp return {d.key: d for d in (bestellservice.DEMO, mcp.DEMO, chat.DEMO)} def run(settings: Settings, keys: list[str] | None = None, reset: bool = False) -> int: from app.cli import seed_demo from app.identity.models import User from app.identity.service import principal_for from app.tools import service as tools demos = all_demos() chosen = [demos[k] for k in keys] if keys else list(demos.values()) seed_demo(settings) db = Database(settings.database_url) try: with db.session() as session: admin = session.scalar(select(User).where(User.username == ADMIN)) classified = tools.classify_suggested(session, principal_for(session, admin.id)) if classified: print(f"{classified} eingebaute Tools nach Vorschlag eingestuft (wie „Vorschläge übernehmen“ unter Tools).") for demo in chosen: create(db, settings, demo, admin.id, reset) print(f"Fertig. {LOGIN}") return 0 finally: db.dispose() def create(db: Database, settings: Settings, demo: Demo, user_id: int, reset: bool) -> None: from app.ai import service as ai from app.data_sources import service as sources from app.identity.models import Team from app.identity.service import principal_for from app.uploads import service as uploads from app.workspaces import removal from app.workspaces import service as workspaces from app.workspaces.models import Workspace vault = Vault(settings.secret_key) with db.session() as session: team = session.scalar(select(Team).where(Team.slug == "demo")) existing = session.scalar(select(Workspace).where(Workspace.team_id == team.id, Workspace.name == demo.name, Workspace.deleted.is_(None))) if existing is not None and not reset: print(f"„{demo.name}“ existiert schon (ID {existing.id}). Neu anlegen mit: aiw demo {demo.key} --reset") existing_id = existing.id else: existing_id = None if existing is not None and reset: removal.remove(session, settings, existing, None) print(f"Alten Workspace „{demo.name}“ gelöscht.") if existing_id is not None: if demo.extra: demo.extra(db, settings, user_id, existing_id) return with db.session() as session: principal = principal_for(session, user_id) workspace = workspaces.create(session, principal, team.id, demo.name, demo.description) workspace_id = workspace.id for username, role in MEMBERS.items(): workspaces.set_member(session, principal, workspace_id, username, role) workspaces.set_examples(session, principal, workspace_id, "\n".join(demo.examples)) if not demo.repo and not demo.upload and not demo.folder: workspaces.set_chat_only(session, principal, workspace_id, True) model = ai.effective_model(session, workspace) print(f"„{demo.name}“ angelegt (ID {workspace_id}), Mitglieder admin (Admin), entwicklung, fachbereich, " "freigabe (Freigeber).") if not demo.repo and not demo.upload and not demo.folder: print(" Keine Datenquellen: Chat ohne Kontext eingeschaltet (Einstellungen → Allgemein).") # Repository if demo.repo and settings.git_allow_local: target = repo_path(settings, demo) build_repo(demo, target) with db.session() as session: source = sources.create_git(session, principal, settings, vault, workspace_id, name=demo.repo, url=target.resolve().as_uri(), branch="main", token="", exposure_confirmed=True) source_id = source.id with db.session() as session: source = sources.require_testable(session, principal, source_id) result = sources.run_test(source, settings, vault) with db.session() as session: sources.save_test(session, principal, source_id, result) print(f" Repository {demo.repo} ({len(demo.commits)} Commits auf main, Branch {demo.branch[0]}): " f"{'verbunden' if result.ok else 'Fehler: ' + result.message}") elif demo.repo: print(" Repository übersprungen: lokale Git-Quellen sind aus (AIW_GIT_ALLOW_LOCAL=true für die Demo).") # Datei zur Analyse if demo.upload: filename, generate = demo.upload with db.session() as session: upload = uploads.save(session, principal, settings, workspace_id, filename, io.BytesIO(generate())) print(f" Datei {filename}: {upload.row_count} Zeilen als Tabelle {upload.table_name}.") # Dokumentenordner (Textdateien, brauchen keine Dokumenten-Auslese) if demo.folder: add_folder(db, settings, principal, workspace_id, demo) if demo.extra: demo.extra(db, settings, user_id, workspace_id) if model is None: print(" Hinweis: Noch kein Modell eingerichtet; das macht ein Plattform-Admin unter „Modelle und Zugänge“.") def add_folder(db: Database, settings: Settings, principal, workspace_id: int, demo: Demo) -> None: from app.documents import service as documents with db.session() as session: folder = documents.create(session, principal, workspace_id, demo.folder, documents.WORKSPACE, True) folder_id = folder.id modified = int(datetime.now().timestamp() * 1000) with db.session() as session: for path, text in demo.documents.items(): documents.put(session, principal, settings, folder_id, path, modified, io.BytesIO(text.encode())) documents.prune(session, principal, settings, folder_id, list(demo.documents)) print(f" Ordner {demo.folder}: {len(demo.documents)} Dokumente (für den ganzen Workspace).")