226 lines
11 KiB
Python
226 lines
11 KiB
Python
"""Beispieldatenbank für die Demo „Bestellservice“: Kunden, Artikel, Bestellungen, Positionen, Retouren.
|
|
|
|
Liegt als eigene Datenbank im PostgreSQL von Bifröst (`<datenbank>_demo_shop`) mit einer nur lesenden Rolle.
|
|
Angebunden über den PostgreSQL-Treiber (DB-Dienst aus `scripts/dev.sh`); läuft der nicht, überspringt die Demo
|
|
diesen Teil mit einem Hinweis.
|
|
"""
|
|
|
|
import os
|
|
import random
|
|
from datetime import date, timedelta
|
|
|
|
import httpx
|
|
from sqlalchemy import create_engine, select, text
|
|
from sqlalchemy.engine import make_url
|
|
|
|
from app.core.config import Settings
|
|
from app.core.crypto import Vault
|
|
from app.core.database import Database
|
|
|
|
# Wie in scripts/dev.sh; anders über Umgebungsvariablen
|
|
DBMCP_URL = os.environ.get("AIW_DEMO_DBMCP_URL", "http://localhost:8791/mcp")
|
|
DBMCP_KEY = os.environ.get("AIW_DEMO_DBMCP_KEY", "dev-schluessel-12345678")
|
|
READER, READER_PASSWORD = "demo_leser", "demo-leser-nur-lesen"
|
|
CONNECTION = "shop"
|
|
EXAMPLES = [
|
|
"Welche Kunden haben 2026 am meisten retourniert, und aus welchen Gründen? (Shop-Datenbank)",
|
|
"Wie hat sich der Umsatz von Gewerbe- und Privatkunden je Quartal entwickelt? Bitte als Diagramm.",
|
|
]
|
|
|
|
SCHEMA = """
|
|
DROP TABLE IF EXISTS retouren, positionen, bestellungen, artikel, kunden;
|
|
CREATE TABLE kunden (
|
|
kunde_id integer PRIMARY KEY,
|
|
name text NOT NULL,
|
|
segment text NOT NULL CHECK (segment IN ('Privat', 'Gewerbe')),
|
|
stadt text NOT NULL,
|
|
region text NOT NULL,
|
|
kunde_seit date NOT NULL
|
|
);
|
|
COMMENT ON TABLE kunden IS 'Kundinnen und Kunden des Bestellservice';
|
|
CREATE TABLE artikel (
|
|
artikel_id integer PRIMARY KEY,
|
|
name text NOT NULL,
|
|
kategorie text NOT NULL,
|
|
preis_eur numeric(10, 2) NOT NULL
|
|
);
|
|
CREATE TABLE bestellungen (
|
|
bestell_id integer PRIMARY KEY,
|
|
kunde_id integer NOT NULL REFERENCES kunden,
|
|
datum date NOT NULL,
|
|
kanal text NOT NULL,
|
|
rabatt_prozent integer NOT NULL DEFAULT 0
|
|
);
|
|
COMMENT ON COLUMN bestellungen.rabatt_prozent IS 'Rabatt auf den Warenwert der Bestellung';
|
|
CREATE TABLE positionen (
|
|
bestell_id integer NOT NULL REFERENCES bestellungen,
|
|
artikel_id integer NOT NULL REFERENCES artikel,
|
|
menge integer NOT NULL,
|
|
einzelpreis_eur numeric(10, 2) NOT NULL,
|
|
PRIMARY KEY (bestell_id, artikel_id)
|
|
);
|
|
CREATE TABLE retouren (
|
|
retoure_id integer PRIMARY KEY,
|
|
bestell_id integer NOT NULL REFERENCES bestellungen,
|
|
artikel_id integer NOT NULL REFERENCES artikel,
|
|
datum date NOT NULL,
|
|
grund text NOT NULL
|
|
);
|
|
"""
|
|
|
|
ARTICLES = [("Kaffeemaschine", "Geräte", 249.00), ("Wasserkocher", "Geräte", 39.90), ("Mühle", "Geräte", 89.90),
|
|
("Milchaufschäumer", "Zubehör", 54.90), ("Kaffeebohnen 1 kg", "Verbrauch", 18.90),
|
|
("Entkalker", "Verbrauch", 7.90), ("Tassen-Set", "Zubehör", 29.90), ("Wasserfilter", "Verbrauch", 12.90)]
|
|
CITIES = {"Nord": ["Hamburg", "Bremen", "Kiel"], "Süd": ["München", "Stuttgart", "Nürnberg"],
|
|
"West": ["Köln", "Düsseldorf", "Dortmund"], "Ost": ["Berlin", "Leipzig", "Dresden"]}
|
|
FIRST = ["Lena", "Jonas", "Mia", "Felix", "Emma", "Paul", "Hanna", "Lukas", "Sophie", "Noah", "Lea", "Elias"]
|
|
LAST = ["Berger", "Krüger", "Hoffmann", "Wagner", "Becker", "Schulz", "Lange", "Vogel", "Seidel", "Brandt"]
|
|
FIRMS = ["Büro", "Kanzlei", "Praxis", "Café", "Agentur", "Werkstatt", "Hotel", "Bäckerei"]
|
|
REASONS = ["Defekt bei Lieferung", "Falscher Artikel", "Gefällt nicht", "Lieferung zu spät", "Doppelt bestellt"]
|
|
|
|
|
|
def rows() -> dict[str, list[tuple]]:
|
|
"""Reproduzierbare Daten 2025/2026; Gewerbekunden bestellen größer, einige Kunden retournieren auffällig oft."""
|
|
rng = random.Random(14)
|
|
customers = []
|
|
for n in range(1, 301):
|
|
region = rng.choice(list(CITIES))
|
|
business = rng.random() < 0.3
|
|
name = (f"{rng.choice(FIRMS)} {rng.choice(LAST)}" if business else f"{rng.choice(FIRST)} {rng.choice(LAST)}")
|
|
customers.append((n, name, "Gewerbe" if business else "Privat", rng.choice(CITIES[region]), region,
|
|
date(2023, 1, 1) + timedelta(days=rng.randrange(0, 900))))
|
|
articles = [(i + 1, name, kind, price) for i, (name, kind, price) in enumerate(ARTICLES)]
|
|
frequent = {c[0] for c in rng.sample(customers, 8)} # retournieren oft
|
|
orders, items, returns = [], [], []
|
|
start = date(2025, 1, 1)
|
|
for order_id in range(1, 2501):
|
|
customer = rng.choice(customers)
|
|
day = start + timedelta(days=rng.randrange(0, 663))
|
|
business = customer[2] == "Gewerbe"
|
|
chosen = rng.sample(articles, rng.randint(1, 3))
|
|
value = 0.0
|
|
for article in chosen:
|
|
quantity = rng.choice([1, 1, 2, 3]) * (rng.choice([2, 5, 10]) if business else 1)
|
|
items.append((order_id, article[0], quantity, article[3]))
|
|
value += quantity * article[3]
|
|
chance = 0.35 if customer[0] in frequent else 0.04
|
|
if rng.random() < chance:
|
|
returns.append((len(returns) + 1, order_id, article[0], day + timedelta(days=rng.randint(3, 20)),
|
|
rng.choice(REASONS[:2] if customer[0] in frequent else REASONS)))
|
|
orders.append((order_id, customer[0], day, rng.choice(["Online", "Online", "Filiale", "Telefon"]),
|
|
10 if value >= 500 else 0))
|
|
return {"kunden": customers, "artikel": articles, "bestellungen": orders, "positionen": items,
|
|
"retouren": returns}
|
|
|
|
|
|
def database_name(settings: Settings) -> str:
|
|
return f"{make_url(settings.database_url).database}_demo_shop"
|
|
|
|
|
|
def build(settings: Settings) -> str:
|
|
"""Datenbank, Rolle und Daten anlegen (neu befüllt bei jedem Lauf). Liefert den Datenbanknamen."""
|
|
url, name = make_url(settings.database_url), database_name(settings)
|
|
admin = create_engine(url.set(database="postgres"), isolation_level="AUTOCOMMIT")
|
|
with admin.connect() as c:
|
|
if not c.scalar(text("SELECT 1 FROM pg_database WHERE datname = :n"), {"n": name}):
|
|
c.execute(text(f'CREATE DATABASE "{name}"'))
|
|
if not c.scalar(text("SELECT 1 FROM pg_roles WHERE rolname = :r"), {"r": READER}):
|
|
c.execute(text(f"CREATE ROLE {READER} LOGIN PASSWORD '{READER_PASSWORD}'"))
|
|
admin.dispose()
|
|
engine = create_engine(url.set(database=name))
|
|
data = rows()
|
|
with engine.begin() as c:
|
|
c.execute(text(SCHEMA))
|
|
for table, values in data.items():
|
|
columns = len(values[0])
|
|
placeholders = ", ".join(f":p{i}" for i in range(columns))
|
|
c.execute(text(f"INSERT INTO {table} VALUES ({placeholders})"),
|
|
[{f"p{i}": v for i, v in enumerate(row)} for row in values])
|
|
c.execute(text(f'GRANT CONNECT ON DATABASE "{name}" TO {READER}'))
|
|
c.execute(text(f"GRANT USAGE ON SCHEMA public TO {READER}"))
|
|
c.execute(text(f"GRANT SELECT ON ALL TABLES IN SCHEMA public TO {READER}"))
|
|
engine.dispose()
|
|
return name
|
|
|
|
|
|
def reachable() -> bool:
|
|
try:
|
|
return httpx.get(DBMCP_URL.removesuffix("/mcp") + "/ready", timeout=2).status_code == 200
|
|
except httpx.HTTPError:
|
|
return False
|
|
|
|
|
|
def driver(db: Database, settings: Settings, vault: Vault, principal) -> int | None:
|
|
"""PostgreSQL-Treiber mit der URL des DB-Dienstes: vorhandenen nutzen, sonst registrieren; Tools abrufen und
|
|
die lesenden einstufen (wie ein Plattform-Admin unter „Datenbank-Treiber“)."""
|
|
from app.mcp import service as mcp
|
|
from app.mcp.models import McpServer
|
|
from app.tools import service as tools
|
|
from app.tools.models import ToolDefinition
|
|
|
|
with db.session() as session:
|
|
found = session.scalar(select(McpServer).where(McpServer.workspace_id.is_(None),
|
|
McpServer.role == mcp.DATABASE, McpServer.url == DBMCP_URL))
|
|
if found is None:
|
|
name = "postgres" if session.scalar(select(McpServer).where(McpServer.name == "postgres")) is None \
|
|
else "postgres_demo"
|
|
found = mcp.save(session, principal, settings, vault, None, name=name, label="PostgreSQL", url=DBMCP_URL,
|
|
token=DBMCP_KEY, role=mcp.DATABASE)
|
|
print(f" Datenbank-Treiber PostgreSQL registriert ({DBMCP_URL}).")
|
|
driver_id = found.id
|
|
with db.session() as session:
|
|
error, _ = mcp.discover(session, principal, settings, vault, driver_id)
|
|
if error:
|
|
print(f" Datenbank übersprungen: Tools des Treibers nicht abrufbar ({error}).")
|
|
return None
|
|
with db.session() as session:
|
|
kind = mcp.kind(session.get(McpServer, driver_id).name)
|
|
tools.sync(session)
|
|
for definition in session.scalars(select(ToolDefinition).where(ToolDefinition.kind == kind)):
|
|
if not definition.classified and definition.suggested_risk == "read":
|
|
tools.classify(session, principal, definition.name, "read", tools.ALLOW)
|
|
return driver_id
|
|
|
|
|
|
def setup(db: Database, settings: Settings, user_id: int, workspace_id: int) -> None:
|
|
"""Beispieldatenbank anlegen und im Workspace verbinden (auch in einem schon vorhandenen Demo-Workspace)."""
|
|
from app.identity.service import principal_for
|
|
from app.mcp import databases
|
|
from app.mcp.models import McpServer
|
|
from app.workspaces import service as workspaces
|
|
from app.workspaces.models import Workspace
|
|
|
|
if not reachable():
|
|
print(f" Datenbank übersprungen: kein DB-Dienst unter {DBMCP_URL} (läuft mit scripts/dev.sh).")
|
|
return
|
|
# Nur für die Demo: lokaler DB-Dienst und lokale Datenbank (scripts/dev.sh erlaubt beides ebenso)
|
|
settings = settings.model_copy(update={"mcp_allow_local": True, "db_allow_local": True})
|
|
vault = Vault(settings.secret_key)
|
|
with db.session() as session:
|
|
principal = principal_for(session, user_id)
|
|
existing = session.scalar(select(McpServer).where(McpServer.workspace_id == workspace_id,
|
|
McpServer.name == CONNECTION))
|
|
name = build(settings)
|
|
if existing is not None:
|
|
print(f" Datenbank {name}: Daten neu geladen, Verbindung „Shop-Datenbank“ besteht schon.")
|
|
return
|
|
driver_id = driver(db, settings, vault, principal)
|
|
if driver_id is None:
|
|
return
|
|
url = make_url(settings.database_url)
|
|
with db.session() as session:
|
|
server = databases.create(session, principal, settings, vault, workspace_id, driver_id=driver_id,
|
|
name=CONNECTION, label="Shop-Datenbank", host=url.host or "127.0.0.1",
|
|
port=str(url.port or 5432), database=name, user=READER, password=READER_PASSWORD,
|
|
sslmode="disable", exposure_confirmed=True)
|
|
databases.test(session, settings, vault, server, user_id)
|
|
status, message = server.status, server.status_message
|
|
workspace = session.get(Workspace, workspace_id)
|
|
examples = workspaces.examples(workspace)
|
|
added = [e for e in EXAMPLES if e not in examples]
|
|
if added:
|
|
workspaces.set_examples(session, principal, workspace_id,
|
|
"\n".join((examples + added)[:workspaces.MAX_EXAMPLES]))
|
|
counts = {table: len(values) for table, values in rows().items()}
|
|
print(f" Datenbank {name} ({counts['kunden']} Kunden, {counts['bestellungen']} Bestellungen, "
|
|
f"{counts['retouren']} Retouren) als „Shop-Datenbank“: {'verbunden' if status == 'ok' else message}")
|