Skip to content

Commit f81c3b7

Browse files
authored
Merge pull request #234 from thrcle/feat/enrich-rowlimit-session
Feat/enrich rowlimit session
2 parents c9c8304 + db97bd4 commit f81c3b7

22 files changed

Lines changed: 533 additions & 35 deletions

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,24 @@
44
# Required to run `lang2sql-bot`; the bot exits with a clear error if it's unset.
55
DISCORD_BOT_TOKEN=
66

7+
# Set to true only when you add/remove slash commands to re-sync with Discord.
8+
# Leave unset (or false) during normal development to avoid rate limits.
9+
LANG2SQL_SYNC_COMMANDS=
10+
711
# OpenAI API key. Optional: when set, the agent uses gpt-4.1-mini. When unset,
812
# it falls back to the offline FakeLLM (deterministic canned tool cycles — fine
913
# for a smoke run, not for real answers).
1014
OPENAI_API_KEY=
1115

16+
# ── Local LLM (vLLM / Ollama) ────────────────────────────────────────────
17+
# When LANG2SQL_LLM_BASE_URL is set it takes priority over OPENAI_API_KEY.
18+
# Point to the base URL of any OpenAI-compatible server.
19+
# vLLM: http://localhost:8000
20+
# Ollama: http://localhost:11434
21+
LANG2SQL_LLM_BASE_URL=
22+
# Model name as the server expects it (e.g. Qwen/Qwen3-14B-AWQ for vLLM).
23+
LANG2SQL_LLM_MODEL=
24+
1225
# Fernet key used to encrypt stored secrets (DSNs / API keys) at rest. Optional:
1326
# if unset, a key is auto-generated and persisted in the SQLite kv table. Set it
1427
# in production so secrets decrypt across restarts and machines. Generate one:

dev.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/bash
2+
# Auto-reload dev runner for lang2sql-bot.
3+
# Watches src/ for .py changes and restarts the bot automatically.
4+
5+
set -a
6+
source "$(dirname "$0")/.env"
7+
set +a
8+
9+
REF=$(mktemp)
10+
11+
restart_bot() {
12+
if [ -n "$BOT_PID" ] && kill -0 "$BOT_PID" 2>/dev/null; then
13+
echo "[watch] stopping PID $BOT_PID..."
14+
kill "$BOT_PID"
15+
wait "$BOT_PID" 2>/dev/null
16+
fi
17+
echo "[watch] starting bot..."
18+
.venv/bin/lang2sql-bot &
19+
BOT_PID=$!
20+
touch "$REF"
21+
echo "[watch] PID $BOT_PID"
22+
}
23+
24+
trap 'kill $BOT_PID 2>/dev/null; rm -f $REF; exit' INT TERM
25+
26+
restart_bot
27+
28+
while true; do
29+
sleep 2
30+
if find src/ -name "*.py" -newer "$REF" | grep -q .; then
31+
CHANGED=$(find src/ -name "*.py" -newer "$REF" | head -3 | tr '\n' ' ')
32+
echo "[watch] changed: $CHANGED"
33+
restart_bot
34+
elif ! kill -0 "$BOT_PID" 2>/dev/null; then
35+
echo "[watch] bot crashed, restarting in 2s..."
36+
sleep 2
37+
restart_bot
38+
fi
39+
done

src/lang2sql/adapters/db/dsn_builder.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from __future__ import annotations
1111

1212
from dataclasses import dataclass
13-
from urllib.parse import quote_plus
13+
from urllib.parse import quote_plus, urlsplit
1414

1515

1616
@dataclass
@@ -37,8 +37,13 @@ def _quote(s: str) -> str:
3737

3838

3939
def build_postgresql(*, host: str, port: str, database: str, user: str, password: str) -> ConnectionSpec:
40+
# User may paste a full URL (e.g. "host/db?sslmode=require") into the host field.
41+
# Extract just the hostname to avoid corrupting the assembled DSN.
42+
parsed = urlsplit("//" + host)
43+
clean_host = parsed.hostname or host
4044
p = int(port) if port else 5432
41-
dsn = f"postgresql+psycopg://{_quote(user)}:{_quote(password)}@{host}:{p}/{database}"
45+
suffix = "?sslmode=require" if clean_host.endswith(".neon.tech") else ""
46+
dsn = f"postgresql+psycopg://{_quote(user)}:{_quote(password)}@{clean_host}:{p}/{database}{suffix}"
4247
return ConnectionSpec(dsn=dsn, extras={})
4348

