adaptive-rag / scripts /seed_demo_data.py
mcikalmerdeka's picture
feat: add HF Spaces deployment with pre-cached models + Neon Postgres
583e9cc
Raw
History Blame Contribute Delete
15.8 kB
"""Seed the demo Postgres with a small e-commerce dataset.
Usage:
uv run python scripts/seed_demo_data.py # idempotent re-seed
uv run python scripts/seed_demo_data.py --recreate # drop everything first
Connects to ``SQL_DATABASE_URL`` if set, else assumes the bundled
docker-compose Postgres at ``postgresql+psycopg://adaptive_rag:adaptive_rag@localhost:5432/adaptive_rag``.
Creates:
- 5 base tables (customers, products, orders, order_items, refunds)
- ~100 customers, ~50 products, ~500 orders, ~1000 line items, ~50 refunds
- A dedicated ``adaptive_rag_ro`` read-only role with SELECT grants only
The data is generated with a fixed RNG seed so re-running gives the same
rows back. Useful for repeatable demos and eval.
"""
from __future__ import annotations
import argparse
import logging
import random
import sys
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import create_engine, text # noqa: E402
from sqlalchemy.engine import Engine # noqa: E402
from src.config import settings # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("seed_demo_data")
# ---- defaults --------------------------------------------------------------
DEFAULT_ADMIN_URL = "postgresql+psycopg://adaptive_rag:adaptive_rag@localhost:5433/adaptive_rag"
RO_ROLE = "adaptive_rag_ro"
RO_PASSWORD = "adaptive_rag_ro"
RNG_SEED = 42
# Keep the dataset deliberately small — large enough for non-trivial queries,
# small enough to seed in <1s and reason about by hand.
N_CUSTOMERS = 100
N_PRODUCTS = 50
N_ORDERS = 500
REFUND_RATE = 0.10 # ~10% of completed orders get refunded
# ---- DDL -------------------------------------------------------------------
DROP_SQL = """
DROP TABLE IF EXISTS refunds CASCADE;
DROP TABLE IF EXISTS order_items CASCADE;
DROP TABLE IF EXISTS orders CASCADE;
DROP TABLE IF EXISTS products CASCADE;
DROP TABLE IF EXISTS customers CASCADE;
"""
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
country TEXT NOT NULL,
signup_date DATE NOT NULL
);
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL,
stock INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'refunded', 'cancelled')),
total NUMERIC(10, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(10, 2) NOT NULL
);
CREATE TABLE IF NOT EXISTS refunds (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL UNIQUE REFERENCES orders(id),
amount NUMERIC(10, 2) NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_customer_id ON orders(customer_id);
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_refunds_created_at ON refunds(created_at);
"""
# ---- synthetic data --------------------------------------------------------
FIRST_NAMES = [
"Alex", "Sam", "Jordan", "Taylor", "Morgan", "Riley", "Casey", "Avery",
"Quinn", "Skyler", "Kai", "Aditi", "Wei", "Yuki", "Hugo", "Leila",
"Mateo", "Priya", "Sven", "Mia", "Ravi", "Jin", "Noor", "Ezra",
]
LAST_NAMES = [
"Lopez", "Smith", "Patel", "Nguyen", "Tanaka", "Müller", "Rossi",
"Kim", "Cohen", "García", "Sato", "Singh", "Lee", "Wang", "Khan",
"Andersen", "Dubois", "Silva", "Costa", "Brown", "Jones", "Davies",
]
COUNTRIES = ["US", "UK", "DE", "JP", "BR", "ID", "IN", "FR", "AU", "CA"]
PRODUCT_TEMPLATES = [
("Wireless Headphones", "audio", 129.99),
("USB-C Charger", "accessories", 29.99),
("4K Webcam", "video", 149.99),
("Mechanical Keyboard", "input", 189.00),
("Ergonomic Mouse", "input", 69.50),
("Standing Desk Mat", "furniture", 79.00),
("Noise-Cancelling Buds", "audio", 199.00),
("HDMI Cable 2m", "accessories", 14.99),
("Laptop Stand", "furniture", 49.00),
("Webcam Privacy Cover", "accessories", 9.99),
]
REFUND_REASONS = [
"defective on arrival",
"wrong item shipped",
"no longer needed",
"did not match description",
"shipping took too long",
"found cheaper elsewhere",
]
def _seed_customers(conn, rng: random.Random) -> int:
rows = []
today = date.today()
used_emails: set[str] = set()
for i in range(1, N_CUSTOMERS + 1):
first = rng.choice(FIRST_NAMES)
last = rng.choice(LAST_NAMES)
country = rng.choice(COUNTRIES)
signup = today - timedelta(days=rng.randint(1, 730))
# Disambiguate duplicate emails deterministically.
suffix = i
while True:
email = f"{first.lower()}.{last.lower()}{suffix}@example.com"
if email not in used_emails:
break
suffix += 1
used_emails.add(email)
rows.append(
{
"name": f"{first} {last}",
"email": email,
"country": country,
"signup_date": signup,
}
)
conn.execute(
text(
"INSERT INTO customers (name, email, country, signup_date) "
"VALUES (:name, :email, :country, :signup_date)"
),
rows,
)
return len(rows)
def _seed_products(conn, rng: random.Random) -> int:
rows = []
for i in range(N_PRODUCTS):
base = PRODUCT_TEMPLATES[i % len(PRODUCT_TEMPLATES)]
name, category, base_price = base
# Variant suffix so we get N_PRODUCTS rows with stable names.
variant = chr(ord("A") + (i // len(PRODUCT_TEMPLATES)))
full_name = f"{name} {variant}" if i >= len(PRODUCT_TEMPLATES) else name
rows.append(
{
"name": full_name,
"category": category,
"price": Decimal(str(round(base_price * rng.uniform(0.9, 1.1), 2))),
"stock": rng.randint(0, 200),
}
)
conn.execute(
text(
"INSERT INTO products (name, category, price, stock) "
"VALUES (:name, :category, :price, :stock)"
),
rows,
)
return len(rows)
def _seed_orders_and_items(conn, rng: random.Random) -> tuple[int, int, int]:
customer_ids = [r[0] for r in conn.execute(text("SELECT id FROM customers")).all()]
products = conn.execute(text("SELECT id, price FROM products")).all()
now = datetime.now(timezone.utc)
# Single-row inserts: psycopg3 + SQLAlchemy's executemany doesn't
# reliably return rows from RETURNING. 500 individual inserts is still
# well under a second and keeps the script simple.
insert_order = text(
"INSERT INTO orders (customer_id, status, total, created_at) "
"VALUES (:customer_id, :status, :total, :created_at) "
"RETURNING id"
)
order_ids: list[int] = []
for _ in range(N_ORDERS):
customer_id = rng.choice(customer_ids)
status = rng.choices(
["completed", "pending", "cancelled"],
weights=[0.75, 0.15, 0.10],
k=1,
)[0]
days_ago = rng.randint(0, 365)
created_at = now - timedelta(days=days_ago, hours=rng.randint(0, 23))
oid = conn.execute(
insert_order,
{
"customer_id": customer_id,
"status": status,
"total": Decimal("0.00"),
"created_at": created_at,
},
).scalar_one()
order_ids.append(oid)
# Items: 1-4 line items per order.
item_rows: list[dict] = []
order_totals: dict[int, Decimal] = {oid: Decimal("0.00") for oid in order_ids}
for oid in order_ids:
for _ in range(rng.randint(1, 4)):
pid, price = rng.choice(products)
qty = rng.randint(1, 3)
line_total = price * qty
order_totals[oid] += line_total
item_rows.append(
{
"order_id": oid,
"product_id": pid,
"quantity": qty,
"unit_price": price,
}
)
conn.execute(
text(
"INSERT INTO order_items (order_id, product_id, quantity, unit_price) "
"VALUES (:order_id, :product_id, :quantity, :unit_price)"
),
item_rows,
)
# Backfill order totals.
conn.execute(
text("UPDATE orders SET total = :total WHERE id = :id"),
[{"id": oid, "total": tot} for oid, tot in order_totals.items()],
)
# Refund a fraction of completed orders.
completed = conn.execute(
text(
"SELECT id, total, created_at FROM orders WHERE status = 'completed'"
)
).all()
refund_rows: list[dict] = []
for oid, total, created_at in completed:
if rng.random() < REFUND_RATE:
refund_amount = total * Decimal(str(round(rng.uniform(0.3, 1.0), 2)))
refund_rows.append(
{
"order_id": oid,
"amount": refund_amount.quantize(Decimal("0.01")),
"reason": rng.choice(REFUND_REASONS),
"created_at": created_at + timedelta(days=rng.randint(1, 14)),
}
)
if refund_rows:
conn.execute(
text(
"INSERT INTO refunds (order_id, amount, reason, created_at) "
"VALUES (:order_id, :amount, :reason, :created_at)"
),
refund_rows,
)
# Mark those orders as refunded.
conn.execute(
text(
"UPDATE orders SET status = 'refunded' "
"WHERE id = ANY(:ids)"
),
{"ids": [r["order_id"] for r in refund_rows]},
)
return len(order_ids), len(item_rows), len(refund_rows)
# ---- read-only role --------------------------------------------------------
# NOTE: the canonical Postgres way to safely interpolate the password into
# CREATE ROLE is ``format('... %L', pw)`` — but psycopg3 intercepts ``%``
# as a client-side placeholder. ``quote_literal`` is the equivalent that
# does not contain a ``%``.
def _ro_role_sql() -> str:
pw_literal = "'" + RO_PASSWORD.replace("'", "''") + "'"
return f"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{RO_ROLE}') THEN
EXECUTE 'CREATE ROLE {RO_ROLE} LOGIN PASSWORD ' || quote_literal({pw_literal});
END IF;
END $$;
GRANT CONNECT ON DATABASE adaptive_rag TO {RO_ROLE};
GRANT USAGE ON SCHEMA public TO {RO_ROLE};
GRANT SELECT ON ALL TABLES IN SCHEMA public TO {RO_ROLE};
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO {RO_ROLE};
"""
# ---- driver ----------------------------------------------------------------
def _resolve_admin_url() -> str:
# Prefer the env var if set, else assume bundled docker compose.
raw = settings.SQL_DATABASE_URL or DEFAULT_ADMIN_URL
# If the env var points at the read-only role, swap to the admin role for
# seeding (the seed needs CREATE TABLE / INSERT).
if RO_ROLE in raw:
logger.info(
f"SQL_DATABASE_URL targets the read-only role; using "
f"{DEFAULT_ADMIN_URL} for seeding instead."
)
return DEFAULT_ADMIN_URL
return raw
def main() -> int:
parser = argparse.ArgumentParser(description="Seed demo Postgres data.")
parser.add_argument(
"--recreate",
action="store_true",
help="Drop tables before recreating. Default appends nothing if tables exist.",
)
args = parser.parse_args()
url = _resolve_admin_url()
logger.info(f"Connecting to {_redact(url)}")
engine: Engine = create_engine(url, isolation_level="AUTOCOMMIT", future=True)
with engine.begin() as conn:
if args.recreate:
logger.info("Dropping existing tables")
conn.exec_driver_sql(DROP_SQL)
# Probe whether the schema is already populated.
existing = conn.execute(
text("SELECT to_regclass('public.customers')")
).scalar()
if existing and not args.recreate:
count = conn.execute(text("SELECT COUNT(*) FROM customers")).scalar()
if count and count > 0:
logger.info(
f"customers table already has {count} rows. "
"Pass --recreate to wipe and reseed."
)
_ensure_ro_role(conn)
_print_summary(conn)
return 0
logger.info("Creating schema")
conn.exec_driver_sql(CREATE_SQL)
rng = random.Random(RNG_SEED)
logger.info("Seeding customers")
n_customers = _seed_customers(conn, rng)
logger.info(f" inserted {n_customers} customers")
logger.info("Seeding products")
n_products = _seed_products(conn, rng)
logger.info(f" inserted {n_products} products")
logger.info("Seeding orders + items + refunds")
n_orders, n_items, n_refunds = _seed_orders_and_items(conn, rng)
logger.info(
f" inserted {n_orders} orders, {n_items} line items, "
f"{n_refunds} refunds"
)
_ensure_ro_role(conn)
_print_summary(conn)
logger.info(
f"Done. App should connect with: "
f"{_redact(settings.SQL_DATABASE_URL or DEFAULT_ADMIN_URL)}"
)
return 0
def _ensure_ro_role(conn) -> None:
logger.info(f"Ensuring read-only role '{RO_ROLE}' exists with SELECT grants")
try:
conn.exec_driver_sql(_ro_role_sql())
except Exception as exc:
logger.warning(
f"Could not create read-only role (this is expected on managed "
f"Postgres like Neon): {exc}"
)
def _print_summary(conn) -> None:
counts = conn.execute(
text(
"""
SELECT
(SELECT COUNT(*) FROM customers) AS customers,
(SELECT COUNT(*) FROM products) AS products,
(SELECT COUNT(*) FROM orders) AS orders,
(SELECT COUNT(*) FROM order_items) AS order_items,
(SELECT COUNT(*) FROM refunds) AS refunds
"""
)
).mappings().one()
logger.info(
"Row counts: " + ", ".join(f"{k}={v}" for k, v in counts.items())
)
def _redact(url: str) -> str:
# Hide password between user: and @host
if "://" not in url or "@" not in url:
return url
scheme, rest = url.split("://", 1)
creds, host = rest.rsplit("@", 1)
if ":" in creds:
user, _ = creds.split(":", 1)
return f"{scheme}://{user}:****@{host}"
return url
if __name__ == "__main__":
raise SystemExit(main())