Skip to content

Commit a7c1d81

Browse files
JuliaEdomclaude
andcommitted
refactor: split streamlit_app.py 1184→200 lines (Kimi P1.3)
Decompose monolithic streamlit_app.py into focused modules: - app/i18n.py — I18N dict + t() helper (187 lines) - app/theme.py — FONT_CSS + inject_chrome + chart palette (343 lines) - app/samples.py — SAMPLE_QUESTIONS + SOURCE_LINKS (122 lines) - app/bootstrap.py — bootstrap + make_pipeline (93 lines) - app/components/output.py — render_output + chart helpers (83 lines) - app/components/show_working.py — render_show_working (55 lines) - app/components/schema_explorer.py — render_schema_explorer (42 lines) - app/components/welcome.py — render_welcome + render_lang_toggle (104 lines) streamlit_app.py becomes a thin entry-point (main + replay turn), 200 lines. pyproject.toml: add "app" to ruff src so sibling imports sort first-party. Zero behavior change verified via local Streamlit + Playwright E2E: EN 94.0% renders, RU toggle switches to 94,0%, schema explorer label visible. 357 pytest pass, ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a47a7fe commit a7c1d81

11 files changed

Lines changed: 1078 additions & 1029 deletions

File tree

‎app/bootstrap.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Resource bootstrap + pipeline factory for the Streamlit UI."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
import chromadb
9+
import streamlit as st
10+
11+
from nl_sql.agent.graph import PipelineConfig, build_pipeline
12+
from nl_sql.config import get_settings
13+
from nl_sql.db.registry import DatabaseRegistry, get_default_registry
14+
from nl_sql.llm.cache import CachingEmbeddingProvider, CachingLLMProvider
15+
from nl_sql.llm.providers import build_provider
16+
from nl_sql.llm.providers.base import EmbeddingProvider, LLMProvider
17+
from nl_sql.llm.providers.mistral import MistralProvider
18+
from nl_sql.schema_index.indexer import SchemaIndex
19+
20+
21+
@st.cache_resource(show_spinner="Initialising providers + Chroma index…")
22+
def bootstrap() -> tuple[DatabaseRegistry, SchemaIndex, LLMProvider, LLMProvider]:
23+
settings = get_settings()
24+
if not settings.mistral_api_key:
25+
raise RuntimeError(
26+
"MISTRAL_API_KEY is not set in .env — required for codestral + mistral-embed."
27+
)
28+
29+
registry = get_default_registry()
30+
31+
persist_dir = Path("chroma_data")
32+
if not persist_dir.is_dir():
33+
raise RuntimeError(
34+
f"Chroma persist dir {persist_dir!r} not found. "
35+
"Run `uv run python scripts/build_index.py --db all` first."
36+
)
37+
chroma_client = chromadb.PersistentClient(path=str(persist_dir))
38+
39+
raw_embedder = MistralProvider(
40+
api_key=settings.mistral_api_key,
41+
gen_model=settings.mistral_gen_model,
42+
embed_model=settings.mistral_embed_model,
43+
base_url=settings.mistral_base_url,
44+
)
45+
embedder: EmbeddingProvider = CachingEmbeddingProvider(
46+
raw_embedder,
47+
cache_dir=settings.llm_cache_dir,
48+
size_limit_gb=settings.llm_cache_size_limit_gb,
49+
)
50+
schema_index = SchemaIndex(persist_dir=persist_dir, embedder=embedder, client=chroma_client)
51+
52+
raw_sql = build_provider("mistral", settings=settings)
53+
sql_provider: LLMProvider = CachingLLMProvider(
54+
raw_sql,
55+
cache_dir=settings.llm_cache_dir,
56+
size_limit_gb=settings.llm_cache_size_limit_gb,
57+
)
58+
explain_provider = sql_provider
59+
60+
return registry, schema_index, sql_provider, explain_provider
61+
62+
63+
def make_pipeline(
64+
registry: DatabaseRegistry,
65+
schema_index: SchemaIndex,
66+
sql_provider: LLMProvider,
67+
explain_provider: LLMProvider,
68+
*,
69+
schema_top_k: int,
70+
fk_hops: int,
71+
table_budget: int,
72+
sort_schema_block: bool,
73+
extended_sample_size: int,
74+
fewshot_top_k: int = 3,
75+
cross_db_fewshot: bool = True,
76+
verify_retry_on_empty: bool = True,
77+
) -> Any:
78+
config = PipelineConfig(
79+
sql_provider=sql_provider,
80+
explain_provider=explain_provider,
81+
schema_index=schema_index,
82+
registry=registry,
83+
schema_top_k=schema_top_k,
84+
fewshot_top_k=fewshot_top_k,
85+
fk_hops=fk_hops,
86+
table_budget=table_budget,
87+
sort_schema_block=sort_schema_block,
88+
primary_sample_size=3,
89+
extended_sample_size=extended_sample_size,
90+
cross_db_fewshot=cross_db_fewshot,
91+
verify_retry_on_empty=verify_retry_on_empty,
92+
)
93+
return build_pipeline(config)