4449

src/lang2sql/adapters/db/factory.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ def build_explorer(
5555
token=extras.get("d1_token"),
5656
)
5757

58+
# Normalize bare postgresql:// → postgresql+psycopg:// (psycopg3 is installed).
59+
if scheme == "postgresql":
60+
connection = "postgresql+psycopg" + connection[len("postgresql"):]
61+
5862
# Anything else is assumed to be a SQLAlchemy URL (driver loaded lazily).
5963
return SqlAlchemyExplorer(connection, schema=schema)
6064

@@ -67,7 +71,8 @@ def explorer_from_env() -> ExplorerPort | None:
6771
"""
6872
url = os.environ.get("LANG2SQL_DB_URL")
6973
if url:
70-
return build_explorer(url, schema=os.environ.get("LANG2SQL_DB_SCHEMA"))
74+
schema = os.environ.get("LANG2SQL_DB_SCHEMA") or None
75+
return build_explorer(url, schema=schema)
7176

7277
account = os.environ.get("CLOUDFLARE_D1_ACCOUNT_ID")
7378
database = os.environ.get("CLOUDFLARE_D1_DATABASE_ID")

src/lang2sql/adapters/db/sqlalchemy_explorer.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,15 @@ async def execute(self, sql: str, limit: int = 1000) -> list[dict]:
5555
def _list_tables_sync(self) -> list[Table]:
5656
from sqlalchemy import inspect
5757

58-
insp = inspect(self._get_engine())
59-
schema = self._schema or insp.default_schema_name
58+
engine = self._get_engine()
59+
engine.dispose() # flush stale pool connections so schema changes are visible
60+
insp = inspect(engine)
61+
default = insp.default_schema_name
62+
effective = self._schema or default
63+
# Omit schema when it's the connection default so SQL stays unqualified.
64+
display_schema = "" if (not self._schema or self._schema == default) else effective
6065
return [
61-
Table(name=t, schema=schema or "")
66+
Table(name=t, schema=display_schema)
6267
for t in insp.get_table_names(schema=self._schema)
6368
]
6469

src/lang2sql/adapters/llm/openai_.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
from __future__ import annotations
1313

14+
import asyncio
1415
import json
1516
import os
17+
import re
1618
import urllib.error
1719
import urllib.request
1820
from typing import Any, Sequence
@@ -27,15 +29,16 @@ class OpenAILLM:
2729

2830
def __init__(
2931
self,
30-
model: str = "gpt-4.1-mini",
32+
model: str = "gpt-4o-mini",
3133
api_key: str | None = None,
3234
*,
3335
base_url: str = _DEFAULT_URL,
3436
timeout: float = 60.0,
3537
) -> None:
3638
self.model = model
3739
# Resolve lazily-ish: read env now, but tolerate absence until complete().
38-
self._api_key = api_key if api_key is not None else os.environ.get("OPENAI_API_KEY")
40+
raw_key = api_key if api_key is not None else os.environ.get("OPENAI_API_KEY")
41+
self._api_key = raw_key.strip() if raw_key else raw_key
3942
self._base_url = base_url
4043
self._timeout = timeout
4144

@@ -54,7 +57,7 @@ async def complete(
5457
if tools:
5558
payload["tools"] = [_encode_tool(t) for t in tools]
5659

57-
raw = self._post(payload)
60+
raw = await asyncio.to_thread(self._post, payload)
5861
return _decode_completion(raw)
5962

6063
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -83,11 +86,19 @@ def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
8386
raise RuntimeError(f"OpenAI returned non-JSON response: {text[:200]!r}") from exc
8487

8588

89+
def _strip_thinking(text: str) -> str:
90+
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
91+
92+
8693
def _encode_message(m: Message) -> dict[str, Any]:
8794
"""Core :class:`Message` → an OpenAI chat message dict."""
8895
out: dict[str, Any] = {"role": m.role.value}
89-
# OpenAI wants content present (may be null when only tool_calls are set).
90-
out["content"] = m.content or None
96+
# OpenAI allows null content only when tool_calls are present.
97+
# For plain assistant messages (after session compress), force empty string.
98+
if m.role == Role.ASSISTANT and not m.tool_calls:
99+
out["content"] = m.content or ""
100+
else:
101+
out["content"] = m.content or None
91102
if m.role == Role.TOOL:
92103
out["tool_call_id"] = m.tool_call_id
93104
if m.name:
@@ -141,7 +152,7 @@ def _decode_completion(raw: dict[str, Any]) -> Completion:
141152
)
142153

143154
return Completion(
144-
content=msg.get("content") or "",
155+
content=_strip_thinking(msg.get("content") or ""),
145156
tool_calls=tool_calls,
146157
finish_reason=choice.get("finish_reason"),
147158
)

src/lang2sql/adapters/storage/sqlite_store.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ def kv_delete(self, scope: str, key: str) -> None:
130130
)
131131
self._conn.commit()
132132

133+
def kv_delete_prefix(self, scope: str, prefix: str) -> int:
134+
"""Delete all keys under scope that start with prefix. Returns count deleted."""
135+
cur = self._conn.execute(
136+
"DELETE FROM kv WHERE scope = ? AND key LIKE ?",
137+
(scope, prefix + "%"),
138+
)
139+
self._conn.commit()
140+
return cur.rowcount
141+
133142

134143
# -- Session (de)serialization ------------------------------------------
135144

src/lang2sql/frontends/discord/bot.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import io
18+
import logging
1819
import os
1920

2021
import discord
@@ -25,6 +26,8 @@
2526
from .commands import CommandHandlers
2627
from .session_router import InteractionContext, to_identity
2728

29+
logger = logging.getLogger(__name__)
30+
2831
TOKEN_ENV = "DISCORD_BOT_TOKEN"
2932

3033

@@ -102,8 +105,11 @@ def __init__(self, handlers: CommandHandlers) -> None:
102105
self._register_commands()
103106

104107
async def setup_hook(self) -> None:
105-
# Sync slash commands with Discord on startup.
106-
await self.tree.sync()
108+
# Sync only when LANG2SQL_SYNC_COMMANDS=true (e.g. after adding/removing commands).
109+
# Skipping sync on every restart avoids Discord rate limits during dev.
110+
if os.environ.get("LANG2SQL_SYNC_COMMANDS", "").lower() == "true":
111+
await self.tree.sync()
112+
logger.info("slash commands synced")
107113

108114
def _register_commands(self) -> None:
109115
tree = self.tree
@@ -135,6 +141,13 @@ async def define_metric(
135141
async def remember(interaction: discord.Interaction, text: str) -> None:
136142
await self._run(interaction, handlers.remember(to_identity(_interaction_context(interaction)), text))
137143

144+
@tree.command(name="enrich", description="LLM으로 DB 컬럼 메타데이터 자동 보강 (clear=True로 초기화)")
145+
async def enrich(interaction: discord.Interaction, table: str = "", clear: bool = False) -> None:
146+
await self._run(
147+
interaction,
148+
handlers.enrich(to_identity(_interaction_context(interaction)), table=table, clear=clear),
149+
)
150+
138151
@tree.command(name="semantic_show", description="Show definitions in effect here")
139152
async def semantic_show(interaction: discord.Interaction) -> None:
140153
await self._run(interaction, handlers.semantic_show(to_identity(_interaction_context(interaction))))
@@ -148,7 +161,10 @@ async def _run(self, interaction: discord.Interaction, coro) -> None:
148161
await interaction.response.defer(thinking=True)
149162
message = await coro
150163
content, file = _to_sendable(message)
151-
await interaction.followup.send(content=content or "(empty)", file=file)
164+
kwargs: dict = {"content": content or "(empty)"}
165+
if file is not None:
166+
kwargs["file"] = file
167+
await interaction.followup.send(**kwargs)
152168

153169
async def on_message(self, message: discord.Message) -> None:
154170
"""Treat an @mention (or a reply inside a thread) as a free-form query."""
@@ -166,9 +182,16 @@ async def on_message(self, message: discord.Message) -> None:
166182
return
167183

168184
identity = to_identity(_message_context(message))
169-
out = await self._handlers.query(identity, text)
170-
content, file = _to_sendable(out)
171-
await message.channel.send(content=content or "(empty)", file=file)
185+
try:
186+
out = await self._handlers.query(identity, text)
187+
content, file = _to_sendable(out)
188+
if content and len(content) > 1900:
189+
content = content[:1900] + "\n…(truncated)"
190+
await message.channel.send(content=content or "(empty)", file=file)
191+
except Exception as exc:
192+
import traceback
193+
traceback.print_exc()
194+
await message.channel.send(content=f"❌ Error: {type(exc).__name__}: {exc}")
172195

173196

174197
def run() -> None:
@@ -182,6 +205,7 @@ def run() -> None:
182205
raise RuntimeError(
183206
f"{TOKEN_ENV} is not set; export your Discord bot token to run the bot."
184207
)
185-
handlers = CommandHandlers(ContextConcierge())
208+
data_path = os.environ.get("LANG2SQL_DATA_PATH", "lang2sql_data.db")
209+
handlers = CommandHandlers(ContextConcierge(path=data_path))
186210
client = Lang2SQLBot(handlers)
187211
client.run(token)

src/lang2sql/frontends/discord/commands.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from ...adapters.db.dsn_builder import assemble
2323
from ...core.identity import Identity
2424
from ...core.ports.frontend import OutboundMessage
25+
from ...core.types import Role
2526
from ...harness.loop import agent_loop
2627
from ...tenancy.concierge import ContextConcierge
2728
from .render import render_answer
@@ -41,9 +42,39 @@ async def query(self, identity: Identity, text: str) -> OutboundMessage:
4142
thread/DM continues the conversation (tiebreaker #4).
4243
"""
4344
ctx = await self._concierge.build_context(identity, user_text=text)
45+
pre_loop_len = len(ctx.session.history())
4446
answer = await agent_loop(ctx, text)
47+
48+
history = ctx.session.history()
49+
current_turn = history[pre_loop_len:]
50+
51+
call_id_to_sql: dict[str, str] = {
52+
tc.id: tc.arguments["sql"]
53+
for msg in current_turn
54+
if msg.role == Role.ASSISTANT and msg.tool_calls
55+
for tc in msg.tool_calls
56+
if tc.name == "run_sql" and "sql" in tc.arguments
57+
}
58+
59+
sql_queries: list[str] = []
60+
sql_results: list[str] = []
61+
for msg in current_turn:
62+
if msg.role != Role.TOOL or msg.name != "run_sql" or not msg.content:
63+
continue
64+
sql = call_id_to_sql.get(msg.tool_call_id or "")
65+
if sql and ("row(s):" in msg.content or "(0 rows)" in msg.content):
66+
sql_queries.append(sql)
67+
sql_results.append(msg.content)
68+
69+
ctx.session.compress()
4570
await self._concierge.store.save(identity.session_key(), ctx.session)
46-
return render_answer(answer)
71+
72+
suffix = ""
73+
if sql_queries:
74+
suffix += "\n\n**SQL:**\n```sql\n" + "\n\n".join(sql_queries) + "\n```"
75+
if sql_results:
76+
suffix += "\n\n**결과:**\n```\n" + "\n\n".join(sql_results) + "\n```"
77+
return render_answer(answer + suffix)
4778

