Skip to content

Commit 92baa24

Browse files
authored
Merge pull request #230 from CausalInferenceLab/feat/setup-wizard
feat(wizard): /setup — 비개발자용 DSN 없는 DB 연결 flow
2 parents c76918f + 602d2c1 commit 92baa24

7 files changed

Lines changed: 564 additions & 5 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Form fields → DSN assembly.
2+
3+
The setup wizard collects credentials field-by-field so non-developers never
4+
see a DSN string. Each ``build_*`` here turns those fields into the canonical
5+
SQLAlchemy/D1 URL that :func:`build_explorer` already understands. Splitting
6+
this off keeps the wizard's UI layer (Discord modals) thin and lets us unit-
7+
test the assembly without a Discord runtime.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass
13+
from urllib.parse import quote_plus
14+
15+
16+
@dataclass
17+
class ConnectionSpec:
18+
"""The wizard's output: a DSN + any out-of-band secrets the adapter needs."""
19+
20+
dsn: str
21+
extras: dict[str, str]
22+
23+
24+
# Supported DB types in the wizard. Order matters — surfaces in the dropdown.
25+
SUPPORTED_DB_TYPES: tuple[str, ...] = (
26+
"postgresql",
27+
"mysql",
28+
"snowflake",
29+
"bigquery",
30+
"duckdb",
31+
"d1",
32+
)
33+
34+
35+
def _quote(s: str) -> str:
36+
return quote_plus(s, safe="")
37+
38+
39+
def build_postgresql(*, host: str, port: str, database: str, user: str, password: str) -> ConnectionSpec:
40+
p = int(port) if port else 5432
41+
dsn = f"postgresql+psycopg://{_quote(user)}:{_quote(password)}@{host}:{p}/{database}"
42+
return ConnectionSpec(dsn=dsn, extras={})
43+
44+
45+
def build_mysql(*, host: str, port: str, database: str, user: str, password: str) -> ConnectionSpec:
46+
p = int(port) if port else 3306
47+
dsn = f"mysql+pymysql://{_quote(user)}:{_quote(password)}@{host}:{p}/{database}"
48+
return ConnectionSpec(dsn=dsn, extras={})
49+
50+
51+
def build_snowflake(
52+
*, account: str, user: str, password: str, database: str, warehouse: str
53+
) -> ConnectionSpec:
54+
dsn = (
55+
f"snowflake://{_quote(user)}:{_quote(password)}@{account}"
56+
f"/{database}?warehouse={_quote(warehouse)}"
57+
)
58+
return ConnectionSpec(dsn=dsn, extras={})
59+
60+
61+
def build_bigquery(*, project: str, dataset: str) -> ConnectionSpec:
62+
# Auth via Application Default Credentials (gcloud) — credentials are not
63+
# in the DSN. We document this in the wizard's success message.
64+
dsn = f"bigquery://{project}/{dataset}"
65+
return ConnectionSpec(dsn=dsn, extras={})
66+
67+
68+
def build_duckdb(*, path: str) -> ConnectionSpec:
69+
return ConnectionSpec(dsn=f"duckdb:///{path}", extras={})
70+
71+
72+
def build_d1(*, account_id: str, database_id: str, api_token: str) -> ConnectionSpec:
73+
# The token doesn't go in the URL — it's an out-of-band header.
74+
return ConnectionSpec(
75+
dsn=f"d1://{account_id}/{database_id}",
76+
extras={"d1_token": api_token},
77+
)
78+
79+
80+
# Field schemas surfaced by the Discord Modal layer. Each entry is
81+
# (label, placeholder, required, masked).
82+
FIELD_SCHEMA: dict[str, list[tuple[str, str, bool, bool]]] = {
83+
"postgresql": [
84+
("host", "db.example.com", True, False),
85+
("port", "5432", False, False),
86+
("database", "analytics", True, False),
87+
("user", "readonly_user", True, False),
88+
("password", "•••••", True, True),
89+
],
90+
"mysql": [
91+
("host", "db.example.com", True, False),
92+
("port", "3306", False, False),
93+
("database", "analytics", True, False),
94+
("user", "readonly_user", True, False),
95+
("password", "•••••", True, True),
96+
],
97+
"snowflake": [
98+
("account", "abc12345.us-east-1", True, False),
99+
("user", "readonly_user", True, False),
100+
("password", "•••••", True, True),
101+
("database", "ANALYTICS", True, False),
102+
("warehouse", "COMPUTE_WH", True, False),
103+
],
104+
"bigquery": [
105+
("project", "my-gcp-project", True, False),
106+
("dataset", "analytics", True, False),
107+
],
108+
"duckdb": [
109+
("path", "/data/warehouse.duckdb", True, False),
110+
],
111+
"d1": [
112+
("account_id", "Cloudflare account ID", True, False),
113+
("database_id", "D1 database ID", True, False),
114+
("api_token", "Cloudflare API token", True, True),
115+
],
116+
}
117+
118+
119+
_BUILDERS = {
120+
"postgresql": build_postgresql,
121+
"mysql": build_mysql,
122+
"snowflake": build_snowflake,
123+
"bigquery": build_bigquery,
124+
"duckdb": build_duckdb,
125+
"d1": build_d1,
126+
}
127+
128+
129+
def assemble(db_type: str, fields: dict[str, str]) -> ConnectionSpec:
130+
"""Dispatch by ``db_type`` to the matching builder.
131+
132+
The wizard hands raw modal inputs in ``fields``; this is the one entry
133+
point so the UI layer stays dialect-agnostic.
134+
"""
135+
builder = _BUILDERS.get(db_type)
136+
if builder is None:
137+
raise ValueError(f"unsupported db type: {db_type!r}")
138+
# Filter to the expected kwargs (modal can hand stray keys safely).
139+
expected = {name for name, *_ in FIELD_SCHEMA[db_type]}
140+
cleaned = {k: (v or "").strip() for k, v in fields.items() if k in expected}
141+
missing = [n for n, _, req, _ in FIELD_SCHEMA[db_type] if req and not cleaned.get(n)]
142+
if missing:
143+
raise ValueError(f"missing required fields: {', '.join(missing)}")
144+
return builder(**cleaned)