‎app/components/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""UI components for the Streamlit assistant."""

‎app/components/output.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Output renderers: scalar, sentence, table, chart + label helpers."""
2+
3+
from __future__ import annotations
4+
5+
import pandas as pd
6+
import plotly.express as px
7+
import streamlit as st
8+
9+
from i18n import t
10+
from nl_sql.render.formats import (
11+
BarChart,
12+
LineChart,
13+
OutputFormat,
14+
PieChart,
15+
Scalar,
16+
ScatterChart,
17+
Sentence,
18+
Table,
19+
)
20+
from nl_sql.render.labels import classify_scalar_label
21+
from theme import style_fig
22+
23+
24+
def scalar_metric_label(column: str) -> str:
25+
"""Translate a raw SQL column label into a localized business label
26+
(audit P2 #5). Engine columns like ``COUNT(DISTINCT s.CDSCode)`` become
27+
"Count" / "Количество"; identifier-like columns (``total_revenue``) are
28+
kept as-is."""
29+
kind = classify_scalar_label(column)
30+
if kind == "identifier":
31+
return column
32+
return t(f"scalar_label_{kind}")
33+
34+
35+
def confidence_label(value: float) -> str:
36+
if value >= 0.8:
37+
return t("conf_high")
38+
if value >= 0.5:
39+
return t("conf_med")
40+
if value > 0.0:
41+
return t("conf_low")
42+
return t("conf_unknown")
43+
44+
45+
def render_chart(
46+
spec: BarChart | LineChart | PieChart | ScatterChart,
47+
df: pd.DataFrame,
48+
) -> None:
49+
if isinstance(spec, BarChart):
50+
fig = px.bar(df, x=spec.x_field, y=spec.y_fields)
51+
elif isinstance(spec, LineChart):
52+
fig = px.line(df, x=spec.x_field, y=spec.y_fields)
53+
elif isinstance(spec, PieChart):
54+
y_field = spec.y_fields[0] if spec.y_fields else df.columns[1]
55+
fig = px.pie(df, names=spec.x_field, values=y_field)
56+
else:
57+
y_field = spec.y_fields[0] if spec.y_fields else df.columns[1]
58+
fig = px.scatter(df, x=spec.x_field, y=y_field)
59+
st.plotly_chart(style_fig(fig), use_container_width=True)
60+
61+
62+
def render_output(output: OutputFormat | None, *, caption: str) -> None:
63+
if isinstance(output, Scalar):
64+
st.metric(scalar_metric_label(output.column), str(output.value))
65+
elif isinstance(output, Sentence):
66+
st.markdown(
67+
f"<div style=\"font-family:'NLEdSerif',Georgia,serif; "
68+
f"font-size:1.25rem; line-height:1.45; color:var(--ink); "
69+
f'margin:0.4rem 0 0.6rem;">{output.text}</div>',
70+
unsafe_allow_html=True,
71+
)
72+
if output.fields:
73+
st.json(output.fields, expanded=False)
74+
elif isinstance(output, Table):
75+
df = pd.DataFrame(output.rows, columns=output.columns)
76+
st.dataframe(df, use_container_width=True, hide_index=True)
77+
elif isinstance(output, BarChart | LineChart | PieChart | ScatterChart):
78+
df = pd.DataFrame(output.rows, columns=output.columns)
79+
render_chart(output, df)
80+
elif output is None:
81+
st.warning(t("no_output_warning"))
82+
if caption:
83+
st.caption(caption)