4879
async def define_metric(
4980
self,
@@ -149,6 +180,14 @@ async def register_db_for_guild(
149180
)
150181
)
151182

183+
async def enrich(self, identity: Identity, table: str = "", clear: bool = False) -> OutboundMessage:
184+
"""Run EnrichSchema tool: sample DB columns and LLM-infer descriptions."""
185+
ctx = await self._concierge.build_context(identity)
186+
result = await ctx.tools.dispatch(
187+
"enrich_schema", {"table": table, "clear": clear}, ctx, "cmd:enrich"
188+
)
189+
return OutboundMessage(text=result.content)
190+
152191
async def connect(self, identity: Identity, dsn: str) -> OutboundMessage:
153192
"""V1 stub: stash a DB DSN keyed by guild/DM in the concierge kv store.
154193

src/lang2sql/harness/context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99
from __future__ import annotations
1010

1111
from dataclasses import dataclass
12+
from typing import TYPE_CHECKING
1213

1314
from ..core.identity import Identity
15+
16+
if TYPE_CHECKING:
17+
from ..adapters.storage.sqlite_store import SqliteStore
1418
from ..core.ports.audit import AuditPort
1519
from ..core.ports.explorer import ExplorerPort
1620
from ..core.ports.llm import LLMPort
@@ -30,4 +34,5 @@ class HarnessContext:
3034
safety: SafetyPipelinePort | None = None
3135
audit: AuditPort | None = None
3236
scope_resolver: ScopeResolverPort | None = None
37+
store: SqliteStore | None = None
3338
max_turns: int = 8

0 commit comments

Comments
 (0)