Skip to content

Commit 34791cf

Browse files
committed
feat(local): add full-source demo launcher
1 parent 2517151 commit 34791cf

7 files changed

Lines changed: 451 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,32 @@ https://github.com/brownjuly2003-code/NL_SQL/raw/main/docs/ui-live-demo.mp4
6767

6868
Two tiers: **(A) clone-first** (fastest first run) and **(B) full local rebuild** (all 12 DBs).
6969

70+
### Full-source local demo (recommended instead of the paused HF Space)
71+
72+
This path runs the original checkout directly: **all 12 registered SQLite DBs**,
73+
both Chroma collections, and the normal product pipeline
74+
(`mistral` / `codestral-latest` by default). It does not use the filtered HF
75+
publish set, alter source files, or put the API key in a command argument.
76+
At runtime it uses a temporary byte-for-byte Chroma copy, so Chroma housekeeping
77+
does not dirty the tracked source index.
78+
79+
Complete the dependency and `.env` steps in (A), the full data steps in (B),
80+
then run:
81+
82+
```powershell
83+
# Read-only local preflight: verifies the key is configured, all 12 DBs exist,
84+
# and the Chroma schema index covers all 12. It makes no API request.
85+
uv run python scripts/run_local_demo.py --check
86+
87+
# Serve the complete source checkout on loopback only.
88+
uv run python scripts/run_local_demo.py
89+
# Open http://127.0.0.1:8501
90+
```
91+
92+
Keep your own `MISTRAL_API_KEY` in the gitignored `.env` or the current process
93+
environment. The launcher never prints it. Provider calls use your account and
94+
quota; local SQLite queries remain read-only.
95+
7096
### A. Clone-first (fastest)
7197

7298
Requires **Python 3.13**.

‎app/bootstrap.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from pathlib import Path
56
from typing import Any
67

78
import chromadb
@@ -18,6 +19,14 @@
1819
from nl_sql.schema_index.indexer import SchemaIndex
1920

2021