‎app/components/schema_explorer.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Sidebar schema explorer: per-table chunk cards rendered from the index."""
2+
3+
from __future__ import annotations
4+
5+
import streamlit as st
6+
7+
from i18n import t
8+
9+
10+
@st.cache_data(show_spinner=False)
11+
def fetch_schema_chunks(_index_id: int, db_id: str) -> list[tuple[str, str]]:
12+
schema_index = st.session_state.get("_schema_index")
13+
if schema_index is None:
14+
return []
15+
records = schema_index.schema_collection.get(
16+
where={"db_id": db_id},
17+
include=["documents", "metadatas"],
18+
)
19+
docs = records.get("documents") or []
20+
metas = records.get("metadatas") or []
21+
pairs: list[tuple[str, str]] = []
22+
for doc, meta in zip(docs, metas, strict=False):
23+
table_name = str((meta or {}).get("table_name") or "")
24+
if table_name:
25+
pairs.append((table_name, str(doc)))
26+
pairs.sort(key=lambda p: p[0].lower())
27+
return pairs
28+
29+
30+
def render_schema_explorer(db_id: str) -> None:
31+
schema_index = st.session_state.get("_schema_index")
32+
if schema_index is None:
33+
return
34+
chunks = fetch_schema_chunks(id(schema_index), db_id)
35+
if not chunks:
36+
st.caption(t("schema_explorer_empty"))
37+
return
38+
with st.expander(t("schema_explorer_collapsed", n=len(chunks)), expanded=False):
39+
st.caption(t("schema_explorer_caption"))
40+
for table_name, text in chunks:
41+
with st.expander(table_name, expanded=False):
42+
st.code(text, language="text")

‎app/components/show_working.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Show-working expander: pipeline trace, metadata, shape, rationale."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
import pandas as pd
8+
import streamlit as st
9+
10+
from components.output import confidence_label
11+
from i18n import t
12+
from nl_sql.agent.graph import PipelineRunResult
13+
14+
15+
def render_show_working(result: PipelineRunResult) -> None:
16+
with st.expander(t("show_working")):
17+
trace_rows: list[dict[str, Any]] = []
18+
for entry in result.trace:
19+
trace_rows.append(
20+
{
21+
"node": str(entry.get("node", "?")),
22+
"model": str(entry.get("model", "—")),
23+
"tokens_in": entry.get("input_tokens", "—"),
24+
"tokens_out": entry.get("output_tokens", "—"),
25+
"confidence": entry.get("confidence", "—"),
26+
}
27+
)
28+
if trace_rows:
29+
st.markdown(f"**{t('trace_header')}**")
30+
st.dataframe(
31+
pd.DataFrame(trace_rows),
32+
use_container_width=True,
33+
hide_index=True,
34+
)
35+
36+
col_a, col_b = st.columns(2)
37+
with col_a:
38+
st.markdown(f"**{t('meta_header')}**")
39+
conf_label = confidence_label(result.confidence)
40+
st.markdown(f"- {t('confidence_label')}: **{conf_label}** ({result.confidence:.2f})")
41+
st.markdown(f"- {t('repair_attempted')}: {result.repair_attempted}")
42+
st.markdown(f"- {t('db_field')}: `{result.db_id}`")
43+
with col_b:
44+
st.markdown(f"**{t('shape_header')}**")
45+
if result.outcome and result.outcome.result:
46+
st.markdown(f"- {t('rows_returned')}: {result.outcome.result.row_count}")
47+
cols = ", ".join(result.outcome.result.columns) or "—"
48+
st.markdown(f"- {t('columns_field')}: {cols}")
49+
else:
50+
st.markdown(f"- {t('no_rows')}")
51+
if result.rationale:
52+
st.markdown(f"**{t('rationale_header')}**")
53+
st.write(result.rationale)
54+
if result.error_kind:
55+
st.error(f"{t('error_kind')}: {result.error_kind} — {result.error_message}")

‎app/components/welcome.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Hero block + sample question cards + sidebar language toggle."""
2+
3+
from __future__ import annotations
4+
5+
import streamlit as st
6+
7+
from i18n import t
8+
from samples import SAMPLE_QUESTIONS
9+
10+
11+
def render_lang_toggle() -> None:
12+
"""Two flat segments: EN / RU. Active one inverts."""
13+
lang = st.session_state.get("lang", "en")
14+
st.markdown(f"<div class='nl-side-sub'>{t('lang_label')}</div>", unsafe_allow_html=True)
15+
cols = st.columns(2)
16+
with cols[0]:
17+
if st.button(
18+
t("lang_en"),
19+
key="lang_en_btn",
20+
use_container_width=True,
21+
type="primary" if lang == "en" else "secondary",
22+
):
23+
st.session_state.lang = "en"
24+
st.rerun()
25+
with cols[1]:
26+
if st.button(
27+
t("lang_ru"),
28+
key="lang_ru_btn",
29+
use_container_width=True,
30+
type="primary" if lang == "ru" else "secondary",
31+
):
32+
st.session_state.lang = "ru"
33+
st.rerun()
34+
35+
36+
def render_welcome(db_id: str) -> None:
37+
st.markdown(
38+
"<div class='nl-display'>NL<span class='arrow'>→</span>SQL</div>",
39+
unsafe_allow_html=True,
40+
)
41+
st.markdown(f"<div class='nl-tagline'>{t('tagline')}</div>", unsafe_allow_html=True)
42+
43+
col_a, col_b = st.columns(2)
44+
with col_a:
45+
st.markdown(
46+
f"""
47+
<div class='nl-metric'>
48+
<div class='nl-kicker'>{t("metric_kicker")}</div>
49+
<div class='nl-metric-row'>
50+
<span class='nl-metric-value'>{t("metric_value")}</span>
51+
<span class='nl-metric-aside'>{t("metric_percent")}</span>
52+
</div>
53+
<div class='nl-metric-cap'>{t("metric_caption")}</div>
54+
</div>
55+
""",
56+
unsafe_allow_html=True,
57+
)
58+
with col_b:
59+
st.markdown(
60+
f"""
61+
<div class='nl-metric'>
62+
<div class='nl-kicker'>{t("research_kicker")}</div>
63+
<div class='nl-metric-row'>
64+
<span class='nl-metric-value'>{t("research_value")}</span>
65+
</div>
66+
<div class='nl-metric-cap'>{t("research_caption")}</div>
67+
</div>
68+
""",
69+
unsafe_allow_html=True,
70+
)
71+
72+
samples = SAMPLE_QUESTIONS.get(db_id)
73+
if not samples:
74+
st.markdown(
75+
f"<div class='nl-section-label'>{t('ask_intro_label')}</div>",
76+
unsafe_allow_html=True,
77+
)
78+
st.info(t("no_samples"))
79+
return
80+
81+
st.markdown(
82+
f"<div class='nl-section-label'>{t('ask_intro_label')}</div>",
83+
unsafe_allow_html=True,
84+
)
85+
86+
cols = st.columns(len(samples))
87+
diff_map = {
88+
"simple": t("diff_simple"),
89+
"moderate": t("diff_moderate"),
90+
"challenging": t("diff_challenging"),
91+
}
92+
for col, (difficulty, question) in zip(cols, samples, strict=False):
93+
with col:
94+
st.markdown(
95+
f"<div class='nl-sample-kicker'>{diff_map.get(difficulty, difficulty)}</div>",
96+
unsafe_allow_html=True,
97+
)
98+
if st.button(
99+
question,
100+
key=f"sample_{db_id}_{hash(question)}",
101+
use_container_width=True,
102+
):
103+
st.session_state.pending_question = question
104+
st.rerun()

0 commit comments

Comments
 (0)