src/lang2sql/adapters/db/factory.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@
2121
from .sqlalchemy_explorer import SqlAlchemyExplorer
2222

2323

24-
def build_explorer(connection: str, *, schema: str | None = None) -> ExplorerPort:
24+
def build_explorer(
25+
connection: str,
26+
*,
27+
schema: str | None = None,
28+
extras: dict | None = None,
29+
) -> ExplorerPort:
2530
"""Route a connection string to the matching explorer adapter.
2631
2732
``schema`` is forwarded to the SQLAlchemy explorer (ignored by D1, which is
28-
schema-less SQLite). Raises ``ValueError`` on an empty/unparseable string.
33+
schema-less SQLite). ``extras`` carries per-adapter secrets that don't
34+
belong in the URL — currently ``d1_token`` for the D1 HTTP API. Raises
35+
``ValueError`` on an empty/unparseable string.
2936
"""
3037
if not connection or not connection.strip():
3138
raise ValueError("empty connection string")
@@ -34,13 +41,19 @@ def build_explorer(connection: str, *, schema: str | None = None) -> ExplorerPor
3441
if not scheme:
3542
raise ValueError(f"connection string has no scheme: {connection!r}")
3643

44+
extras = extras or {}
45+
3746
if scheme == "d1":
3847
parts = urlsplit(connection)
3948
account_id = parts.netloc
4049
database_id = parts.path.lstrip("/")
4150
if not account_id or not database_id:
4251
raise ValueError("d1 URL must be d1://<account_id>/<database_id>")
43-
return D1Explorer(account_id=account_id, database_id=database_id)
52+
return D1Explorer(
53+
account_id=account_id,
54+
database_id=database_id,
55+
token=extras.get("d1_token"),
56+
)
4457

4558
# Anything else is assumed to be a SQLAlchemy URL (driver loaded lazily).
4659
return SqlAlchemyExplorer(connection, schema=schema)

src/lang2sql/frontends/discord/bot.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ def _register_commands(self) -> None:
109109
tree = self.tree
110110
handlers = self._handlers
111111