22+
def chroma_persist_dir(configured: str | Path) -> Path:
23+
"""Resolve the default source index or an absolute runtime-copy override."""
24+
candidate = Path(configured)
25+
if candidate.is_absolute():
26+
return candidate
27+
return under_root(*candidate.parts)
28+
29+
2130
@st.cache_resource(show_spinner="Initialising providers + Chroma index…")
2231
def bootstrap() -> tuple[DatabaseRegistry, SchemaIndex, LLMProvider, LLMProvider]:
2332
settings = get_settings()
@@ -26,7 +35,7 @@ def bootstrap() -> tuple[DatabaseRegistry, SchemaIndex, LLMProvider, LLMProvider
2635

2736
registry = get_default_registry()
2837

29-
persist_dir = under_root("chroma_data")
38+
persist_dir = chroma_persist_dir(settings.chroma_data_dir)
3039
if not persist_dir.is_dir():
3140
raise RuntimeError(
3241
f"Chroma persist dir {persist_dir!r} not found. "

‎scripts/run_local_demo.py‎

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#!/usr/bin/env python
2+
"""Run the complete source checkout as a loopback-only Streamlit demo.
3+
4+
Unlike the Hugging Face deployment, this launcher requires all 12 local SQLite
5+
databases and a Chroma schema index covering the same database ids. The Mistral
6+
key must come from the process environment or the gitignored local ``.env``;
7+
its value is never accepted as a CLI argument or printed.
8+
9+
Usage::
10+
11+
uv run python scripts/run_local_demo.py --check
12+
uv run python scripts/run_local_demo.py
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import os
19+
import shutil
20+
import sqlite3
21+
import subprocess
22+
import sys
23+
import tempfile
24+
from collections.abc import Collection, Mapping, Sequence
25+
from contextlib import closing
26+
from pathlib import Path
27+
28+
from dotenv import dotenv_values
29+
30+
FULL_LOCAL_DB_IDS: frozenset[str] = frozenset(
31+
{
32+
"bird_california_schools",
33+
"bird_card_games",
34+
"bird_codebase_community",
35+
"bird_debit_card_specializing",
36+
"bird_european_football_2",
37+
"bird_financial",
38+
"bird_formula_1",
39+
"bird_student_club",
40+
"bird_superhero",
41+
"bird_thrombosis_prediction",
42+
"bird_toxicology",
43+
"chinook",
44+
}
45+
)
46+
47+
48+
def repo_root() -> Path:
49+
"""Return the source checkout root independently of the caller's CWD."""
50+
return Path(__file__).resolve().parents[1]
51+
52+
53+
def _mistral_key_is_configured(*, root: Path, environ: Mapping[str, str]) -> bool:
54+
process_value = environ.get("MISTRAL_API_KEY", "")
55+
if process_value.strip():
56+
return True
57+
58+
env_path = root / ".env"
59+
if not env_path.is_file():
60+
return False
61+
file_value = dotenv_values(env_path).get("MISTRAL_API_KEY")
62+
return isinstance(file_value, str) and bool(file_value.strip())
63+
64+
65+
def registered_database_ids() -> frozenset[str]:
66+
"""Return SQLite databases discoverable by the normal application registry."""
67+
from nl_sql.db.registry import get_default_registry
68+
69+
return frozenset(get_default_registry().ids())
70+
71+
72+
def indexed_database_ids(*, root: Path) -> frozenset[str]:
73+
"""Read schema database ids directly from Chroma SQLite in read-only mode."""
74+
sqlite_path = (root / "chroma_data" / "chroma.sqlite3").resolve()
75+
uri = f"{sqlite_path.as_uri()}?mode=ro"
76+
with closing(sqlite3.connect(uri, uri=True)) as connection:
77+
rows = connection.execute(
78+
"""
79+
SELECT DISTINCT em.string_value
80+
FROM embedding_metadata AS em
81+
JOIN embeddings AS e ON e.id = em.id
82+
JOIN segments AS s ON s.id = e.segment_id
83+
JOIN collections AS c ON c.id = s.collection
84+
WHERE c.name = 'schema_chunks'
85+
AND em.key = 'db_id'
86+
AND em.string_value IS NOT NULL
87+
ORDER BY em.string_value
88+
"""
89+
).fetchall()
90+
return frozenset(str(row[0]) for row in rows)
91+
92+
93+
def copy_runtime_chroma(*, root: Path, destination: Path) -> None:
94+
"""Copy the complete source index so Chroma housekeeping cannot dirty git."""
95+
shutil.copytree(root / "chroma_data", destination)
96+
97+
98+
def streamlit_environment(
99+
*,
100+
runtime_chroma: Path,
101+
environ: Mapping[str, str],
102+
) -> dict[str, str]:
103+
"""Return child-only environment with the runtime Chroma override."""
104+
child = dict(environ)
105+
child["NL_SQL_CHROMA_DATA_DIR"] = str(runtime_chroma)
106+
return child
107+
108+
109+
def preflight_issues(
110+
*,
111+
root: Path | None = None,
112+
environ: Mapping[str, str] | None = None,
113+
registered_ids: Collection[str] | None = None,
114+
indexed_ids: Collection[str] | None = None,
115+
) -> list[str]:
116+
"""Return local-demo prerequisite failures without making network calls."""
117+
base = repo_root() if root is None else root
118+
current_environ = os.environ if environ is None else environ
119+
issues: list[str] = []
120+
121+
if not _mistral_key_is_configured(root=base, environ=current_environ):
122+
issues.append("MISTRAL_API_KEY is not configured in the process environment or local .env.")
123+
124+
available_databases = (
125+
registered_database_ids() if registered_ids is None else frozenset(registered_ids)
126+
)
127+
missing_databases = sorted(FULL_LOCAL_DB_IDS - available_databases)
128+
if missing_databases:
129+
issues.append(
130+
"Full-source database set is incomplete; missing database ids: "
131+
+ ", ".join(missing_databases)
132+
)
133+
134+
chroma_sqlite = base / "chroma_data" / "chroma.sqlite3"
135+
if not chroma_sqlite.is_file():
136+
issues.append(
137+
"Chroma index is missing; run `uv run python scripts/build_index.py --db all`."
138+
)
139+
else:
140+
try:
141+
available_index = (
142+
indexed_database_ids(root=base) if indexed_ids is None else frozenset(indexed_ids)
143+
)
144+
except Exception as exc:
145+
issues.append(f"Chroma index could not be read ({type(exc).__name__}).")
146+
else:
147+
missing_index = sorted(FULL_LOCAL_DB_IDS - available_index)
148+
if missing_index:
149+
issues.append(
150+
"Full-source Chroma index is incomplete; missing index database ids: "
151+
+ ", ".join(missing_index)
152+
)
153+
154+
return issues
155+
156+
157+
def streamlit_command(*, port: int) -> list[str]:
158+
"""Build a secret-free command that serves the source checkout on loopback."""
159+
return [
160+
sys.executable,
161+
"-m",
162+
"streamlit",
163+
"run",
164+
"app/streamlit_app.py",
165+
"--server.address=127.0.0.1",
166+
f"--server.port={port}",
167+
"--server.headless=true",
168+
"--browser.gatherUsageStats=false",
169+
]
170+
171+
172+
def run_streamlit(*, root: Path, port: int) -> int:
173+
"""Run Streamlit against a temporary byte-for-byte copy of Chroma."""
174+
with tempfile.TemporaryDirectory(prefix="nl_sql_local_demo_") as temporary:
175+
runtime_chroma = Path(temporary) / "chroma_data"
176+
copy_runtime_chroma(root=root, destination=runtime_chroma)
177+
child_environ = streamlit_environment(
178+
runtime_chroma=runtime_chroma,
179+
environ=os.environ,
180+
)
181+
print("[local-demo] using a temporary full Chroma copy; source bytes stay unchanged.")
182+
completed = subprocess.run(
183+
streamlit_command(port=port),
184+
cwd=root,
185+
env=child_environ,
186+
check=False,
187+
)
188+
return completed.returncode
189+
190+
191+
def _port(value: str) -> int:
192+
port = int(value)
193+
if not 1 <= port <= 65535:
194+
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
195+
return port
196+
197+
198+
def build_parser() -> argparse.ArgumentParser:
199+
parser = argparse.ArgumentParser(description=__doc__)
200+
parser.add_argument(
201+
"--check",
202+
action="store_true",
203+
help="Verify the full local source prerequisites without starting Streamlit.",
204+
)
205+
parser.add_argument("--port", type=_port, default=8501, help="Loopback port (default: 8501).")
206+
return parser
207+
208+
209+
def main(argv: Sequence[str] | None = None) -> int:
210+
args = build_parser().parse_args(argv)
211+
root = repo_root()
212+
issues = preflight_issues(root=root)
213+
if issues:
214+
print("[local-demo] preflight FAILED:", file=sys.stderr)
215+
for issue in issues:
216+
print(f" - {issue}", file=sys.stderr)
217+
return 1
218+
219+
print(
220+
f"[local-demo] preflight OK: full source checkout "
221+
f"({len(FULL_LOCAL_DB_IDS)} SQLite DBs + Chroma); "
222+
"MISTRAL_API_KEY configured (value not printed)."
223+
)
224+
if args.check:
225+
return 0
226+
227+
print(f"[local-demo] starting http://127.0.0.1:{args.port}")
228+
try:
229+
return run_streamlit(root=root, port=args.port)
230+
except KeyboardInterrupt:
231+
return 130
232+
233+
234+
if __name__ == "__main__":
235+
raise SystemExit(main())

‎src/nl_sql/config/settings.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ class Settings(BaseSettings):
161161

162162
# diskcache for LLM generate/embed responses (per docs/02_architecture_v2.md §6.5).
163163
# Two subdirs ("gen", "embed") are created under this root by `nl_sql.llm.cache`.
164+
chroma_data_dir: str = "chroma_data"
164165
llm_cache_dir: str = ".cache/llm"
165166
llm_cache_size_limit_gb: int = 4
166167

‎tests/app/test_bootstrap.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def _settings(tmp_path: Path, **overrides: Any) -> SimpleNamespace:
3232
"mistral_gen_model": "codestral-latest",
3333
"mistral_embed_model": "mistral-embed",
3434
"mistral_base_url": "https://api.mistral.ai/v1",
35+
"chroma_data_dir": "chroma_data",
3536
"llm_cache_dir": tmp_path / "cache",
3637
"llm_cache_size_limit_gb": 1.0,
3738
}
@@ -104,3 +105,18 @@ def test_bootstrap_reports_mistral_key_is_for_embeddings(
104105

105106
with pytest.raises(RuntimeError, match="required for embeddings"):
106107
bootstrap_module.bootstrap()
108+
109+
110+
def test_chroma_persist_dir_accepts_runtime_absolute_path(
111+
bootstrap_module: Any,
112+
monkeypatch: pytest.MonkeyPatch,
113+
tmp_path: Path,
114+
) -> None:
115+
runtime_copy = tmp_path / "runtime-chroma"
116+
monkeypatch.setattr(
117+
bootstrap_module,
118+
"under_root",
119+
lambda *_: pytest.fail("absolute runtime path must not be joined to repo root"),
120+
)
121+
122+
assert bootstrap_module.chroma_persist_dir(runtime_copy) == runtime_copy

0 commit comments

Comments
 (0)