bifroest/app/demo/__init__.py

219 lines
9.9 KiB
Python

"""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).")