112+
@tree.command(name="setup", description="Connect a database with a guided form (no DSN needed)")
113+
async def setup(interaction: discord.Interaction) -> None:
114+
from .setup_wizard import start_setup_flow # local import — discord-only path
115+
await start_setup_flow(interaction, handlers, _interaction_context)
116+
112117
@tree.command(name="connect", description="Store a database connection string")
113118
async def connect(interaction: discord.Interaction, dsn: str) -> None:
114119
await self._run(interaction, handlers.connect(to_identity(_interaction_context(interaction)), dsn))

src/lang2sql/frontends/discord/commands.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
from datetime import datetime, timezone
2020

21+
from ...adapters.db import build_explorer
22+
from ...adapters.db.dsn_builder import assemble
2123
from ...core.identity import Identity
2224
from ...core.ports.frontend import OutboundMessage
2325
from ...harness.loop import agent_loop
@@ -93,6 +95,60 @@ async def audit_me(self, identity: Identity) -> OutboundMessage:
9395
lines.append(f"- {_fmt_ts(event.ts)} {event.action} @ {event.scope}")
9496
return OutboundMessage(text="\n".join(lines))
9597

98+
async def register_db_for_guild(
99+
self,
100+
identity: Identity,
101+
db_type: str,
102+
fields: dict[str, str],
103+
) -> OutboundMessage:
104+
"""The /setup wizard's commit step (non-developer entry point).
105+
106+
Takes the wizard's per-field inputs (no DSN literals), assembles the
107+
DSN, tests the connection by listing tables once, and on success
108+
stores the DSN (+ any out-of-band token) under the guild's scope via
109+
:class:`EncryptedSecrets`. The next ``build_context`` for this guild
110+
will use this DB transparently.
111+
"""
112+
try:
113+
spec = assemble(db_type, fields)
114+
except ValueError as exc:
115+
return OutboundMessage(text=f"⚠️ Setup error: {exc}")
116+
117+
try:
118+
explorer = build_explorer(spec.dsn, extras=spec.extras)
119+
tables = await explorer.list_tables()
120+
except ModuleNotFoundError as exc:
121+
return OutboundMessage(
122+
text=(
123+
f"⚠️ Connection driver not installed for {db_type}. "
124+
f"Ask an admin to run `uv sync --extra {db_type}`.\n"
125+
f"(details: {exc})"
126+
)
127+
)
128+
except Exception as exc: # surface what the DB said, but stay user-friendly
129+
return OutboundMessage(
130+
text=(
131+
f"❌ Couldn't connect to {db_type}: {type(exc).__name__}: {exc}.\n"
132+
"Common causes: wrong host/port, network/firewall, "
133+
"wrong credentials, or read permission missing."
134+
)
135+
)
136+
137+
scope = identity.guild_id or f"dm:{identity.user_id}"
138+
await self._concierge.secrets.set(scope, "db_dsn", spec.dsn)
139+
for k, v in spec.extras.items():
140+
await self._concierge.secrets.set(scope, f"db_extras.{k}", v)
141+
# Bust any cached explorer for this scope so the next turn picks it up.
142+
self._concierge.forget_explorer(scope)
143+
144+
return OutboundMessage(
145+
text=(
146+
f"✅ Connected to **{db_type}** — found **{len(tables)} table(s)**. "
147+
"Your credentials are stored encrypted; you can `/semantic_show` "
148+
"or just ask a question now."
149+
)
150+
)
151+
96152
async def connect(self, identity: Identity, dsn: str) -> OutboundMessage:
97153
"""V1 stub: stash a DB DSN keyed by guild/DM in the concierge kv store.
98154
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""``/setup`` — a zero-DSN connection wizard for non-developers.
2+
3+
The user never sees a SQLAlchemy URL or an env file. They run ``/setup``, pick
4+
their database from a dropdown, and fill a short form. We assemble the DSN,
5+
test the connection by listing tables, and store the credentials encrypted via
6+
:class:`EncryptedSecrets` keyed by the guild scope. The next message in that
7+
guild transparently uses the new database.
8+
9+
Discord coupling lives only here and in ``bot.py``: the actual register-and-
10+
test logic is :meth:`CommandHandlers.register_db_for_guild` (pure, testable).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import TYPE_CHECKING
16+
17+
import discord
18+
from discord import ui
19+
20+
from ...adapters.db.dsn_builder import FIELD_SCHEMA, SUPPORTED_DB_TYPES
21+
from .session_router import to_identity
22+
23+
if TYPE_CHECKING:
24+
from .commands import CommandHandlers
25+
from .bot import InteractionContext
26+
27+
28+
# Per-DB human labels surfaced in the dropdown.
29+
_LABELS: dict[str, str] = {
30+
"postgresql": "PostgreSQL",
31+
"mysql": "MySQL",
32+
"snowflake": "Snowflake",
33+
"bigquery": "BigQuery",
34+
"duckdb": "DuckDB (file)",
35+
"d1": "Cloudflare D1",
36+
}
37+
38+
39+
class _ConnectionFormModal(ui.Modal):
40+
"""The per-DB-type form. Fields come from :data:`FIELD_SCHEMA`.
41+
42+
Discord modals cap at 5 :class:`ui.TextInput` rows, which matches our
43+
widest schema (Postgres/MySQL/Snowflake). Passwords/tokens are plain text
44+
inputs — Discord has no masked input style — but the form is ephemeral so
45+
only the user sees what they typed.
46+
"""
47+
48+
def __init__(
49+
self,
50+
db_type: str,
51+
handlers: "CommandHandlers",
52+
ctx_factory,
53+
) -> None:
54+
super().__init__(title=f"Connect to {_LABELS.get(db_type, db_type)}")
55+
self._db_type = db_type
56+
self._handlers = handlers
57+
self._ctx_factory = ctx_factory # () -> InteractionContext
58+
self._inputs: dict[str, ui.TextInput] = {}
59+
for name, placeholder, required, _masked in FIELD_SCHEMA[db_type]:
60+
inp = ui.TextInput(
61+
label=name,
62+
placeholder=placeholder,
63+
required=required,
64+
style=discord.TextStyle.short,
65+
max_length=200,
66+
)
67+
self._inputs[name] = inp
68+
self.add_item(inp)
69+
70+
async def on_submit(self, interaction: discord.Interaction) -> None:
71+
# Connection test can take a few seconds; defer so Discord doesn't
72+
# timeout the interaction. Ephemeral so only the user sees the result.
73+
await interaction.response.defer(ephemeral=True, thinking=True)
74+
fields = {name: inp.value for name, inp in self._inputs.items()}
75+
identity = to_identity(self._ctx_factory(interaction))
76+
result = await self._handlers.register_db_for_guild(
77+
identity, self._db_type, fields
78+
)
79+
await interaction.followup.send(result.text, ephemeral=True)
80+
81+
82+
class _DbTypeSelect(ui.Select):
83+
"""Step 1 dropdown — pick which DB type to connect."""
84+
85+
def __init__(self, handlers: "CommandHandlers", ctx_factory) -> None:
86+
options = [
87+
discord.SelectOption(label=_LABELS[t], value=t) for t in SUPPORTED_DB_TYPES
88+
]
89+
super().__init__(
90+
placeholder="Choose your database…",
91+
options=options,
92+
min_values=1,
93+
max_values=1,
94+
)
95+
self._handlers = handlers
96+
self._ctx_factory = ctx_factory
97+
98+
async def callback(self, interaction: discord.Interaction) -> None:
99+
# Opening a modal *is* the response to this select interaction.
100+
await interaction.response.send_modal(
101+
_ConnectionFormModal(self.values[0], self._handlers, self._ctx_factory)
102+
)
103+
104+
105+
class _SetupView(ui.View):
106+
"""Holds the DB-type dropdown. Auto-times out after 2 minutes."""
107+
108+
def __init__(self, handlers: "CommandHandlers", ctx_factory) -> None:
109+
super().__init__(timeout=120.0)
110+
self.add_item(_DbTypeSelect(handlers, ctx_factory))
111+
112+
113+
async def start_setup_flow(
114+
interaction: discord.Interaction,
115+
handlers: "CommandHandlers",
116+
ctx_factory,
117+
) -> None:
118+
"""Entry point bot.py wires to ``/setup`` — surfaces the picker ephemerally."""
119+
await interaction.response.send_message(
120+
"Let's connect your database. Pick its type, then fill the form. "
121+
"Your credentials are stored encrypted; nobody else sees what you type.",
122+
view=_SetupView(handlers, ctx_factory),
123+
ephemeral=True,
124+
)

0 commit comments

Comments
 (0)