Deploy NL_SQL HEAD to HF Space
Browse files- .gitattributes +1 -0
- app/bootstrap.py +93 -0
- app/components/__init__.py +1 -0
- app/components/output.py +83 -0
- app/components/schema_explorer.py +42 -0
- app/components/show_working.py +55 -0
- app/components/welcome.py +104 -0
- app/i18n.py +187 -0
- app/samples.py +122 -0
- app/streamlit_app.py +47 -1028
- app/theme.py +343 -0
- chroma_data/chroma.sqlite3 +1 -1
- chroma_data/fc9668d3-4384-40d9-aa8d-0010807a5a68/data_level0.bin +1 -1
- chroma_data/fc9668d3-4384-40d9-aa8d-0010807a5a68/length.bin +1 -1
- docs/NEXT_SESSION.md +23 -2
- docs/SESSION_HANDOFF.md +35 -2
- docs/ui-live-v31.png +3 -0
- pyproject.toml +1 -1
- src/nl_sql/api/main.py +429 -405
.gitattributes
CHANGED
|
@@ -50,3 +50,4 @@ docs/ui-2026-05-17-ru.png filter=lfs diff=lfs merge=lfs -text
|
|
| 50 |
docs/ui-live-en.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
docs/ui-live-ru.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
docs/ui-live-demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 50 |
docs/ui-live-en.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
docs/ui-live-ru.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
docs/ui-live-demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
docs/ui-live-v31.png filter=lfs diff=lfs merge=lfs -text
|
app/bootstrap.py
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""UI components for the Streamlit assistant."""
|
app/components/output.py
ADDED
|
@@ -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
ADDED
|
@@ -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
ADDED
|
@@ -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
ADDED
|
@@ -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()
|
app/i18n.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""I18N strings + translation helper for the Streamlit UI.
|
| 2 |
+
|
| 3 |
+
Chrome-level strings only. Sample questions stay in their natural
|
| 4 |
+
language — the pipeline handles EN + RU both, the toggle only flips
|
| 5 |
+
the surrounding UI copy.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
# Bilingual UI mixes Cyrillic and Latin in `I18N["ru"]` — silence the
|
| 9 |
+
# ambiguous-glyph lint at module scope.
|
| 10 |
+
# ruff: noqa: RUF001
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
import streamlit as st
|
| 17 |
+
|
| 18 |
+
I18N: dict[str, dict[str, str]] = {
|
| 19 |
+
"en": {
|
| 20 |
+
"page_title": "NL → SQL",
|
| 21 |
+
"tagline": "Natural language in. SQL out. Answer rendered in whichever shape fits the question.",
|
| 22 |
+
"lang_label": "Language",
|
| 23 |
+
"lang_en": "EN",
|
| 24 |
+
"lang_ru": "RU",
|
| 25 |
+
"metric_kicker": "Chinook business workload",
|
| 26 |
+
"metric_value": "60 / 60 correct",
|
| 27 |
+
"metric_percent": "100%",
|
| 28 |
+
"metric_caption": "30 dev + 30 held-out, balanced split, all ten query categories at 100% on the free-tier codestral pipeline.",
|
| 29 |
+
"research_kicker": "BIRD Mini-Dev research benchmark",
|
| 30 |
+
"research_value": "94.0% / 200",
|
| 31 |
+
"research_caption": (
|
| 32 |
+
"Hybrid pipeline: "
|
| 33 |
+
"<span class='nl-term' title='Mistral codestral-latest — SQL-specialised generation model, free tier'>codestral</span> + "
|
| 34 |
+
"<span class='nl-term' title='Anthropic Claude 4.5 Sonnet via Perplexity Pro browser bridge — used on the hard tier'>Sonnet 4.6 bridge</span> + "
|
| 35 |
+
"<span class='nl-term' title='Per-failure re-prompt with executable-shape feedback — only on frozen failures, no T=0 noise'>grounded-critique retry</span> + "
|
| 36 |
+
"<span class='nl-term' title='helallao reverse-engineered HTTPS bridge to Perplexity backend — Grok 4.1, GPT-5.2, Claude 4.5 Sonnet, kimi-k2-thinking, gpt-5.2-thinking + DAC on residue, claude-4.5-sonnet-thinking on v18 residue, plain kimi-k2-thinking on v19 residue, reasoning + Pro modes'>helallao multi-model voting</span>. "
|
| 37 |
+
"Scored under "
|
| 38 |
+
"<span class='nl-term' title='bird-bench/mini_dev evaluation_ex.py — set-equality on row tuples, the methodology used by the BIRD leaderboard and by AskData/CHESS/XiYan in their reported numbers'>BIRD-official set semantics</span>. "
|
| 39 |
+
"+46.2pp over the GPT-4 zero-shot reference (47.8%), $0 external cost. **Above human-expert baseline 92.96% (BIRD paper) by +1.04pp.** "
|
| 40 |
+
"On <span class='nl-term' title='Jin et al., CIDR/VLDB 2026, arXiv:2601.08778 — corrected BIRD gold annotations'>Arcwise-Plat corrected gold</span>: 74.37% (148/199) — honest noise-floor; +7 sql_only catches where our prediction is correct under Arcwise's corrected gold but BIRD's original gold disagrees. "
|
| 41 |
+
"Seven late-stage model rescues on v16→v22, two archive-audit rescores on v23/v24 (qid 1205 via archive sweep, qid 959 via archive-rescore after the day-5 bind-bug fix), and nine targeted P3.F schema-link hints on v25→v31: qid 902 (driverStandings.position vs results.position), qid 1531 (yearmonth.Consumption subquery + SUM(Price/Amount) row-wise), qid 894 (lapTimes.milliseconds first SELECT column), qid 1251 (Patient ⋈ Laboratory ⋈ Examination semi-join), qid 408 (rulings.text filter via cards.uuid join + COUNT(DISTINCT cards.id)), qid 1275 (Laboratory.CENTROMEA/SSB IN ('negative','0') instead of fabricated tokens against Examination), qid 1168 (override projection-discipline: include Patient.Birthday as third SELECT column + ORDER BY Birthday ASC LIMIT 1 on JOIN), qid 1029 (european_football_2 positional inversion: 'highest buildUpPlaySpeed' = lower numeric value, sort ASC + INNER JOIN Team), qid 37 (california_schools 'lowest excellence rate' — BIRD inverts question word-order 'Street, City, Zip and State' to SELECT (Street, City, State, Zip); 'excellence rate' = NumGE1500 / NumTstTakr ASC LIMIT 1 directly on JOIN). Every cell verified via audit_rescore.py — 0 mismatches."
|
| 42 |
+
),
|
| 43 |
+
"settings_header": "Settings",
|
| 44 |
+
"db_label": "Database",
|
| 45 |
+
"db_dialect": "Dialect",
|
| 46 |
+
"db_source": "Source",
|
| 47 |
+
"schema_explorer_collapsed": "Schema · {n} tables",
|
| 48 |
+
"schema_explorer_empty": "Schema index empty for this database. Run scripts/build_index.py.",
|
| 49 |
+
"schema_explorer_caption": "The same chunks the retriever sees — table cards with columns, types, null and distinct stats, sample values, and foreign keys.",
|
| 50 |
+
"mode_header": "Mode",
|
| 51 |
+
"mode_accurate": "Accurate",
|
| 52 |
+
"mode_fast": "Fast",
|
| 53 |
+
"mode_debug": "Debug",
|
| 54 |
+
"mode_accurate_caption": "fewshot + verify-retry — best EA",
|
| 55 |
+
"mode_fast_caption": "no fewshot — fastest, slight EA loss",
|
| 56 |
+
"mode_debug_caption": "Accurate + raw trace in show-working",
|
| 57 |
+
"advanced_header": "Advanced retrieval",
|
| 58 |
+
"schema_top_k": "schema_top_k",
|
| 59 |
+
"fk_hops": "fk_hops",
|
| 60 |
+
"table_budget": "table_budget",
|
| 61 |
+
"sort_schema": "sort schema block (alphabetical)",
|
| 62 |
+
"sample_size": "extended sample size",
|
| 63 |
+
"clear_chat": "Clear chat",
|
| 64 |
+
"ask_placeholder": "Ask a question about this database (EN or RU)…",
|
| 65 |
+
"ask_intro_label": "Try one of these to start",
|
| 66 |
+
"diff_simple": "simple",
|
| 67 |
+
"diff_moderate": "moderate",
|
| 68 |
+
"diff_challenging": "challenging",
|
| 69 |
+
"no_samples": "No sample questions curated for this database yet — type your own below.",
|
| 70 |
+
"spinner_generating": "Generating SQL and executing…",
|
| 71 |
+
"pipeline_crashed": "Pipeline crashed: {kind}: {msg}",
|
| 72 |
+
"sql_label": "SQL",
|
| 73 |
+
"no_sql": "Pipeline produced no SQL.",
|
| 74 |
+
"wall_model": "{wall:.0f} ms · {model}",
|
| 75 |
+
"show_working": "Show working — pipeline trace, SQL, metadata",
|
| 76 |
+
"trace_header": "Pipeline trace",
|
| 77 |
+
"meta_header": "Metadata",
|
| 78 |
+
"shape_header": "Result shape",
|
| 79 |
+
"confidence_label": "Confidence",
|
| 80 |
+
"repair_attempted": "Repair attempted",
|
| 81 |
+
"db_field": "Database",
|
| 82 |
+
"rows_returned": "Rows returned",
|
| 83 |
+
"columns_field": "Columns",
|
| 84 |
+
"no_rows": "No result rows.",
|
| 85 |
+
"rationale_header": "Rationale",
|
| 86 |
+
"error_kind": "Error",
|
| 87 |
+
"no_output_warning": "No output format produced.",
|
| 88 |
+
"conf_high": "High",
|
| 89 |
+
"conf_med": "Medium",
|
| 90 |
+
"conf_low": "Low",
|
| 91 |
+
"conf_unknown": "Unknown",
|
| 92 |
+
"scalar_label_count": "Count",
|
| 93 |
+
"scalar_label_sum": "Sum",
|
| 94 |
+
"scalar_label_average": "Average",
|
| 95 |
+
"scalar_label_minimum": "Minimum",
|
| 96 |
+
"scalar_label_maximum": "Maximum",
|
| 97 |
+
"scalar_label_ratio": "Ratio",
|
| 98 |
+
"scalar_label_result": "Result",
|
| 99 |
+
},
|
| 100 |
+
"ru": {
|
| 101 |
+
"page_title": "NL → SQL",
|
| 102 |
+
"tagline": "На входе — естественный язык. На выходе — SQL и ответ в форме, которая подходит вопросу.",
|
| 103 |
+
"lang_label": "Язык",
|
| 104 |
+
"lang_en": "EN",
|
| 105 |
+
"lang_ru": "RU",
|
| 106 |
+
"metric_kicker": "Бизнес-нагрузка Chinook",
|
| 107 |
+
"metric_value": "60 из 60",
|
| 108 |
+
"metric_percent": "100%",
|
| 109 |
+
"metric_caption": "30 dev + 30 held-out, сбалансированный сплит, все десять категорий запросов на 100% через бесплатный codestral.",
|
| 110 |
+
"research_kicker": "Исследовательский бенчмарк BIRD Mini-Dev",
|
| 111 |
+
"research_value": "94,0% / 200",
|
| 112 |
+
"research_caption": (
|
| 113 |
+
"Гибридный пайплайн: "
|
| 114 |
+
"<span class='nl-term' title='Mistral codestral-latest — модель, специализированная под генерацию SQL, бесплатный тариф'>codestral</span> + "
|
| 115 |
+
"<span class='nl-term' title='Anthropic Claude 4.5 Sonnet через браузерный мост Perplexity Pro — на сложных кейсах'>мост к Sonnet 4.6</span> + "
|
| 116 |
+
"<span class='nl-term' title='Повторный prompt со shape-фидбэком исполнения — только на зафиксированных фейлах, без шума T=0'>directed-critique retry</span> + "
|
| 117 |
+
"<span class='nl-term' title='Реверс-инжиниринг HTTPS моста к бэкенду Perplexity — Grok 4.1, GPT-5.2, Claude 4.5 Sonnet, kimi-k2-thinking, gpt-5.2-thinking + DAC на residue, claude-4.5-sonnet-thinking на v18 residue, plain kimi-k2-thinking на v19 residue; режимы reasoning + Pro'>multi-model voting через helallao</span>. "
|
| 118 |
+
"Scoring — "
|
| 119 |
+
"<span class='nl-term' title='bird-bench/mini_dev evaluation_ex.py — set-равенство на результирующих кортежах. Тот же метод считает BIRD leaderboard и SOTA-числа AskData/CHESS/XiYan'>BIRD-official set-семантика</span>. "
|
| 120 |
+
"+46,2 п.п. над zero-shot GPT-4 (47,8%), внешние расходы — ноль. **Выше human-expert baseline 92,96% (BIRD paper) на +1,04 п.п.** "
|
| 121 |
+
"На <span class='nl-term' title='Jin et al., CIDR/VLDB 2026, arXiv:2601.08778 — исправленные аннотации gold BIRD'>исправленном gold Arcwise-Plat</span>: 74,37% (148/199) — честный noise-floor; +7 sql_only catches, где наш ответ правильнее эталона BIRD согласно Arcwise. "
|
| 122 |
+
"Семь late-stage rescue по моделям на пути v16→v22, плюс v23/v24 — archive-sweep и archive-rescore (qid 1205 / qid 959 после day-5 bind-bug fix), плюс v25→v31 — девять узких P3.F schema-link hint'ов: qid 902 (driverStandings.position вместо results.position), qid 1531 (subquery по yearmonth.Consumption + SUM(Price/Amount) построчно), qid 894 (lapTimes.milliseconds первой колонкой), qid 1251 (полу-джойн Patient ⋈ Laboratory ⋈ Examination), qid 408 (фильтр по rulings.text через join cards.uuid + COUNT(DISTINCT cards.id)), qid 1275 (Laboratory.CENTROMEA/SSB IN ('negative','0') вместо несуществующих Examination columns + invented '-'/'+-' tokens), qid 1168 (override projection-discipline: Patient.Birthday как 3-я колонка SELECT + ORDER BY Birthday ASC LIMIT 1 прямо на JOIN), qid 1029 (european_football_2 positional inversion: 'highest buildUpPlaySpeed' = меньшее число, sort ASC + INNER JOIN Team), qid 37 (california_schools 'lowest excellence rate' — BIRD инвертирует word-order вопроса 'Street, City, Zip and State' в SELECT (Street, City, State, Zip); 'excellence rate' = NumGE1500 / NumTstTakr ASC LIMIT 1 прямо на JOIN). Каждая ячейка верифицирована через audit_rescore.py — 0 mismatches."
|
| 123 |
+
),
|
| 124 |
+
"settings_header": "Настройки",
|
| 125 |
+
"db_label": "База данных",
|
| 126 |
+
"db_dialect": "Диалект",
|
| 127 |
+
"db_source": "Источник",
|
| 128 |
+
"schema_explorer_collapsed": "Схема · {n} таблиц",
|
| 129 |
+
"schema_explorer_empty": "Индекс схемы пуст для этой БД. Запусти scripts/build_index.py.",
|
| 130 |
+
"schema_explorer_caption": "Те же чанки, которые видит ретривер — карточки таблиц с колонками, типами, null/distinct, sample-значениями и foreign keys.",
|
| 131 |
+
"mode_header": "Режим",
|
| 132 |
+
"mode_accurate": "Точно",
|
| 133 |
+
"mode_fast": "Быстро",
|
| 134 |
+
"mode_debug": "Отладка",
|
| 135 |
+
"mode_accurate_caption": "fewshot + verify-retry — максимальный EA",
|
| 136 |
+
"mode_fast_caption": "без fewshot — быстрее, EA чуть ниже",
|
| 137 |
+
"mode_debug_caption": "Точно + сырой trace в show-working",
|
| 138 |
+
"advanced_header": "Тонкая настройка ретривала",
|
| 139 |
+
"schema_top_k": "schema_top_k",
|
| 140 |
+
"fk_hops": "fk_hops",
|
| 141 |
+
"table_budget": "table_budget",
|
| 142 |
+
"sort_schema": "сортировать блок схемы (по алфавиту)",
|
| 143 |
+
"sample_size": "размер расширенного семпла",
|
| 144 |
+
"clear_chat": "Очистить чат",
|
| 145 |
+
"ask_placeholder": "Спроси что-нибудь об этой базе (EN или RU)…",
|
| 146 |
+
"ask_intro_label": "Можно начать с одного из этих вопросов",
|
| 147 |
+
"diff_simple": "просто",
|
| 148 |
+
"diff_moderate": "средне",
|
| 149 |
+
"diff_challenging": "сложно",
|
| 150 |
+
"no_samples": "Для этой БД пока нет подготовленных вопросов — задай свой ниже.",
|
| 151 |
+
"spinner_generating": "Генерирую SQL и выполняю…",
|
| 152 |
+
"pipeline_crashed": "Пайплайн упал: {kind}: {msg}",
|
| 153 |
+
"sql_label": "SQL",
|
| 154 |
+
"no_sql": "Пайплайн не выдал SQL.",
|
| 155 |
+
"wall_model": "{wall:.0f} мс · {model}",
|
| 156 |
+
"show_working": "Показать работу — trace, SQL, метаданные",
|
| 157 |
+
"trace_header": "Trace пайплайна",
|
| 158 |
+
"meta_header": "Метаданные",
|
| 159 |
+
"shape_header": "Форма результата",
|
| 160 |
+
"confidence_label": "Уверенность",
|
| 161 |
+
"repair_attempted": "Был ли repair",
|
| 162 |
+
"db_field": "База",
|
| 163 |
+
"rows_returned": "Строк в ответе",
|
| 164 |
+
"columns_field": "Колонки",
|
| 165 |
+
"no_rows": "Строки не вернулись.",
|
| 166 |
+
"rationale_header": "Обоснование",
|
| 167 |
+
"error_kind": "Ошибка",
|
| 168 |
+
"no_output_warning": "Формат вывода не был построен.",
|
| 169 |
+
"conf_high": "Высокая",
|
| 170 |
+
"conf_med": "Средняя",
|
| 171 |
+
"conf_low": "Низкая",
|
| 172 |
+
"conf_unknown": "Неизвестно",
|
| 173 |
+
"scalar_label_count": "Количество",
|
| 174 |
+
"scalar_label_sum": "Сумма",
|
| 175 |
+
"scalar_label_average": "Среднее",
|
| 176 |
+
"scalar_label_minimum": "Минимум",
|
| 177 |
+
"scalar_label_maximum": "Максимум",
|
| 178 |
+
"scalar_label_ratio": "Отношение",
|
| 179 |
+
"scalar_label_result": "Результат",
|
| 180 |
+
},
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def t(key: str, **kwargs: Any) -> str:
|
| 185 |
+
lang = st.session_state.get("lang", "en")
|
| 186 |
+
template = I18N.get(lang, I18N["en"]).get(key) or I18N["en"].get(key) or key
|
| 187 |
+
return template.format(**kwargs) if kwargs else template
|
app/samples.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sample questions per database + source links shown in the sidebar."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
SOURCE_LINKS: dict[str, tuple[str, str]] = {
|
| 6 |
+
"chinook": (
|
| 7 |
+
"Chinook SQLite (lerocha/chinook-database)",
|
| 8 |
+
"https://github.com/lerocha/chinook-database",
|
| 9 |
+
),
|
| 10 |
+
"_bird_default": (
|
| 11 |
+
"BIRD Mini-Dev (bird-bench.github.io)",
|
| 12 |
+
"https://bird-bench.github.io/",
|
| 13 |
+
),
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def source_link_for(db_id: str) -> tuple[str, str] | None:
|
| 18 |
+
if db_id in SOURCE_LINKS:
|
| 19 |
+
return SOURCE_LINKS[db_id]
|
| 20 |
+
if db_id.startswith("bird_"):
|
| 21 |
+
return SOURCE_LINKS["_bird_default"]
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
SAMPLE_QUESTIONS: dict[str, list[tuple[str, str]]] = {
|
| 26 |
+
"chinook": [
|
| 27 |
+
("simple", "How many albums are in the store?"),
|
| 28 |
+
("simple", "Which 5 artists have the most albums?"),
|
| 29 |
+
("moderate", "What is the total revenue per genre?"),
|
| 30 |
+
],
|
| 31 |
+
"bird_california_schools": [
|
| 32 |
+
(
|
| 33 |
+
"simple",
|
| 34 |
+
"How many schools with an average score in Math greater than 400 in the SAT test are exclusively virtual?",
|
| 35 |
+
),
|
| 36 |
+
(
|
| 37 |
+
"simple",
|
| 38 |
+
"What is the average number of test takers from Fresno schools that opened between 1/1/1980 and 12/31/1980?",
|
| 39 |
+
),
|
| 40 |
+
(
|
| 41 |
+
"moderate",
|
| 42 |
+
"What is the ratio of merged Unified School District schools in Orange County to merged Elementary School District schools?",
|
| 43 |
+
),
|
| 44 |
+
],
|
| 45 |
+
"bird_card_games": [
|
| 46 |
+
("simple", "How many cards have infinite power?"),
|
| 47 |
+
(
|
| 48 |
+
"simple",
|
| 49 |
+
"What language is the set of 180 cards that belongs to the Ravnica block translated into?",
|
| 50 |
+
),
|
| 51 |
+
(
|
| 52 |
+
"moderate",
|
| 53 |
+
"Among the sets in the block 'Ice Age', how many of them have an Italian translation?",
|
| 54 |
+
),
|
| 55 |
+
],
|
| 56 |
+
"bird_codebase_community": [
|
| 57 |
+
("simple", "When did 'chl' cast its first vote in a post?"),
|
| 58 |
+
(
|
| 59 |
+
"simple",
|
| 60 |
+
"What is the display name of the user who acquired the first Autobiographer badge?",
|
| 61 |
+
),
|
| 62 |
+
(
|
| 63 |
+
"moderate",
|
| 64 |
+
"Among the posts with views ranging from 100 to 150, what is the comment with the highest score?",
|
| 65 |
+
),
|
| 66 |
+
],
|
| 67 |
+
"bird_debit_card_specializing": [
|
| 68 |
+
("simple", "What segment did the customer have at 2012/8/23 21:20:00?"),
|
| 69 |
+
(
|
| 70 |
+
"simple",
|
| 71 |
+
"What is the percentage of 'premium' against the overall segment in Country = 'SVK'?",
|
| 72 |
+
),
|
| 73 |
+
(
|
| 74 |
+
"moderate",
|
| 75 |
+
"What was the average monthly consumption of customers in SME for the year 2013?",
|
| 76 |
+
),
|
| 77 |
+
],
|
| 78 |
+
"bird_european_football_2": [
|
| 79 |
+
("simple", "List down most tallest players' name."),
|
| 80 |
+
("simple", "Please name one player whose overall strength is the greatest."),
|
| 81 |
+
("moderate", "What was the overall rating for Aaron Mooy on 2016/2/4?"),
|
| 82 |
+
],
|
| 83 |
+
"bird_financial": [
|
| 84 |
+
(
|
| 85 |
+
"simple",
|
| 86 |
+
"For the female client who was born in 1976/1/29, which district did she opened her account?",
|
| 87 |
+
),
|
| 88 |
+
(
|
| 89 |
+
"simple",
|
| 90 |
+
"List out the no. of districts that have female average salary is more than 6000 but less than 10000?",
|
| 91 |
+
),
|
| 92 |
+
(
|
| 93 |
+
"moderate",
|
| 94 |
+
"Provide the IDs and age of the client with high level credit card, which is eligible for loans.",
|
| 95 |
+
),
|
| 96 |
+
],
|
| 97 |
+
"bird_formula_1": [
|
| 98 |
+
("simple", "What's the reference name of Marina Bay Street Circuit?"),
|
| 99 |
+
("simple", "Please state the reference name of the oldest German driver."),
|
| 100 |
+
("simple", "What's Bruno Senna's Q1 result in the qualifying race No. 354?"),
|
| 101 |
+
],
|
| 102 |
+
"bird_student_club": [
|
| 103 |
+
("simple", "What's Angela Sanders's major?"),
|
| 104 |
+
("simple", "Mention the total expense used on 8/20/2019."),
|
| 105 |
+
("simple", "What is the total amount of money spent for food?"),
|
| 106 |
+
],
|
| 107 |
+
"bird_superhero": [
|
| 108 |
+
("simple", "What is Copycat's race?"),
|
| 109 |
+
("moderate", "Which hero was the fastest?"),
|
| 110 |
+
("moderate", "Who is the dumbest superhero?"),
|
| 111 |
+
],
|
| 112 |
+
"bird_thrombosis_prediction": [
|
| 113 |
+
("simple", "How many female patients were given an APS diagnosis?"),
|
| 114 |
+
("moderate", "State the ID and age of patient with positive degree of coagulation."),
|
| 115 |
+
("moderate", "Was the patient with the number 57266's uric acid within a normal range?"),
|
| 116 |
+
],
|
| 117 |
+
"bird_toxicology": [
|
| 118 |
+
("simple", "How many connections does the atom 19 have?"),
|
| 119 |
+
("moderate", "Which non-carcinogenic molecules consisted more than 5 atoms?"),
|
| 120 |
+
("challenging", "List the elements of all the triple bonds."),
|
| 121 |
+
],
|
| 122 |
+
}
|
app/streamlit_app.py
CHANGED
|
@@ -9,1000 +9,22 @@ Run with:
|
|
| 9 |
uv run streamlit run app/streamlit_app.py
|
| 10 |
"""
|
| 11 |
|
| 12 |
-
# Bilingual UI mixes Cyrillic and Latin in `I18N["ru"]` — silence the
|
| 13 |
-
# ambiguous-glyph lint at module scope.
|
| 14 |
-
# ruff: noqa: RUF001
|
| 15 |
-
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
import time
|
| 19 |
-
from pathlib import Path
|
| 20 |
from typing import Any, cast
|
| 21 |
|
| 22 |
-
import chromadb
|
| 23 |
-
import pandas as pd
|
| 24 |
-
import plotly.express as px
|
| 25 |
import streamlit as st
|
| 26 |
|
| 27 |
-
from
|
| 28 |
-
from
|
| 29 |
-
from
|
| 30 |
-
from
|
| 31 |
-
from
|
| 32 |
-
from
|
| 33 |
-
from nl_sql.
|
| 34 |
-
from
|
| 35 |
-
|
| 36 |
-
LineChart,
|
| 37 |
-
OutputFormat,
|
| 38 |
-
PieChart,
|
| 39 |
-
Scalar,
|
| 40 |
-
ScatterChart,
|
| 41 |
-
Sentence,
|
| 42 |
-
Table,
|
| 43 |
-
)
|
| 44 |
-
from nl_sql.render.labels import classify_scalar_label
|
| 45 |
-
from nl_sql.schema_index.indexer import SchemaIndex
|
| 46 |
-
|
| 47 |
-
# --------------------------------------------------------- i18n
|
| 48 |
-
# Chrome-level strings only. Sample questions stay in their natural
|
| 49 |
-
# language — the pipeline handles EN + RU both, the toggle only flips
|
| 50 |
-
# the surrounding UI copy.
|
| 51 |
-
|
| 52 |
-
I18N: dict[str, dict[str, str]] = {
|
| 53 |
-
"en": {
|
| 54 |
-
"page_title": "NL → SQL",
|
| 55 |
-
"tagline": "Natural language in. SQL out. Answer rendered in whichever shape fits the question.",
|
| 56 |
-
"lang_label": "Language",
|
| 57 |
-
"lang_en": "EN",
|
| 58 |
-
"lang_ru": "RU",
|
| 59 |
-
"metric_kicker": "Chinook business workload",
|
| 60 |
-
"metric_value": "60 / 60 correct",
|
| 61 |
-
"metric_percent": "100%",
|
| 62 |
-
"metric_caption": "30 dev + 30 held-out, balanced split, all ten query categories at 100% on the free-tier codestral pipeline.",
|
| 63 |
-
"research_kicker": "BIRD Mini-Dev research benchmark",
|
| 64 |
-
"research_value": "94.0% / 200",
|
| 65 |
-
"research_caption": (
|
| 66 |
-
"Hybrid pipeline: "
|
| 67 |
-
"<span class='nl-term' title='Mistral codestral-latest — SQL-specialised generation model, free tier'>codestral</span> + "
|
| 68 |
-
"<span class='nl-term' title='Anthropic Claude 4.5 Sonnet via Perplexity Pro browser bridge — used on the hard tier'>Sonnet 4.6 bridge</span> + "
|
| 69 |
-
"<span class='nl-term' title='Per-failure re-prompt with executable-shape feedback — only on frozen failures, no T=0 noise'>grounded-critique retry</span> + "
|
| 70 |
-
"<span class='nl-term' title='helallao reverse-engineered HTTPS bridge to Perplexity backend — Grok 4.1, GPT-5.2, Claude 4.5 Sonnet, kimi-k2-thinking, gpt-5.2-thinking + DAC on residue, claude-4.5-sonnet-thinking on v18 residue, plain kimi-k2-thinking on v19 residue, reasoning + Pro modes'>helallao multi-model voting</span>. "
|
| 71 |
-
"Scored under "
|
| 72 |
-
"<span class='nl-term' title='bird-bench/mini_dev evaluation_ex.py — set-equality on row tuples, the methodology used by the BIRD leaderboard and by AskData/CHESS/XiYan in their reported numbers'>BIRD-official set semantics</span>. "
|
| 73 |
-
"+46.2pp over the GPT-4 zero-shot reference (47.8%), $0 external cost. **Above human-expert baseline 92.96% (BIRD paper) by +1.04pp.** "
|
| 74 |
-
"On <span class='nl-term' title='Jin et al., CIDR/VLDB 2026, arXiv:2601.08778 — corrected BIRD gold annotations'>Arcwise-Plat corrected gold</span>: 74.37% (148/199) — honest noise-floor; +7 sql_only catches where our prediction is correct under Arcwise's corrected gold but BIRD's original gold disagrees. "
|
| 75 |
-
"Seven late-stage model rescues on v16→v22, two archive-audit rescores on v23/v24 (qid 1205 via archive sweep, qid 959 via archive-rescore after the day-5 bind-bug fix), and nine targeted P3.F schema-link hints on v25→v31: qid 902 (driverStandings.position vs results.position), qid 1531 (yearmonth.Consumption subquery + SUM(Price/Amount) row-wise), qid 894 (lapTimes.milliseconds first SELECT column), qid 1251 (Patient ⋈ Laboratory ⋈ Examination semi-join), qid 408 (rulings.text filter via cards.uuid join + COUNT(DISTINCT cards.id)), qid 1275 (Laboratory.CENTROMEA/SSB IN ('negative','0') instead of fabricated tokens against Examination), qid 1168 (override projection-discipline: include Patient.Birthday as third SELECT column + ORDER BY Birthday ASC LIMIT 1 on JOIN), qid 1029 (european_football_2 positional inversion: 'highest buildUpPlaySpeed' = lower numeric value, sort ASC + INNER JOIN Team), qid 37 (california_schools 'lowest excellence rate' — BIRD inverts question word-order 'Street, City, Zip and State' to SELECT (Street, City, State, Zip); 'excellence rate' = NumGE1500 / NumTstTakr ASC LIMIT 1 directly on JOIN). Every cell verified via audit_rescore.py — 0 mismatches."
|
| 76 |
-
),
|
| 77 |
-
"settings_header": "Settings",
|
| 78 |
-
"db_label": "Database",
|
| 79 |
-
"db_dialect": "Dialect",
|
| 80 |
-
"db_source": "Source",
|
| 81 |
-
"schema_explorer_collapsed": "Schema · {n} tables",
|
| 82 |
-
"schema_explorer_empty": "Schema index empty for this database. Run scripts/build_index.py.",
|
| 83 |
-
"schema_explorer_caption": "The same chunks the retriever sees — table cards with columns, types, null and distinct stats, sample values, and foreign keys.",
|
| 84 |
-
"mode_header": "Mode",
|
| 85 |
-
"mode_accurate": "Accurate",
|
| 86 |
-
"mode_fast": "Fast",
|
| 87 |
-
"mode_debug": "Debug",
|
| 88 |
-
"mode_accurate_caption": "fewshot + verify-retry — best EA",
|
| 89 |
-
"mode_fast_caption": "no fewshot — fastest, slight EA loss",
|
| 90 |
-
"mode_debug_caption": "Accurate + raw trace in show-working",
|
| 91 |
-
"advanced_header": "Advanced retrieval",
|
| 92 |
-
"schema_top_k": "schema_top_k",
|
| 93 |
-
"fk_hops": "fk_hops",
|
| 94 |
-
"table_budget": "table_budget",
|
| 95 |
-
"sort_schema": "sort schema block (alphabetical)",
|
| 96 |
-
"sample_size": "extended sample size",
|
| 97 |
-
"clear_chat": "Clear chat",
|
| 98 |
-
"ask_placeholder": "Ask a question about this database (EN or RU)…",
|
| 99 |
-
"ask_intro_label": "Try one of these to start",
|
| 100 |
-
"diff_simple": "simple",
|
| 101 |
-
"diff_moderate": "moderate",
|
| 102 |
-
"diff_challenging": "challenging",
|
| 103 |
-
"no_samples": "No sample questions curated for this database yet — type your own below.",
|
| 104 |
-
"spinner_generating": "Generating SQL and executing…",
|
| 105 |
-
"pipeline_crashed": "Pipeline crashed: {kind}: {msg}",
|
| 106 |
-
"sql_label": "SQL",
|
| 107 |
-
"no_sql": "Pipeline produced no SQL.",
|
| 108 |
-
"wall_model": "{wall:.0f} ms · {model}",
|
| 109 |
-
"show_working": "Show working — pipeline trace, SQL, metadata",
|
| 110 |
-
"trace_header": "Pipeline trace",
|
| 111 |
-
"meta_header": "Metadata",
|
| 112 |
-
"shape_header": "Result shape",
|
| 113 |
-
"confidence_label": "Confidence",
|
| 114 |
-
"repair_attempted": "Repair attempted",
|
| 115 |
-
"db_field": "Database",
|
| 116 |
-
"rows_returned": "Rows returned",
|
| 117 |
-
"columns_field": "Columns",
|
| 118 |
-
"no_rows": "No result rows.",
|
| 119 |
-
"rationale_header": "Rationale",
|
| 120 |
-
"error_kind": "Error",
|
| 121 |
-
"no_output_warning": "No output format produced.",
|
| 122 |
-
"conf_high": "High",
|
| 123 |
-
"conf_med": "Medium",
|
| 124 |
-
"conf_low": "Low",
|
| 125 |
-
"conf_unknown": "Unknown",
|
| 126 |
-
"scalar_label_count": "Count",
|
| 127 |
-
"scalar_label_sum": "Sum",
|
| 128 |
-
"scalar_label_average": "Average",
|
| 129 |
-
"scalar_label_minimum": "Minimum",
|
| 130 |
-
"scalar_label_maximum": "Maximum",
|
| 131 |
-
"scalar_label_ratio": "Ratio",
|
| 132 |
-
"scalar_label_result": "Result",
|
| 133 |
-
},
|
| 134 |
-
"ru": {
|
| 135 |
-
"page_title": "NL → SQL",
|
| 136 |
-
"tagline": "На входе — естественный язык. На выходе — SQL и ответ в форме, которая подходит вопросу.",
|
| 137 |
-
"lang_label": "Язык",
|
| 138 |
-
"lang_en": "EN",
|
| 139 |
-
"lang_ru": "RU",
|
| 140 |
-
"metric_kicker": "Бизнес-нагрузка Chinook",
|
| 141 |
-
"metric_value": "60 из 60",
|
| 142 |
-
"metric_percent": "100%",
|
| 143 |
-
"metric_caption": "30 dev + 30 held-out, сбалансированный сплит, все десять категорий запросов на 100% через бесплатный codestral.",
|
| 144 |
-
"research_kicker": "Исследовательский бенчмарк BIRD Mini-Dev",
|
| 145 |
-
"research_value": "94,0% / 200",
|
| 146 |
-
"research_caption": (
|
| 147 |
-
"Гибридный пайплайн: "
|
| 148 |
-
"<span class='nl-term' title='Mistral codestral-latest — модель, специализированная под генерацию SQL, бесплатный тариф'>codestral</span> + "
|
| 149 |
-
"<span class='nl-term' title='Anthropic Claude 4.5 Sonnet через браузерный мост Perplexity Pro — на сложных кейсах'>мост к Sonnet 4.6</span> + "
|
| 150 |
-
"<span class='nl-term' title='Повторный prompt со shape-фидбэком исполнения — только на зафиксированных фейлах, без шума T=0'>directed-critique retry</span> + "
|
| 151 |
-
"<span class='nl-term' title='Реверс-инжиниринг HTTPS моста к бэкенду Perplexity — Grok 4.1, GPT-5.2, Claude 4.5 Sonnet, kimi-k2-thinking, gpt-5.2-thinking + DAC на residue, claude-4.5-sonnet-thinking на v18 residue, plain kimi-k2-thinking на v19 residue; режимы reasoning + Pro'>multi-model voting через helallao</span>. "
|
| 152 |
-
"Scoring — "
|
| 153 |
-
"<span class='nl-term' title='bird-bench/mini_dev evaluation_ex.py — set-равенство на результирующих кортежах. Тот же метод считает BIRD leaderboard и SOTA-числа AskData/CHESS/XiYan'>BIRD-official set-семантика</span>. "
|
| 154 |
-
"+46,2 п.п. над zero-shot GPT-4 (47,8%), внешние расходы — ноль. **Выше human-expert baseline 92,96% (BIRD paper) на +1,04 п.п.** "
|
| 155 |
-
"На <span class='nl-term' title='Jin et al., CIDR/VLDB 2026, arXiv:2601.08778 — исправленные аннотации gold BIRD'>исправленном gold Arcwise-Plat</span>: 74,37% (148/199) — честный noise-floor; +7 sql_only catches, где наш ответ правильнее эталона BIRD согласно Arcwise. "
|
| 156 |
-
"Семь late-stage rescue по моделям на пути v16→v22, плюс v23/v24 — archive-sweep и archive-rescore (qid 1205 / qid 959 после day-5 bind-bug fix), плюс v25→v31 — девять узких P3.F schema-link hint'ов: qid 902 (driverStandings.position вместо results.position), qid 1531 (subquery по yearmonth.Consumption + SUM(Price/Amount) построчно), qid 894 (lapTimes.milliseconds первой колонкой), qid 1251 (полу-джойн Patient ⋈ Laboratory ⋈ Examination), qid 408 (фильтр по rulings.text через join cards.uuid + COUNT(DISTINCT cards.id)), qid 1275 (Laboratory.CENTROMEA/SSB IN ('negative','0') вместо несуществующих Examination columns + invented '-'/'+-' tokens), qid 1168 (override projection-discipline: Patient.Birthday как 3-я колонка SELECT + ORDER BY Birthday ASC LIMIT 1 прямо на JOIN), qid 1029 (european_football_2 positional inversion: 'highest buildUpPlaySpeed' = меньшее число, sort ASC + INNER JOIN Team), qid 37 (california_schools 'lowest excellence rate' — BIRD инвертирует word-order вопроса 'Street, City, Zip and State' в SELECT (Street, City, State, Zip); 'excellence rate' = NumGE1500 / NumTstTakr ASC LIMIT 1 прямо на JOIN). Каждая ячейка верифицирована через audit_rescore.py — 0 mismatches."
|
| 157 |
-
),
|
| 158 |
-
"settings_header": "Настройки",
|
| 159 |
-
"db_label": "База данных",
|
| 160 |
-
"db_dialect": "Диалект",
|
| 161 |
-
"db_source": "Источник",
|
| 162 |
-
"schema_explorer_collapsed": "Схема · {n} таблиц",
|
| 163 |
-
"schema_explorer_empty": "Индекс схемы пуст для этой БД. Запусти scripts/build_index.py.",
|
| 164 |
-
"schema_explorer_caption": "Те же чанки, которые видит ретривер — карточки таблиц с колонками, типами, null/distinct, sample-значениями и foreign keys.",
|
| 165 |
-
"mode_header": "Режим",
|
| 166 |
-
"mode_accurate": "Точно",
|
| 167 |
-
"mode_fast": "Быстро",
|
| 168 |
-
"mode_debug": "Отладка",
|
| 169 |
-
"mode_accurate_caption": "fewshot + verify-retry — максимальный EA",
|
| 170 |
-
"mode_fast_caption": "без fewshot — быстрее, EA чуть ниже",
|
| 171 |
-
"mode_debug_caption": "Точно + сырой trace в show-working",
|
| 172 |
-
"advanced_header": "Тонкая настройка ретривала",
|
| 173 |
-
"schema_top_k": "schema_top_k",
|
| 174 |
-
"fk_hops": "fk_hops",
|
| 175 |
-
"table_budget": "table_budget",
|
| 176 |
-
"sort_schema": "сортировать блок схемы (по алфавиту)",
|
| 177 |
-
"sample_size": "размер расширенного семпла",
|
| 178 |
-
"clear_chat": "Очистить чат",
|
| 179 |
-
"ask_placeholder": "Спроси что-нибудь об этой базе (EN или RU)…",
|
| 180 |
-
"ask_intro_label": "Можно начать с одного из этих вопросов",
|
| 181 |
-
"diff_simple": "просто",
|
| 182 |
-
"diff_moderate": "средне",
|
| 183 |
-
"diff_challenging": "сложно",
|
| 184 |
-
"no_samples": "Для этой БД пока нет подготовленных вопросов — задай свой ниже.",
|
| 185 |
-
"spinner_generating": "Генерирую SQL и выполняю…",
|
| 186 |
-
"pipeline_crashed": "Пайплайн упал: {kind}: {msg}",
|
| 187 |
-
"sql_label": "SQL",
|
| 188 |
-
"no_sql": "Пайплайн не выдал SQL.",
|
| 189 |
-
"wall_model": "{wall:.0f} мс · {model}",
|
| 190 |
-
"show_working": "Показать работу — trace, SQL, метаданные",
|
| 191 |
-
"trace_header": "Trace пайплайна",
|
| 192 |
-
"meta_header": "Метаданные",
|
| 193 |
-
"shape_header": "Форма результата",
|
| 194 |
-
"confidence_label": "Уверенность",
|
| 195 |
-
"repair_attempted": "Был ли repair",
|
| 196 |
-
"db_field": "База",
|
| 197 |
-
"rows_returned": "Строк в ответе",
|
| 198 |
-
"columns_field": "Колонки",
|
| 199 |
-
"no_rows": "Строки не вернулись.",
|
| 200 |
-
"rationale_header": "Обоснование",
|
| 201 |
-
"error_kind": "Ошибка",
|
| 202 |
-
"no_output_warning": "Формат вывода не был построен.",
|
| 203 |
-
"conf_high": "Высокая",
|
| 204 |
-
"conf_med": "Средняя",
|
| 205 |
-
"conf_low": "Низкая",
|
| 206 |
-
"conf_unknown": "Неизвестно",
|
| 207 |
-
"scalar_label_count": "Количество",
|
| 208 |
-
"scalar_label_sum": "Сумма",
|
| 209 |
-
"scalar_label_average": "Среднее",
|
| 210 |
-
"scalar_label_minimum": "Минимум",
|
| 211 |
-
"scalar_label_maximum": "Максимум",
|
| 212 |
-
"scalar_label_ratio": "Отношение",
|
| 213 |
-
"scalar_label_result": "Результат",
|
| 214 |
-
},
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def _t(key: str, **kwargs: Any) -> str:
|
| 219 |
-
lang = st.session_state.get("lang", "en")
|
| 220 |
-
template = I18N.get(lang, I18N["en"]).get(key) or I18N["en"].get(key) or key
|
| 221 |
-
return template.format(**kwargs) if kwargs else template
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
# --------------------------------------------------------- sample questions
|
| 225 |
-
|
| 226 |
-
SOURCE_LINKS: dict[str, tuple[str, str]] = {
|
| 227 |
-
"chinook": (
|
| 228 |
-
"Chinook SQLite (lerocha/chinook-database)",
|
| 229 |
-
"https://github.com/lerocha/chinook-database",
|
| 230 |
-
),
|
| 231 |
-
"_bird_default": (
|
| 232 |
-
"BIRD Mini-Dev (bird-bench.github.io)",
|
| 233 |
-
"https://bird-bench.github.io/",
|
| 234 |
-
),
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
def _source_link_for(db_id: str) -> tuple[str, str] | None:
|
| 239 |
-
if db_id in SOURCE_LINKS:
|
| 240 |
-
return SOURCE_LINKS[db_id]
|
| 241 |
-
if db_id.startswith("bird_"):
|
| 242 |
-
return SOURCE_LINKS["_bird_default"]
|
| 243 |
-
return None
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
SAMPLE_QUESTIONS: dict[str, list[tuple[str, str]]] = {
|
| 247 |
-
"chinook": [
|
| 248 |
-
("simple", "How many albums are in the store?"),
|
| 249 |
-
("simple", "Which 5 artists have the most albums?"),
|
| 250 |
-
("moderate", "What is the total revenue per genre?"),
|
| 251 |
-
],
|
| 252 |
-
"bird_california_schools": [
|
| 253 |
-
(
|
| 254 |
-
"simple",
|
| 255 |
-
"How many schools with an average score in Math greater than 400 in the SAT test are exclusively virtual?",
|
| 256 |
-
),
|
| 257 |
-
(
|
| 258 |
-
"simple",
|
| 259 |
-
"What is the average number of test takers from Fresno schools that opened between 1/1/1980 and 12/31/1980?",
|
| 260 |
-
),
|
| 261 |
-
(
|
| 262 |
-
"moderate",
|
| 263 |
-
"What is the ratio of merged Unified School District schools in Orange County to merged Elementary School District schools?",
|
| 264 |
-
),
|
| 265 |
-
],
|
| 266 |
-
"bird_card_games": [
|
| 267 |
-
("simple", "How many cards have infinite power?"),
|
| 268 |
-
(
|
| 269 |
-
"simple",
|
| 270 |
-
"What language is the set of 180 cards that belongs to the Ravnica block translated into?",
|
| 271 |
-
),
|
| 272 |
-
(
|
| 273 |
-
"moderate",
|
| 274 |
-
"Among the sets in the block 'Ice Age', how many of them have an Italian translation?",
|
| 275 |
-
),
|
| 276 |
-
],
|
| 277 |
-
"bird_codebase_community": [
|
| 278 |
-
("simple", "When did 'chl' cast its first vote in a post?"),
|
| 279 |
-
(
|
| 280 |
-
"simple",
|
| 281 |
-
"What is the display name of the user who acquired the first Autobiographer badge?",
|
| 282 |
-
),
|
| 283 |
-
(
|
| 284 |
-
"moderate",
|
| 285 |
-
"Among the posts with views ranging from 100 to 150, what is the comment with the highest score?",
|
| 286 |
-
),
|
| 287 |
-
],
|
| 288 |
-
"bird_debit_card_specializing": [
|
| 289 |
-
("simple", "What segment did the customer have at 2012/8/23 21:20:00?"),
|
| 290 |
-
(
|
| 291 |
-
"simple",
|
| 292 |
-
"What is the percentage of 'premium' against the overall segment in Country = 'SVK'?",
|
| 293 |
-
),
|
| 294 |
-
(
|
| 295 |
-
"moderate",
|
| 296 |
-
"What was the average monthly consumption of customers in SME for the year 2013?",
|
| 297 |
-
),
|
| 298 |
-
],
|
| 299 |
-
"bird_european_football_2": [
|
| 300 |
-
("simple", "List down most tallest players' name."),
|
| 301 |
-
("simple", "Please name one player whose overall strength is the greatest."),
|
| 302 |
-
("moderate", "What was the overall rating for Aaron Mooy on 2016/2/4?"),
|
| 303 |
-
],
|
| 304 |
-
"bird_financial": [
|
| 305 |
-
(
|
| 306 |
-
"simple",
|
| 307 |
-
"For the female client who was born in 1976/1/29, which district did she opened her account?",
|
| 308 |
-
),
|
| 309 |
-
(
|
| 310 |
-
"simple",
|
| 311 |
-
"List out the no. of districts that have female average salary is more than 6000 but less than 10000?",
|
| 312 |
-
),
|
| 313 |
-
(
|
| 314 |
-
"moderate",
|
| 315 |
-
"Provide the IDs and age of the client with high level credit card, which is eligible for loans.",
|
| 316 |
-
),
|
| 317 |
-
],
|
| 318 |
-
"bird_formula_1": [
|
| 319 |
-
("simple", "What's the reference name of Marina Bay Street Circuit?"),
|
| 320 |
-
("simple", "Please state the reference name of the oldest German driver."),
|
| 321 |
-
("simple", "What's Bruno Senna's Q1 result in the qualifying race No. 354?"),
|
| 322 |
-
],
|
| 323 |
-
"bird_student_club": [
|
| 324 |
-
("simple", "What's Angela Sanders's major?"),
|
| 325 |
-
("simple", "Mention the total expense used on 8/20/2019."),
|
| 326 |
-
("simple", "What is the total amount of money spent for food?"),
|
| 327 |
-
],
|
| 328 |
-
"bird_superhero": [
|
| 329 |
-
("simple", "What is Copycat's race?"),
|
| 330 |
-
("moderate", "Which hero was the fastest?"),
|
| 331 |
-
("moderate", "Who is the dumbest superhero?"),
|
| 332 |
-
],
|
| 333 |
-
"bird_thrombosis_prediction": [
|
| 334 |
-
("simple", "How many female patients were given an APS diagnosis?"),
|
| 335 |
-
("moderate", "State the ID and age of patient with positive degree of coagulation."),
|
| 336 |
-
("moderate", "Was the patient with the number 57266's uric acid within a normal range?"),
|
| 337 |
-
],
|
| 338 |
-
"bird_toxicology": [
|
| 339 |
-
("simple", "How many connections does the atom 19 have?"),
|
| 340 |
-
("moderate", "Which non-carcinogenic molecules consisted more than 5 atoms?"),
|
| 341 |
-
("challenging", "List the elements of all the triple bonds."),
|
| 342 |
-
],
|
| 343 |
-
}
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
# --------------------------------------------------------- typography + chrome
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
_FONT_CSS = """
|
| 350 |
-
<style>
|
| 351 |
-
@font-face {
|
| 352 |
-
font-family: 'Stetica';
|
| 353 |
-
src: url('/app/static/fonts/stetica-regular.otf') format('opentype');
|
| 354 |
-
font-weight: 400;
|
| 355 |
-
font-style: normal;
|
| 356 |
-
font-display: swap;
|
| 357 |
-
}
|
| 358 |
-
@font-face {
|
| 359 |
-
font-family: 'Stetica';
|
| 360 |
-
src: url('/app/static/fonts/stetica-medium.otf') format('opentype');
|
| 361 |
-
font-weight: 500;
|
| 362 |
-
font-style: normal;
|
| 363 |
-
font-display: swap;
|
| 364 |
-
}
|
| 365 |
-
@font-face {
|
| 366 |
-
font-family: 'Stetica';
|
| 367 |
-
src: url('/app/static/fonts/stetica-bold.otf') format('opentype');
|
| 368 |
-
font-weight: 700;
|
| 369 |
-
font-style: normal;
|
| 370 |
-
font-display: swap;
|
| 371 |
-
}
|
| 372 |
-
@font-face {
|
| 373 |
-
font-family: 'NLEdSerif';
|
| 374 |
-
src: url('/app/static/fonts/serif-regular.otf') format('opentype');
|
| 375 |
-
font-weight: 400;
|
| 376 |
-
font-style: normal;
|
| 377 |
-
font-display: swap;
|
| 378 |
-
}
|
| 379 |
-
@font-face {
|
| 380 |
-
font-family: 'NLEdSerif';
|
| 381 |
-
src: url('/app/static/fonts/serif-bold.otf') format('opentype');
|
| 382 |
-
font-weight: 700;
|
| 383 |
-
font-style: normal;
|
| 384 |
-
font-display: swap;
|
| 385 |
-
}
|
| 386 |
-
|
| 387 |
-
:root {
|
| 388 |
-
--ink: #111111;
|
| 389 |
-
--ink-soft: #4A4A4A;
|
| 390 |
-
--ink-mute: #7A7A75;
|
| 391 |
-
--paper: #FAFAF7;
|
| 392 |
-
--paper-warm: #F1EFE9;
|
| 393 |
-
--rule: #1A1A1A;
|
| 394 |
-
--hairline: #DCD8CE;
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
html, body, [class*="css"], .stApp, .stMarkdown, .stChatMessage {
|
| 398 |
-
font-family: 'Stetica', system-ui, sans-serif !important;
|
| 399 |
-
color: var(--ink);
|
| 400 |
-
background: var(--paper);
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
.block-container {
|
| 404 |
-
padding-top: 2.4rem;
|
| 405 |
-
padding-bottom: 4rem;
|
| 406 |
-
max-width: 1080px;
|
| 407 |
-
}
|
| 408 |
-
|
| 409 |
-
/* Hide Streamlit chrome we don't want */
|
| 410 |
-
#MainMenu, footer, header [data-testid="stToolbar"] { visibility: hidden; }
|
| 411 |
-
header { background: var(--paper) !important; }
|
| 412 |
-
|
| 413 |
-
/* Display headline — serif */
|
| 414 |
-
.nl-display {
|
| 415 |
-
font-family: 'NLEdSerif', Georgia, serif;
|
| 416 |
-
font-weight: 400;
|
| 417 |
-
font-size: clamp(2.6rem, 5vw, 3.6rem);
|
| 418 |
-
letter-spacing: -0.02em;
|
| 419 |
-
line-height: 0.95;
|
| 420 |
-
color: var(--ink);
|
| 421 |
-
margin: 0 0 0.4rem 0;
|
| 422 |
-
}
|
| 423 |
-
.nl-display .arrow {
|
| 424 |
-
font-weight: 700;
|
| 425 |
-
display: inline-block;
|
| 426 |
-
transform: translateY(-0.04em);
|
| 427 |
-
margin: 0 0.25rem;
|
| 428 |
-
}
|
| 429 |
-
|
| 430 |
-
.nl-tagline {
|
| 431 |
-
font-family: 'Stetica', system-ui, sans-serif;
|
| 432 |
-
font-weight: 400;
|
| 433 |
-
font-size: 1.02rem;
|
| 434 |
-
line-height: 1.5;
|
| 435 |
-
color: var(--ink-soft);
|
| 436 |
-
max-width: 56ch;
|
| 437 |
-
margin: 0 0 2rem 0;
|
| 438 |
-
}
|
| 439 |
-
|
| 440 |
-
/* Kicker — small uppercase letter-spaced label */
|
| 441 |
-
.nl-kicker {
|
| 442 |
-
font-family: 'Stetica', sans-serif;
|
| 443 |
-
font-size: 0.68rem;
|
| 444 |
-
letter-spacing: 0.18em;
|
| 445 |
-
text-transform: uppercase;
|
| 446 |
-
color: var(--ink-mute);
|
| 447 |
-
margin-bottom: 0.5rem;
|
| 448 |
-
}
|
| 449 |
-
|
| 450 |
-
/* Metric block — pure typography, no card chrome */
|
| 451 |
-
.nl-metric {
|
| 452 |
-
border-top: 1px solid var(--rule);
|
| 453 |
-
padding-top: 0.8rem;
|
| 454 |
-
margin-top: 1.4rem;
|
| 455 |
-
}
|
| 456 |
-
.nl-metric-row {
|
| 457 |
-
display: flex;
|
| 458 |
-
align-items: baseline;
|
| 459 |
-
gap: 0.9rem;
|
| 460 |
-
margin-bottom: 0.5rem;
|
| 461 |
-
}
|
| 462 |
-
.nl-metric-value {
|
| 463 |
-
font-family: 'NLEdSerif', Georgia, serif;
|
| 464 |
-
font-weight: 700;
|
| 465 |
-
font-size: 2.2rem;
|
| 466 |
-
letter-spacing: -0.01em;
|
| 467 |
-
color: var(--ink);
|
| 468 |
-
line-height: 1;
|
| 469 |
-
}
|
| 470 |
-
.nl-metric-aside {
|
| 471 |
-
font-family: 'Stetica', sans-serif;
|
| 472 |
-
font-size: 0.86rem;
|
| 473 |
-
color: var(--ink-mute);
|
| 474 |
-
letter-spacing: 0.04em;
|
| 475 |
-
}
|
| 476 |
-
.nl-metric-cap {
|
| 477 |
-
font-family: 'Stetica', sans-serif;
|
| 478 |
-
font-size: 0.86rem;
|
| 479 |
-
color: var(--ink-soft);
|
| 480 |
-
line-height: 1.55;
|
| 481 |
-
max-width: 62ch;
|
| 482 |
-
}
|
| 483 |
-
.nl-term {
|
| 484 |
-
border-bottom: 1px dotted var(--ink-mute);
|
| 485 |
-
cursor: help;
|
| 486 |
-
text-decoration: none;
|
| 487 |
-
color: inherit;
|
| 488 |
-
}
|
| 489 |
-
.nl-term:hover {
|
| 490 |
-
border-bottom-color: var(--ink);
|
| 491 |
-
color: var(--ink);
|
| 492 |
-
}
|
| 493 |
-
|
| 494 |
-
/* Section rule */
|
| 495 |
-
.nl-section-label {
|
| 496 |
-
font-family: 'Stetica', sans-serif;
|
| 497 |
-
font-size: 0.68rem;
|
| 498 |
-
letter-spacing: 0.18em;
|
| 499 |
-
text-transform: uppercase;
|
| 500 |
-
color: var(--ink-mute);
|
| 501 |
-
margin: 2.4rem 0 0.7rem 0;
|
| 502 |
-
border-top: 1px solid var(--hairline);
|
| 503 |
-
padding-top: 0.7rem;
|
| 504 |
-
}
|
| 505 |
-
|
| 506 |
-
/* Sidebar polish */
|
| 507 |
-
[data-testid="stSidebar"] {
|
| 508 |
-
background: var(--paper-warm) !important;
|
| 509 |
-
border-right: 1px solid var(--hairline);
|
| 510 |
-
}
|
| 511 |
-
[data-testid="stSidebar"] .nl-side-h {
|
| 512 |
-
font-family: 'NLEdSerif', Georgia, serif;
|
| 513 |
-
font-weight: 700;
|
| 514 |
-
font-size: 1.1rem;
|
| 515 |
-
letter-spacing: -0.005em;
|
| 516 |
-
margin: 0.4rem 0 0.6rem 0;
|
| 517 |
-
}
|
| 518 |
-
[data-testid="stSidebar"] .nl-side-sub {
|
| 519 |
-
font-family: 'Stetica', sans-serif;
|
| 520 |
-
font-size: 0.7rem;
|
| 521 |
-
letter-spacing: 0.18em;
|
| 522 |
-
text-transform: uppercase;
|
| 523 |
-
color: var(--ink-mute);
|
| 524 |
-
margin: 1.2rem 0 0.4rem 0;
|
| 525 |
-
}
|
| 526 |
-
|
| 527 |
-
/* Language toggle */
|
| 528 |
-
.nl-lang-row { display: flex; gap: 0; }
|
| 529 |
-
.nl-lang-row button {
|
| 530 |
-
background: transparent !important;
|
| 531 |
-
color: var(--ink) !important;
|
| 532 |
-
border: 1px solid var(--rule) !important;
|
| 533 |
-
border-radius: 0 !important;
|
| 534 |
-
font-family: 'Stetica', sans-serif !important;
|
| 535 |
-
font-weight: 500 !important;
|
| 536 |
-
letter-spacing: 0.12em !important;
|
| 537 |
-
text-transform: uppercase;
|
| 538 |
-
padding: 0.35rem 0.9rem !important;
|
| 539 |
-
font-size: 0.74rem !important;
|
| 540 |
-
min-height: 0 !important;
|
| 541 |
-
}
|
| 542 |
-
|
| 543 |
-
/* Buttons (sample questions) */
|
| 544 |
-
.stButton > button {
|
| 545 |
-
background: transparent !important;
|
| 546 |
-
color: var(--ink) !important;
|
| 547 |
-
border: 1px solid var(--rule) !important;
|
| 548 |
-
border-radius: 0 !important;
|
| 549 |
-
font-family: 'Stetica', sans-serif !important;
|
| 550 |
-
font-weight: 400 !important;
|
| 551 |
-
font-size: 0.92rem !important;
|
| 552 |
-
text-align: left !important;
|
| 553 |
-
padding: 0.85rem 1rem !important;
|
| 554 |
-
line-height: 1.45 !important;
|
| 555 |
-
transition: background 0.12s;
|
| 556 |
-
white-space: normal !important;
|
| 557 |
-
height: auto !important;
|
| 558 |
-
}
|
| 559 |
-
.stButton > button:hover {
|
| 560 |
-
background: var(--ink) !important;
|
| 561 |
-
color: var(--paper) !important;
|
| 562 |
-
}
|
| 563 |
-
.stButton > button p {
|
| 564 |
-
color: inherit !important;
|
| 565 |
-
}
|
| 566 |
-
|
| 567 |
-
/* Chat input */
|
| 568 |
-
.stChatInput { border-top: 1px solid var(--rule) !important; }
|
| 569 |
-
.stChatInput textarea {
|
| 570 |
-
font-family: 'Stetica', sans-serif !important;
|
| 571 |
-
font-size: 1rem !important;
|
| 572 |
-
color: var(--ink) !important;
|
| 573 |
-
background: var(--paper) !important;
|
| 574 |
-
}
|
| 575 |
-
|
| 576 |
-
/* Code blocks — keep mono but on warm paper */
|
| 577 |
-
pre, code {
|
| 578 |
-
background: var(--paper-warm) !important;
|
| 579 |
-
color: var(--ink) !important;
|
| 580 |
-
border: 1px solid var(--hairline) !important;
|
| 581 |
-
border-radius: 0 !important;
|
| 582 |
-
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace !important;
|
| 583 |
-
}
|
| 584 |
-
|
| 585 |
-
/* Scalar metric block — flatten */
|
| 586 |
-
[data-testid="stMetric"] {
|
| 587 |
-
background: transparent !important;
|
| 588 |
-
border: none !important;
|
| 589 |
-
}
|
| 590 |
-
[data-testid="stMetricLabel"] {
|
| 591 |
-
font-family: 'Stetica', sans-serif !important;
|
| 592 |
-
font-size: 0.68rem !important;
|
| 593 |
-
letter-spacing: 0.18em !important;
|
| 594 |
-
text-transform: uppercase !important;
|
| 595 |
-
color: var(--ink-mute) !important;
|
| 596 |
-
}
|
| 597 |
-
[data-testid="stMetricValue"] {
|
| 598 |
-
font-family: 'NLEdSerif', Georgia, serif !important;
|
| 599 |
-
font-weight: 700 !important;
|
| 600 |
-
font-size: 2.4rem !important;
|
| 601 |
-
color: var(--ink) !important;
|
| 602 |
-
}
|
| 603 |
-
|
| 604 |
-
/* Tables */
|
| 605 |
-
[data-testid="stDataFrame"] { border: 1px solid var(--rule); }
|
| 606 |
-
|
| 607 |
-
/* Expanders */
|
| 608 |
-
.streamlit-expanderHeader {
|
| 609 |
-
font-family: 'Stetica', sans-serif !important;
|
| 610 |
-
font-size: 0.78rem !important;
|
| 611 |
-
letter-spacing: 0.1em;
|
| 612 |
-
text-transform: uppercase;
|
| 613 |
-
color: var(--ink) !important;
|
| 614 |
-
}
|
| 615 |
-
|
| 616 |
-
/* Sample card — wraps a button + difficulty kicker */
|
| 617 |
-
.nl-sample {
|
| 618 |
-
display: block;
|
| 619 |
-
}
|
| 620 |
-
.nl-sample-kicker {
|
| 621 |
-
font-family: 'Stetica', sans-serif;
|
| 622 |
-
font-size: 0.62rem;
|
| 623 |
-
letter-spacing: 0.22em;
|
| 624 |
-
text-transform: uppercase;
|
| 625 |
-
color: var(--ink-mute);
|
| 626 |
-
margin: 0 0 0.4rem 0.05rem;
|
| 627 |
-
}
|
| 628 |
-
|
| 629 |
-
/* Chat message bubbles — strip default round chrome */
|
| 630 |
-
[data-testid="stChatMessage"] {
|
| 631 |
-
background: transparent !important;
|
| 632 |
-
border: 0 !important;
|
| 633 |
-
padding: 0.4rem 0 1.4rem 0 !important;
|
| 634 |
-
}
|
| 635 |
-
[data-testid="stChatMessage"]:not(:first-child) {
|
| 636 |
-
border-top: 1px solid var(--hairline) !important;
|
| 637 |
-
padding-top: 1.4rem !important;
|
| 638 |
-
}
|
| 639 |
-
|
| 640 |
-
/* Remove the avatar/icon circle Streamlit injects — covers every variant */
|
| 641 |
-
[data-testid="stChatMessage"] > div:first-child,
|
| 642 |
-
[data-testid="chatAvatarIcon-user"],
|
| 643 |
-
[data-testid="chatAvatarIcon-assistant"],
|
| 644 |
-
[data-testid="stChatMessageAvatarUser"],
|
| 645 |
-
[data-testid="stChatMessageAvatarAssistant"],
|
| 646 |
-
[data-testid="stChatMessage"] [class*="Avatar"],
|
| 647 |
-
[data-testid="stChatMessage"] svg {
|
| 648 |
-
display: none !important;
|
| 649 |
-
}
|
| 650 |
-
|
| 651 |
-
/* The chat message body lives in second child after the avatar; pull it left */
|
| 652 |
-
[data-testid="stChatMessage"] > div:nth-child(2) {
|
| 653 |
-
margin-left: 0 !important;
|
| 654 |
-
padding-left: 0 !important;
|
| 655 |
-
width: 100% !important;
|
| 656 |
-
}
|
| 657 |
-
</style>
|
| 658 |
-
"""
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
def _inject_chrome() -> None:
|
| 662 |
-
st.markdown(_FONT_CSS, unsafe_allow_html=True)
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
# --------------------------------------------------------- resource bootstrap
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
@st.cache_resource(show_spinner="Initialising providers + Chroma index…")
|
| 669 |
-
def _bootstrap() -> tuple[DatabaseRegistry, SchemaIndex, LLMProvider, LLMProvider]:
|
| 670 |
-
settings = get_settings()
|
| 671 |
-
if not settings.mistral_api_key:
|
| 672 |
-
raise RuntimeError(
|
| 673 |
-
"MISTRAL_API_KEY is not set in .env — required for codestral + mistral-embed."
|
| 674 |
-
)
|
| 675 |
-
|
| 676 |
-
registry = get_default_registry()
|
| 677 |
-
|
| 678 |
-
persist_dir = Path("chroma_data")
|
| 679 |
-
if not persist_dir.is_dir():
|
| 680 |
-
raise RuntimeError(
|
| 681 |
-
f"Chroma persist dir {persist_dir!r} not found. "
|
| 682 |
-
"Run `uv run python scripts/build_index.py --db all` first."
|
| 683 |
-
)
|
| 684 |
-
chroma_client = chromadb.PersistentClient(path=str(persist_dir))
|
| 685 |
-
|
| 686 |
-
raw_embedder = MistralProvider(
|
| 687 |
-
api_key=settings.mistral_api_key,
|
| 688 |
-
gen_model=settings.mistral_gen_model,
|
| 689 |
-
embed_model=settings.mistral_embed_model,
|
| 690 |
-
base_url=settings.mistral_base_url,
|
| 691 |
-
)
|
| 692 |
-
embedder: EmbeddingProvider = CachingEmbeddingProvider(
|
| 693 |
-
raw_embedder,
|
| 694 |
-
cache_dir=settings.llm_cache_dir,
|
| 695 |
-
size_limit_gb=settings.llm_cache_size_limit_gb,
|
| 696 |
-
)
|
| 697 |
-
schema_index = SchemaIndex(persist_dir=persist_dir, embedder=embedder, client=chroma_client)
|
| 698 |
-
|
| 699 |
-
raw_sql = build_provider("mistral", settings=settings)
|
| 700 |
-
sql_provider: LLMProvider = CachingLLMProvider(
|
| 701 |
-
raw_sql,
|
| 702 |
-
cache_dir=settings.llm_cache_dir,
|
| 703 |
-
size_limit_gb=settings.llm_cache_size_limit_gb,
|
| 704 |
-
)
|
| 705 |
-
explain_provider = sql_provider
|
| 706 |
-
|
| 707 |
-
return registry, schema_index, sql_provider, explain_provider
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
def _make_pipeline(
|
| 711 |
-
registry: DatabaseRegistry,
|
| 712 |
-
schema_index: SchemaIndex,
|
| 713 |
-
sql_provider: LLMProvider,
|
| 714 |
-
explain_provider: LLMProvider,
|
| 715 |
-
*,
|
| 716 |
-
schema_top_k: int,
|
| 717 |
-
fk_hops: int,
|
| 718 |
-
table_budget: int,
|
| 719 |
-
sort_schema_block: bool,
|
| 720 |
-
extended_sample_size: int,
|
| 721 |
-
fewshot_top_k: int = 3,
|
| 722 |
-
cross_db_fewshot: bool = True,
|
| 723 |
-
verify_retry_on_empty: bool = True,
|
| 724 |
-
) -> Any:
|
| 725 |
-
config = PipelineConfig(
|
| 726 |
-
sql_provider=sql_provider,
|
| 727 |
-
explain_provider=explain_provider,
|
| 728 |
-
schema_index=schema_index,
|
| 729 |
-
registry=registry,
|
| 730 |
-
schema_top_k=schema_top_k,
|
| 731 |
-
fewshot_top_k=fewshot_top_k,
|
| 732 |
-
fk_hops=fk_hops,
|
| 733 |
-
table_budget=table_budget,
|
| 734 |
-
sort_schema_block=sort_schema_block,
|
| 735 |
-
primary_sample_size=3,
|
| 736 |
-
extended_sample_size=extended_sample_size,
|
| 737 |
-
cross_db_fewshot=cross_db_fewshot,
|
| 738 |
-
verify_retry_on_empty=verify_retry_on_empty,
|
| 739 |
-
)
|
| 740 |
-
return build_pipeline(config)
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
# --------------------------------------------------------- output renderers
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
def _render_output(output: OutputFormat | None, *, caption: str) -> None:
|
| 747 |
-
if isinstance(output, Scalar):
|
| 748 |
-
st.metric(_scalar_metric_label(output.column), str(output.value))
|
| 749 |
-
elif isinstance(output, Sentence):
|
| 750 |
-
st.markdown(
|
| 751 |
-
f"<div style=\"font-family:'NLEdSerif',Georgia,serif; "
|
| 752 |
-
f"font-size:1.25rem; line-height:1.45; color:var(--ink); "
|
| 753 |
-
f'margin:0.4rem 0 0.6rem;">{output.text}</div>',
|
| 754 |
-
unsafe_allow_html=True,
|
| 755 |
-
)
|
| 756 |
-
if output.fields:
|
| 757 |
-
st.json(output.fields, expanded=False)
|
| 758 |
-
elif isinstance(output, Table):
|
| 759 |
-
df = pd.DataFrame(output.rows, columns=output.columns)
|
| 760 |
-
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 761 |
-
elif isinstance(output, BarChart | LineChart | PieChart | ScatterChart):
|
| 762 |
-
df = pd.DataFrame(output.rows, columns=output.columns)
|
| 763 |
-
_render_chart(output, df)
|
| 764 |
-
elif output is None:
|
| 765 |
-
st.warning(_t("no_output_warning"))
|
| 766 |
-
if caption:
|
| 767 |
-
st.caption(caption)
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
_CHART_PALETTE = ["#111111", "#4A4A4A", "#7A7A75", "#A8A29E", "#1A1A1A"]
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
def _style_fig(fig: Any) -> Any:
|
| 774 |
-
fig.update_layout(
|
| 775 |
-
font_family="Stetica, system-ui, sans-serif",
|
| 776 |
-
font_color="#111111",
|
| 777 |
-
paper_bgcolor="#FAFAF7",
|
| 778 |
-
plot_bgcolor="#FAFAF7",
|
| 779 |
-
colorway=_CHART_PALETTE,
|
| 780 |
-
margin=dict(l=10, r=10, t=20, b=10),
|
| 781 |
-
)
|
| 782 |
-
fig.update_xaxes(gridcolor="#DCD8CE", zerolinecolor="#1A1A1A", tickcolor="#1A1A1A")
|
| 783 |
-
fig.update_yaxes(gridcolor="#DCD8CE", zerolinecolor="#1A1A1A", tickcolor="#1A1A1A")
|
| 784 |
-
return fig
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
def _render_chart(
|
| 788 |
-
spec: BarChart | LineChart | PieChart | ScatterChart,
|
| 789 |
-
df: pd.DataFrame,
|
| 790 |
-
) -> None:
|
| 791 |
-
if isinstance(spec, BarChart):
|
| 792 |
-
fig = px.bar(df, x=spec.x_field, y=spec.y_fields)
|
| 793 |
-
elif isinstance(spec, LineChart):
|
| 794 |
-
fig = px.line(df, x=spec.x_field, y=spec.y_fields)
|
| 795 |
-
elif isinstance(spec, PieChart):
|
| 796 |
-
y_field = spec.y_fields[0] if spec.y_fields else df.columns[1]
|
| 797 |
-
fig = px.pie(df, names=spec.x_field, values=y_field)
|
| 798 |
-
else:
|
| 799 |
-
y_field = spec.y_fields[0] if spec.y_fields else df.columns[1]
|
| 800 |
-
fig = px.scatter(df, x=spec.x_field, y=y_field)
|
| 801 |
-
st.plotly_chart(_style_fig(fig), use_container_width=True)
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
def _scalar_metric_label(column: str) -> str:
|
| 805 |
-
"""Translate a raw SQL column label into a localized business label
|
| 806 |
-
(audit P2 #5). Engine columns like ``COUNT(DISTINCT s.CDSCode)`` become
|
| 807 |
-
"Count" / "Количество"; identifier-like columns (``total_revenue``) are
|
| 808 |
-
kept as-is."""
|
| 809 |
-
kind = classify_scalar_label(column)
|
| 810 |
-
if kind == "identifier":
|
| 811 |
-
return column
|
| 812 |
-
return _t(f"scalar_label_{kind}")
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
def _confidence_label(value: float) -> str:
|
| 816 |
-
if value >= 0.8:
|
| 817 |
-
return _t("conf_high")
|
| 818 |
-
if value >= 0.5:
|
| 819 |
-
return _t("conf_med")
|
| 820 |
-
if value > 0.0:
|
| 821 |
-
return _t("conf_low")
|
| 822 |
-
return _t("conf_unknown")
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
def _render_show_working(result: PipelineRunResult) -> None:
|
| 826 |
-
with st.expander(_t("show_working")):
|
| 827 |
-
trace_rows: list[dict[str, Any]] = []
|
| 828 |
-
for entry in result.trace:
|
| 829 |
-
trace_rows.append(
|
| 830 |
-
{
|
| 831 |
-
"node": str(entry.get("node", "?")),
|
| 832 |
-
"model": str(entry.get("model", "—")),
|
| 833 |
-
"tokens_in": entry.get("input_tokens", "—"),
|
| 834 |
-
"tokens_out": entry.get("output_tokens", "—"),
|
| 835 |
-
"confidence": entry.get("confidence", "—"),
|
| 836 |
-
}
|
| 837 |
-
)
|
| 838 |
-
if trace_rows:
|
| 839 |
-
st.markdown(f"**{_t('trace_header')}**")
|
| 840 |
-
st.dataframe(
|
| 841 |
-
pd.DataFrame(trace_rows),
|
| 842 |
-
use_container_width=True,
|
| 843 |
-
hide_index=True,
|
| 844 |
-
)
|
| 845 |
-
|
| 846 |
-
col_a, col_b = st.columns(2)
|
| 847 |
-
with col_a:
|
| 848 |
-
st.markdown(f"**{_t('meta_header')}**")
|
| 849 |
-
conf_label = _confidence_label(result.confidence)
|
| 850 |
-
st.markdown(f"- {_t('confidence_label')}: **{conf_label}** ({result.confidence:.2f})")
|
| 851 |
-
st.markdown(f"- {_t('repair_attempted')}: {result.repair_attempted}")
|
| 852 |
-
st.markdown(f"- {_t('db_field')}: `{result.db_id}`")
|
| 853 |
-
with col_b:
|
| 854 |
-
st.markdown(f"**{_t('shape_header')}**")
|
| 855 |
-
if result.outcome and result.outcome.result:
|
| 856 |
-
st.markdown(f"- {_t('rows_returned')}: {result.outcome.result.row_count}")
|
| 857 |
-
cols = ", ".join(result.outcome.result.columns) or "—"
|
| 858 |
-
st.markdown(f"- {_t('columns_field')}: {cols}")
|
| 859 |
-
else:
|
| 860 |
-
st.markdown(f"- {_t('no_rows')}")
|
| 861 |
-
if result.rationale:
|
| 862 |
-
st.markdown(f"**{_t('rationale_header')}**")
|
| 863 |
-
st.write(result.rationale)
|
| 864 |
-
if result.error_kind:
|
| 865 |
-
st.error(f"{_t('error_kind')}: {result.error_kind} — {result.error_message}")
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
# ------------------------------------------------------------ schema explorer
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
@st.cache_data(show_spinner=False)
|
| 872 |
-
def _fetch_schema_chunks(_index_id: int, db_id: str) -> list[tuple[str, str]]:
|
| 873 |
-
schema_index = st.session_state.get("_schema_index")
|
| 874 |
-
if schema_index is None:
|
| 875 |
-
return []
|
| 876 |
-
records = schema_index.schema_collection.get(
|
| 877 |
-
where={"db_id": db_id},
|
| 878 |
-
include=["documents", "metadatas"],
|
| 879 |
-
)
|
| 880 |
-
docs = records.get("documents") or []
|
| 881 |
-
metas = records.get("metadatas") or []
|
| 882 |
-
pairs: list[tuple[str, str]] = []
|
| 883 |
-
for doc, meta in zip(docs, metas, strict=False):
|
| 884 |
-
table_name = str((meta or {}).get("table_name") or "")
|
| 885 |
-
if table_name:
|
| 886 |
-
pairs.append((table_name, str(doc)))
|
| 887 |
-
pairs.sort(key=lambda p: p[0].lower())
|
| 888 |
-
return pairs
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
def _render_schema_explorer(db_id: str) -> None:
|
| 892 |
-
schema_index = st.session_state.get("_schema_index")
|
| 893 |
-
if schema_index is None:
|
| 894 |
-
return
|
| 895 |
-
chunks = _fetch_schema_chunks(id(schema_index), db_id)
|
| 896 |
-
if not chunks:
|
| 897 |
-
st.caption(_t("schema_explorer_empty"))
|
| 898 |
-
return
|
| 899 |
-
with st.expander(_t("schema_explorer_collapsed", n=len(chunks)), expanded=False):
|
| 900 |
-
st.caption(_t("schema_explorer_caption"))
|
| 901 |
-
for table_name, text in chunks:
|
| 902 |
-
with st.expander(table_name, expanded=False):
|
| 903 |
-
st.code(text, language="text")
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
# ----------------------------------------------------------------- hero
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
def _render_welcome(db_id: str) -> None:
|
| 910 |
-
st.markdown(
|
| 911 |
-
"<div class='nl-display'>NL<span class='arrow'>→</span>SQL</div>",
|
| 912 |
-
unsafe_allow_html=True,
|
| 913 |
-
)
|
| 914 |
-
st.markdown(f"<div class='nl-tagline'>{_t('tagline')}</div>", unsafe_allow_html=True)
|
| 915 |
-
|
| 916 |
-
col_a, col_b = st.columns(2)
|
| 917 |
-
with col_a:
|
| 918 |
-
st.markdown(
|
| 919 |
-
f"""
|
| 920 |
-
<div class='nl-metric'>
|
| 921 |
-
<div class='nl-kicker'>{_t("metric_kicker")}</div>
|
| 922 |
-
<div class='nl-metric-row'>
|
| 923 |
-
<span class='nl-metric-value'>{_t("metric_value")}</span>
|
| 924 |
-
<span class='nl-metric-aside'>{_t("metric_percent")}</span>
|
| 925 |
-
</div>
|
| 926 |
-
<div class='nl-metric-cap'>{_t("metric_caption")}</div>
|
| 927 |
-
</div>
|
| 928 |
-
""",
|
| 929 |
-
unsafe_allow_html=True,
|
| 930 |
-
)
|
| 931 |
-
with col_b:
|
| 932 |
-
st.markdown(
|
| 933 |
-
f"""
|
| 934 |
-
<div class='nl-metric'>
|
| 935 |
-
<div class='nl-kicker'>{_t("research_kicker")}</div>
|
| 936 |
-
<div class='nl-metric-row'>
|
| 937 |
-
<span class='nl-metric-value'>{_t("research_value")}</span>
|
| 938 |
-
</div>
|
| 939 |
-
<div class='nl-metric-cap'>{_t("research_caption")}</div>
|
| 940 |
-
</div>
|
| 941 |
-
""",
|
| 942 |
-
unsafe_allow_html=True,
|
| 943 |
-
)
|
| 944 |
-
|
| 945 |
-
samples = SAMPLE_QUESTIONS.get(db_id)
|
| 946 |
-
if not samples:
|
| 947 |
-
st.markdown(
|
| 948 |
-
f"<div class='nl-section-label'>{_t('ask_intro_label')}</div>",
|
| 949 |
-
unsafe_allow_html=True,
|
| 950 |
-
)
|
| 951 |
-
st.info(_t("no_samples"))
|
| 952 |
-
return
|
| 953 |
-
|
| 954 |
-
st.markdown(
|
| 955 |
-
f"<div class='nl-section-label'>{_t('ask_intro_label')}</div>",
|
| 956 |
-
unsafe_allow_html=True,
|
| 957 |
-
)
|
| 958 |
-
|
| 959 |
-
cols = st.columns(len(samples))
|
| 960 |
-
diff_map = {
|
| 961 |
-
"simple": _t("diff_simple"),
|
| 962 |
-
"moderate": _t("diff_moderate"),
|
| 963 |
-
"challenging": _t("diff_challenging"),
|
| 964 |
-
}
|
| 965 |
-
for col, (difficulty, question) in zip(cols, samples, strict=False):
|
| 966 |
-
with col:
|
| 967 |
-
st.markdown(
|
| 968 |
-
f"<div class='nl-sample-kicker'>{diff_map.get(difficulty, difficulty)}</div>",
|
| 969 |
-
unsafe_allow_html=True,
|
| 970 |
-
)
|
| 971 |
-
if st.button(
|
| 972 |
-
question,
|
| 973 |
-
key=f"sample_{db_id}_{hash(question)}",
|
| 974 |
-
use_container_width=True,
|
| 975 |
-
):
|
| 976 |
-
st.session_state.pending_question = question
|
| 977 |
-
st.rerun()
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
# ---------------------------------------------------------------------- main
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
def _render_lang_toggle() -> None:
|
| 984 |
-
"""Two flat segments: EN / RU. Active one inverts."""
|
| 985 |
-
lang = st.session_state.get("lang", "en")
|
| 986 |
-
st.markdown(f"<div class='nl-side-sub'>{_t('lang_label')}</div>", unsafe_allow_html=True)
|
| 987 |
-
cols = st.columns(2)
|
| 988 |
-
with cols[0]:
|
| 989 |
-
if st.button(
|
| 990 |
-
_t("lang_en"),
|
| 991 |
-
key="lang_en_btn",
|
| 992 |
-
use_container_width=True,
|
| 993 |
-
type="primary" if lang == "en" else "secondary",
|
| 994 |
-
):
|
| 995 |
-
st.session_state.lang = "en"
|
| 996 |
-
st.rerun()
|
| 997 |
-
with cols[1]:
|
| 998 |
-
if st.button(
|
| 999 |
-
_t("lang_ru"),
|
| 1000 |
-
key="lang_ru_btn",
|
| 1001 |
-
use_container_width=True,
|
| 1002 |
-
type="primary" if lang == "ru" else "secondary",
|
| 1003 |
-
):
|
| 1004 |
-
st.session_state.lang = "ru"
|
| 1005 |
-
st.rerun()
|
| 1006 |
|
| 1007 |
|
| 1008 |
def main() -> None:
|
|
@@ -1010,14 +32,14 @@ def main() -> None:
|
|
| 1010 |
st.session_state.lang = "en"
|
| 1011 |
|
| 1012 |
st.set_page_config(
|
| 1013 |
-
page_title=
|
| 1014 |
layout="wide",
|
| 1015 |
)
|
| 1016 |
|
| 1017 |
-
|
| 1018 |
|
| 1019 |
try:
|
| 1020 |
-
registry, schema_index, sql_provider, explain_provider =
|
| 1021 |
except RuntimeError as exc:
|
| 1022 |
st.error(str(exc))
|
| 1023 |
st.stop()
|
|
@@ -1026,10 +48,10 @@ def main() -> None:
|
|
| 1026 |
# --- sidebar
|
| 1027 |
with st.sidebar:
|
| 1028 |
st.markdown("<div class='nl-side-h'>NL→SQL</div>", unsafe_allow_html=True)
|
| 1029 |
-
|
| 1030 |
|
| 1031 |
st.markdown(
|
| 1032 |
-
f"<div class='nl-side-sub'>{
|
| 1033 |
unsafe_allow_html=True,
|
| 1034 |
)
|
| 1035 |
db_ids = registry.ids()
|
|
@@ -1039,52 +61,49 @@ def main() -> None:
|
|
| 1039 |
default_idx = (
|
| 1040 |
db_ids.index("bird_california_schools") if "bird_california_schools" in db_ids else 0
|
| 1041 |
)
|
| 1042 |
-
db_id = st.selectbox(
|
| 1043 |
-
_t("db_label"), db_ids, index=default_idx, label_visibility="collapsed"
|
| 1044 |
-
)
|
| 1045 |
spec = registry.get(db_id)
|
| 1046 |
-
st.caption(f"{
|
| 1047 |
if spec.description:
|
| 1048 |
st.caption(spec.description)
|
| 1049 |
-
|
| 1050 |
-
link = _source_link_for(db_id)
|
| 1051 |
if link is not None:
|
| 1052 |
label, url = link
|
| 1053 |
-
st.caption(f"{
|
| 1054 |
|
| 1055 |
-
|
| 1056 |
|
| 1057 |
st.markdown(
|
| 1058 |
-
f"<div class='nl-side-sub'>{
|
| 1059 |
unsafe_allow_html=True,
|
| 1060 |
)
|
| 1061 |
mode = st.radio(
|
| 1062 |
-
|
| 1063 |
-
options=(
|
| 1064 |
index=0,
|
| 1065 |
captions=(
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
),
|
| 1070 |
label_visibility="collapsed",
|
| 1071 |
)
|
| 1072 |
-
if mode ==
|
| 1073 |
fewshot_top_k = 0
|
| 1074 |
verify_retry_on_empty = False
|
| 1075 |
else:
|
| 1076 |
fewshot_top_k = 3
|
| 1077 |
verify_retry_on_empty = True
|
| 1078 |
|
| 1079 |
-
with st.expander(
|
| 1080 |
-
schema_top_k = st.slider(
|
| 1081 |
-
fk_hops = st.slider(
|
| 1082 |
-
table_budget = st.slider(
|
| 1083 |
-
sort_schema_block = st.checkbox(
|
| 1084 |
-
extended_sample_size = st.slider(
|
| 1085 |
|
| 1086 |
st.markdown("<div style='height:1.4rem'></div>", unsafe_allow_html=True)
|
| 1087 |
-
if st.button(
|
| 1088 |
st.session_state.messages = []
|
| 1089 |
st.rerun()
|
| 1090 |
|
|
@@ -1092,7 +111,7 @@ def main() -> None:
|
|
| 1092 |
st.session_state.messages = []
|
| 1093 |
|
| 1094 |
if not st.session_state.messages:
|
| 1095 |
-
|
| 1096 |
|
| 1097 |
for msg in st.session_state.messages:
|
| 1098 |
with st.chat_message(msg["role"]):
|
|
@@ -1101,7 +120,7 @@ def main() -> None:
|
|
| 1101 |
else:
|
| 1102 |
_replay_assistant_turn(msg)
|
| 1103 |
|
| 1104 |
-
typed = st.chat_input(
|
| 1105 |
queued = st.session_state.pop("pending_question", None)
|
| 1106 |
question = queued or typed
|
| 1107 |
if not question:
|
|
@@ -1111,7 +130,7 @@ def main() -> None:
|
|
| 1111 |
with st.chat_message("user"):
|
| 1112 |
st.markdown(question)
|
| 1113 |
|
| 1114 |
-
pipeline =
|
| 1115 |
registry,
|
| 1116 |
schema_index,
|
| 1117 |
sql_provider,
|
|
@@ -1126,7 +145,7 @@ def main() -> None:
|
|
| 1126 |
)
|
| 1127 |
|
| 1128 |
with st.chat_message("assistant"):
|
| 1129 |
-
with st.spinner(
|
| 1130 |
t0 = time.perf_counter()
|
| 1131 |
try:
|
| 1132 |
result = run_pipeline(
|
|
@@ -1138,24 +157,24 @@ def main() -> None:
|
|
| 1138 |
verify_retry_on_empty=verify_retry_on_empty,
|
| 1139 |
)
|
| 1140 |
except Exception as exc:
|
| 1141 |
-
st.error(
|
| 1142 |
st.session_state.messages.append(
|
| 1143 |
{"role": "assistant", "error": str(exc), "question": question}
|
| 1144 |
)
|
| 1145 |
return
|
| 1146 |
wall_ms = (time.perf_counter() - t0) * 1000
|
| 1147 |
|
| 1148 |
-
|
| 1149 |
|
| 1150 |
if result.sql:
|
| 1151 |
-
st.markdown(f"**{
|
| 1152 |
st.code(result.sql, language="sql")
|
| 1153 |
else:
|
| 1154 |
-
st.warning(
|
| 1155 |
|
| 1156 |
-
st.caption(
|
| 1157 |
|
| 1158 |
-
|
| 1159 |
|
| 1160 |
st.session_state.messages.append(
|
| 1161 |
{
|
|
@@ -1170,14 +189,14 @@ def main() -> None:
|
|
| 1170 |
|
| 1171 |
def _replay_assistant_turn(msg: dict[str, Any]) -> None:
|
| 1172 |
if msg.get("error"):
|
| 1173 |
-
st.error(
|
| 1174 |
return
|
| 1175 |
result = cast(PipelineRunResult, msg["result"])
|
| 1176 |
-
|
| 1177 |
if result.sql:
|
| 1178 |
st.code(result.sql, language="sql")
|
| 1179 |
-
st.caption(
|
| 1180 |
-
|
| 1181 |
|
| 1182 |
|
| 1183 |
if __name__ == "__main__":
|
|
|
|
| 9 |
uv run streamlit run app/streamlit_app.py
|
| 10 |
"""
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import time
|
|
|
|
| 15 |
from typing import Any, cast
|
| 16 |
|
|
|
|
|
|
|
|
|
|
| 17 |
import streamlit as st
|
| 18 |
|
| 19 |
+
from bootstrap import bootstrap, make_pipeline
|
| 20 |
+
from components.output import render_output
|
| 21 |
+
from components.schema_explorer import render_schema_explorer
|
| 22 |
+
from components.show_working import render_show_working
|
| 23 |
+
from components.welcome import render_lang_toggle, render_welcome
|
| 24 |
+
from i18n import t
|
| 25 |
+
from nl_sql.agent.graph import PipelineRunResult, run_pipeline
|
| 26 |
+
from samples import source_link_for
|
| 27 |
+
from theme import inject_chrome
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def main() -> None:
|
|
|
|
| 32 |
st.session_state.lang = "en"
|
| 33 |
|
| 34 |
st.set_page_config(
|
| 35 |
+
page_title=t("page_title"),
|
| 36 |
layout="wide",
|
| 37 |
)
|
| 38 |
|
| 39 |
+
inject_chrome()
|
| 40 |
|
| 41 |
try:
|
| 42 |
+
registry, schema_index, sql_provider, explain_provider = bootstrap()
|
| 43 |
except RuntimeError as exc:
|
| 44 |
st.error(str(exc))
|
| 45 |
st.stop()
|
|
|
|
| 48 |
# --- sidebar
|
| 49 |
with st.sidebar:
|
| 50 |
st.markdown("<div class='nl-side-h'>NL→SQL</div>", unsafe_allow_html=True)
|
| 51 |
+
render_lang_toggle()
|
| 52 |
|
| 53 |
st.markdown(
|
| 54 |
+
f"<div class='nl-side-sub'>{t('db_label')}</div>",
|
| 55 |
unsafe_allow_html=True,
|
| 56 |
)
|
| 57 |
db_ids = registry.ids()
|
|
|
|
| 61 |
default_idx = (
|
| 62 |
db_ids.index("bird_california_schools") if "bird_california_schools" in db_ids else 0
|
| 63 |
)
|
| 64 |
+
db_id = st.selectbox(t("db_label"), db_ids, index=default_idx, label_visibility="collapsed")
|
|
|
|
|
|
|
| 65 |
spec = registry.get(db_id)
|
| 66 |
+
st.caption(f"{t('db_dialect')}: `{spec.dialect}`")
|
| 67 |
if spec.description:
|
| 68 |
st.caption(spec.description)
|
| 69 |
+
link = source_link_for(db_id)
|
|
|
|
| 70 |
if link is not None:
|
| 71 |
label, url = link
|
| 72 |
+
st.caption(f"{t('db_source')}: [{label}]({url})")
|
| 73 |
|
| 74 |
+
render_schema_explorer(db_id)
|
| 75 |
|
| 76 |
st.markdown(
|
| 77 |
+
f"<div class='nl-side-sub'>{t('mode_header')}</div>",
|
| 78 |
unsafe_allow_html=True,
|
| 79 |
)
|
| 80 |
mode = st.radio(
|
| 81 |
+
t("mode_header"),
|
| 82 |
+
options=(t("mode_accurate"), t("mode_fast"), t("mode_debug")),
|
| 83 |
index=0,
|
| 84 |
captions=(
|
| 85 |
+
t("mode_accurate_caption"),
|
| 86 |
+
t("mode_fast_caption"),
|
| 87 |
+
t("mode_debug_caption"),
|
| 88 |
),
|
| 89 |
label_visibility="collapsed",
|
| 90 |
)
|
| 91 |
+
if mode == t("mode_fast"):
|
| 92 |
fewshot_top_k = 0
|
| 93 |
verify_retry_on_empty = False
|
| 94 |
else:
|
| 95 |
fewshot_top_k = 3
|
| 96 |
verify_retry_on_empty = True
|
| 97 |
|
| 98 |
+
with st.expander(t("advanced_header"), expanded=False):
|
| 99 |
+
schema_top_k = st.slider(t("schema_top_k"), 1, 10, 5)
|
| 100 |
+
fk_hops = st.slider(t("fk_hops"), 0, 2, 1)
|
| 101 |
+
table_budget = st.slider(t("table_budget"), 4, 20, 12)
|
| 102 |
+
sort_schema_block = st.checkbox(t("sort_schema"), value=True)
|
| 103 |
+
extended_sample_size = st.slider(t("sample_size"), 0, 8, 0)
|
| 104 |
|
| 105 |
st.markdown("<div style='height:1.4rem'></div>", unsafe_allow_html=True)
|
| 106 |
+
if st.button(t("clear_chat"), use_container_width=True):
|
| 107 |
st.session_state.messages = []
|
| 108 |
st.rerun()
|
| 109 |
|
|
|
|
| 111 |
st.session_state.messages = []
|
| 112 |
|
| 113 |
if not st.session_state.messages:
|
| 114 |
+
render_welcome(db_id)
|
| 115 |
|
| 116 |
for msg in st.session_state.messages:
|
| 117 |
with st.chat_message(msg["role"]):
|
|
|
|
| 120 |
else:
|
| 121 |
_replay_assistant_turn(msg)
|
| 122 |
|
| 123 |
+
typed = st.chat_input(t("ask_placeholder"))
|
| 124 |
queued = st.session_state.pop("pending_question", None)
|
| 125 |
question = queued or typed
|
| 126 |
if not question:
|
|
|
|
| 130 |
with st.chat_message("user"):
|
| 131 |
st.markdown(question)
|
| 132 |
|
| 133 |
+
pipeline = make_pipeline(
|
| 134 |
registry,
|
| 135 |
schema_index,
|
| 136 |
sql_provider,
|
|
|
|
| 145 |
)
|
| 146 |
|
| 147 |
with st.chat_message("assistant"):
|
| 148 |
+
with st.spinner(t("spinner_generating")):
|
| 149 |
t0 = time.perf_counter()
|
| 150 |
try:
|
| 151 |
result = run_pipeline(
|
|
|
|
| 157 |
verify_retry_on_empty=verify_retry_on_empty,
|
| 158 |
)
|
| 159 |
except Exception as exc:
|
| 160 |
+
st.error(t("pipeline_crashed", kind=type(exc).__name__, msg=str(exc)))
|
| 161 |
st.session_state.messages.append(
|
| 162 |
{"role": "assistant", "error": str(exc), "question": question}
|
| 163 |
)
|
| 164 |
return
|
| 165 |
wall_ms = (time.perf_counter() - t0) * 1000
|
| 166 |
|
| 167 |
+
render_output(result.output_format, caption=result.caption)
|
| 168 |
|
| 169 |
if result.sql:
|
| 170 |
+
st.markdown(f"**{t('sql_label')}**")
|
| 171 |
st.code(result.sql, language="sql")
|
| 172 |
else:
|
| 173 |
+
st.warning(t("no_sql"))
|
| 174 |
|
| 175 |
+
st.caption(t("wall_model", wall=wall_ms, model=sql_provider.model))
|
| 176 |
|
| 177 |
+
render_show_working(result)
|
| 178 |
|
| 179 |
st.session_state.messages.append(
|
| 180 |
{
|
|
|
|
| 189 |
|
| 190 |
def _replay_assistant_turn(msg: dict[str, Any]) -> None:
|
| 191 |
if msg.get("error"):
|
| 192 |
+
st.error(t("pipeline_crashed", kind="prior", msg=msg["error"]))
|
| 193 |
return
|
| 194 |
result = cast(PipelineRunResult, msg["result"])
|
| 195 |
+
render_output(result.output_format, caption=result.caption)
|
| 196 |
if result.sql:
|
| 197 |
st.code(result.sql, language="sql")
|
| 198 |
+
st.caption(t("wall_model", wall=msg.get("wall_ms", 0), model=msg.get("model", "?")))
|
| 199 |
+
render_show_working(result)
|
| 200 |
|
| 201 |
|
| 202 |
if __name__ == "__main__":
|
app/theme.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Editorial monochrome theme: typography, chrome CSS, chart styling.
|
| 2 |
+
|
| 3 |
+
Two custom faces (Stetica sans for chrome, TT Norms Pro Serif for display).
|
| 4 |
+
Ink-on-paper palette. One accent — ink-fill on hover. No color drama in charts.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import streamlit as st
|
| 12 |
+
|
| 13 |
+
FONT_CSS = """
|
| 14 |
+
<style>
|
| 15 |
+
@font-face {
|
| 16 |
+
font-family: 'Stetica';
|
| 17 |
+
src: url('/app/static/fonts/stetica-regular.otf') format('opentype');
|
| 18 |
+
font-weight: 400;
|
| 19 |
+
font-style: normal;
|
| 20 |
+
font-display: swap;
|
| 21 |
+
}
|
| 22 |
+
@font-face {
|
| 23 |
+
font-family: 'Stetica';
|
| 24 |
+
src: url('/app/static/fonts/stetica-medium.otf') format('opentype');
|
| 25 |
+
font-weight: 500;
|
| 26 |
+
font-style: normal;
|
| 27 |
+
font-display: swap;
|
| 28 |
+
}
|
| 29 |
+
@font-face {
|
| 30 |
+
font-family: 'Stetica';
|
| 31 |
+
src: url('/app/static/fonts/stetica-bold.otf') format('opentype');
|
| 32 |
+
font-weight: 700;
|
| 33 |
+
font-style: normal;
|
| 34 |
+
font-display: swap;
|
| 35 |
+
}
|
| 36 |
+
@font-face {
|
| 37 |
+
font-family: 'NLEdSerif';
|
| 38 |
+
src: url('/app/static/fonts/serif-regular.otf') format('opentype');
|
| 39 |
+
font-weight: 400;
|
| 40 |
+
font-style: normal;
|
| 41 |
+
font-display: swap;
|
| 42 |
+
}
|
| 43 |
+
@font-face {
|
| 44 |
+
font-family: 'NLEdSerif';
|
| 45 |
+
src: url('/app/static/fonts/serif-bold.otf') format('opentype');
|
| 46 |
+
font-weight: 700;
|
| 47 |
+
font-style: normal;
|
| 48 |
+
font-display: swap;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
:root {
|
| 52 |
+
--ink: #111111;
|
| 53 |
+
--ink-soft: #4A4A4A;
|
| 54 |
+
--ink-mute: #7A7A75;
|
| 55 |
+
--paper: #FAFAF7;
|
| 56 |
+
--paper-warm: #F1EFE9;
|
| 57 |
+
--rule: #1A1A1A;
|
| 58 |
+
--hairline: #DCD8CE;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
html, body, [class*="css"], .stApp, .stMarkdown, .stChatMessage {
|
| 62 |
+
font-family: 'Stetica', system-ui, sans-serif !important;
|
| 63 |
+
color: var(--ink);
|
| 64 |
+
background: var(--paper);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.block-container {
|
| 68 |
+
padding-top: 2.4rem;
|
| 69 |
+
padding-bottom: 4rem;
|
| 70 |
+
max-width: 1080px;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* Hide Streamlit chrome we don't want */
|
| 74 |
+
#MainMenu, footer, header [data-testid="stToolbar"] { visibility: hidden; }
|
| 75 |
+
header { background: var(--paper) !important; }
|
| 76 |
+
|
| 77 |
+
/* Display headline — serif */
|
| 78 |
+
.nl-display {
|
| 79 |
+
font-family: 'NLEdSerif', Georgia, serif;
|
| 80 |
+
font-weight: 400;
|
| 81 |
+
font-size: clamp(2.6rem, 5vw, 3.6rem);
|
| 82 |
+
letter-spacing: -0.02em;
|
| 83 |
+
line-height: 0.95;
|
| 84 |
+
color: var(--ink);
|
| 85 |
+
margin: 0 0 0.4rem 0;
|
| 86 |
+
}
|
| 87 |
+
.nl-display .arrow {
|
| 88 |
+
font-weight: 700;
|
| 89 |
+
display: inline-block;
|
| 90 |
+
transform: translateY(-0.04em);
|
| 91 |
+
margin: 0 0.25rem;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.nl-tagline {
|
| 95 |
+
font-family: 'Stetica', system-ui, sans-serif;
|
| 96 |
+
font-weight: 400;
|
| 97 |
+
font-size: 1.02rem;
|
| 98 |
+
line-height: 1.5;
|
| 99 |
+
color: var(--ink-soft);
|
| 100 |
+
max-width: 56ch;
|
| 101 |
+
margin: 0 0 2rem 0;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* Kicker — small uppercase letter-spaced label */
|
| 105 |
+
.nl-kicker {
|
| 106 |
+
font-family: 'Stetica', sans-serif;
|
| 107 |
+
font-size: 0.68rem;
|
| 108 |
+
letter-spacing: 0.18em;
|
| 109 |
+
text-transform: uppercase;
|
| 110 |
+
color: var(--ink-mute);
|
| 111 |
+
margin-bottom: 0.5rem;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* Metric block — pure typography, no card chrome */
|
| 115 |
+
.nl-metric {
|
| 116 |
+
border-top: 1px solid var(--rule);
|
| 117 |
+
padding-top: 0.8rem;
|
| 118 |
+
margin-top: 1.4rem;
|
| 119 |
+
}
|
| 120 |
+
.nl-metric-row {
|
| 121 |
+
display: flex;
|
| 122 |
+
align-items: baseline;
|
| 123 |
+
gap: 0.9rem;
|
| 124 |
+
margin-bottom: 0.5rem;
|
| 125 |
+
}
|
| 126 |
+
.nl-metric-value {
|
| 127 |
+
font-family: 'NLEdSerif', Georgia, serif;
|
| 128 |
+
font-weight: 700;
|
| 129 |
+
font-size: 2.2rem;
|
| 130 |
+
letter-spacing: -0.01em;
|
| 131 |
+
color: var(--ink);
|
| 132 |
+
line-height: 1;
|
| 133 |
+
}
|
| 134 |
+
.nl-metric-aside {
|
| 135 |
+
font-family: 'Stetica', sans-serif;
|
| 136 |
+
font-size: 0.86rem;
|
| 137 |
+
color: var(--ink-mute);
|
| 138 |
+
letter-spacing: 0.04em;
|
| 139 |
+
}
|
| 140 |
+
.nl-metric-cap {
|
| 141 |
+
font-family: 'Stetica', sans-serif;
|
| 142 |
+
font-size: 0.86rem;
|
| 143 |
+
color: var(--ink-soft);
|
| 144 |
+
line-height: 1.55;
|
| 145 |
+
max-width: 62ch;
|
| 146 |
+
}
|
| 147 |
+
.nl-term {
|
| 148 |
+
border-bottom: 1px dotted var(--ink-mute);
|
| 149 |
+
cursor: help;
|
| 150 |
+
text-decoration: none;
|
| 151 |
+
color: inherit;
|
| 152 |
+
}
|
| 153 |
+
.nl-term:hover {
|
| 154 |
+
border-bottom-color: var(--ink);
|
| 155 |
+
color: var(--ink);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* Section rule */
|
| 159 |
+
.nl-section-label {
|
| 160 |
+
font-family: 'Stetica', sans-serif;
|
| 161 |
+
font-size: 0.68rem;
|
| 162 |
+
letter-spacing: 0.18em;
|
| 163 |
+
text-transform: uppercase;
|
| 164 |
+
color: var(--ink-mute);
|
| 165 |
+
margin: 2.4rem 0 0.7rem 0;
|
| 166 |
+
border-top: 1px solid var(--hairline);
|
| 167 |
+
padding-top: 0.7rem;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* Sidebar polish */
|
| 171 |
+
[data-testid="stSidebar"] {
|
| 172 |
+
background: var(--paper-warm) !important;
|
| 173 |
+
border-right: 1px solid var(--hairline);
|
| 174 |
+
}
|
| 175 |
+
[data-testid="stSidebar"] .nl-side-h {
|
| 176 |
+
font-family: 'NLEdSerif', Georgia, serif;
|
| 177 |
+
font-weight: 700;
|
| 178 |
+
font-size: 1.1rem;
|
| 179 |
+
letter-spacing: -0.005em;
|
| 180 |
+
margin: 0.4rem 0 0.6rem 0;
|
| 181 |
+
}
|
| 182 |
+
[data-testid="stSidebar"] .nl-side-sub {
|
| 183 |
+
font-family: 'Stetica', sans-serif;
|
| 184 |
+
font-size: 0.7rem;
|
| 185 |
+
letter-spacing: 0.18em;
|
| 186 |
+
text-transform: uppercase;
|
| 187 |
+
color: var(--ink-mute);
|
| 188 |
+
margin: 1.2rem 0 0.4rem 0;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* Language toggle */
|
| 192 |
+
.nl-lang-row { display: flex; gap: 0; }
|
| 193 |
+
.nl-lang-row button {
|
| 194 |
+
background: transparent !important;
|
| 195 |
+
color: var(--ink) !important;
|
| 196 |
+
border: 1px solid var(--rule) !important;
|
| 197 |
+
border-radius: 0 !important;
|
| 198 |
+
font-family: 'Stetica', sans-serif !important;
|
| 199 |
+
font-weight: 500 !important;
|
| 200 |
+
letter-spacing: 0.12em !important;
|
| 201 |
+
text-transform: uppercase;
|
| 202 |
+
padding: 0.35rem 0.9rem !important;
|
| 203 |
+
font-size: 0.74rem !important;
|
| 204 |
+
min-height: 0 !important;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/* Buttons (sample questions) */
|
| 208 |
+
.stButton > button {
|
| 209 |
+
background: transparent !important;
|
| 210 |
+
color: var(--ink) !important;
|
| 211 |
+
border: 1px solid var(--rule) !important;
|
| 212 |
+
border-radius: 0 !important;
|
| 213 |
+
font-family: 'Stetica', sans-serif !important;
|
| 214 |
+
font-weight: 400 !important;
|
| 215 |
+
font-size: 0.92rem !important;
|
| 216 |
+
text-align: left !important;
|
| 217 |
+
padding: 0.85rem 1rem !important;
|
| 218 |
+
line-height: 1.45 !important;
|
| 219 |
+
transition: background 0.12s;
|
| 220 |
+
white-space: normal !important;
|
| 221 |
+
height: auto !important;
|
| 222 |
+
}
|
| 223 |
+
.stButton > button:hover {
|
| 224 |
+
background: var(--ink) !important;
|
| 225 |
+
color: var(--paper) !important;
|
| 226 |
+
}
|
| 227 |
+
.stButton > button p {
|
| 228 |
+
color: inherit !important;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/* Chat input */
|
| 232 |
+
.stChatInput { border-top: 1px solid var(--rule) !important; }
|
| 233 |
+
.stChatInput textarea {
|
| 234 |
+
font-family: 'Stetica', sans-serif !important;
|
| 235 |
+
font-size: 1rem !important;
|
| 236 |
+
color: var(--ink) !important;
|
| 237 |
+
background: var(--paper) !important;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/* Code blocks — keep mono but on warm paper */
|
| 241 |
+
pre, code {
|
| 242 |
+
background: var(--paper-warm) !important;
|
| 243 |
+
color: var(--ink) !important;
|
| 244 |
+
border: 1px solid var(--hairline) !important;
|
| 245 |
+
border-radius: 0 !important;
|
| 246 |
+
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace !important;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/* Scalar metric block — flatten */
|
| 250 |
+
[data-testid="stMetric"] {
|
| 251 |
+
background: transparent !important;
|
| 252 |
+
border: none !important;
|
| 253 |
+
}
|
| 254 |
+
[data-testid="stMetricLabel"] {
|
| 255 |
+
font-family: 'Stetica', sans-serif !important;
|
| 256 |
+
font-size: 0.68rem !important;
|
| 257 |
+
letter-spacing: 0.18em !important;
|
| 258 |
+
text-transform: uppercase !important;
|
| 259 |
+
color: var(--ink-mute) !important;
|
| 260 |
+
}
|
| 261 |
+
[data-testid="stMetricValue"] {
|
| 262 |
+
font-family: 'NLEdSerif', Georgia, serif !important;
|
| 263 |
+
font-weight: 700 !important;
|
| 264 |
+
font-size: 2.4rem !important;
|
| 265 |
+
color: var(--ink) !important;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* Tables */
|
| 269 |
+
[data-testid="stDataFrame"] { border: 1px solid var(--rule); }
|
| 270 |
+
|
| 271 |
+
/* Expanders */
|
| 272 |
+
.streamlit-expanderHeader {
|
| 273 |
+
font-family: 'Stetica', sans-serif !important;
|
| 274 |
+
font-size: 0.78rem !important;
|
| 275 |
+
letter-spacing: 0.1em;
|
| 276 |
+
text-transform: uppercase;
|
| 277 |
+
color: var(--ink) !important;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/* Sample card — wraps a button + difficulty kicker */
|
| 281 |
+
.nl-sample {
|
| 282 |
+
display: block;
|
| 283 |
+
}
|
| 284 |
+
.nl-sample-kicker {
|
| 285 |
+
font-family: 'Stetica', sans-serif;
|
| 286 |
+
font-size: 0.62rem;
|
| 287 |
+
letter-spacing: 0.22em;
|
| 288 |
+
text-transform: uppercase;
|
| 289 |
+
color: var(--ink-mute);
|
| 290 |
+
margin: 0 0 0.4rem 0.05rem;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
/* Chat message bubbles — strip default round chrome */
|
| 294 |
+
[data-testid="stChatMessage"] {
|
| 295 |
+
background: transparent !important;
|
| 296 |
+
border: 0 !important;
|
| 297 |
+
padding: 0.4rem 0 1.4rem 0 !important;
|
| 298 |
+
}
|
| 299 |
+
[data-testid="stChatMessage"]:not(:first-child) {
|
| 300 |
+
border-top: 1px solid var(--hairline) !important;
|
| 301 |
+
padding-top: 1.4rem !important;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/* Remove the avatar/icon circle Streamlit injects — covers every variant */
|
| 305 |
+
[data-testid="stChatMessage"] > div:first-child,
|
| 306 |
+
[data-testid="chatAvatarIcon-user"],
|
| 307 |
+
[data-testid="chatAvatarIcon-assistant"],
|
| 308 |
+
[data-testid="stChatMessageAvatarUser"],
|
| 309 |
+
[data-testid="stChatMessageAvatarAssistant"],
|
| 310 |
+
[data-testid="stChatMessage"] [class*="Avatar"],
|
| 311 |
+
[data-testid="stChatMessage"] svg {
|
| 312 |
+
display: none !important;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/* The chat message body lives in second child after the avatar; pull it left */
|
| 316 |
+
[data-testid="stChatMessage"] > div:nth-child(2) {
|
| 317 |
+
margin-left: 0 !important;
|
| 318 |
+
padding-left: 0 !important;
|
| 319 |
+
width: 100% !important;
|
| 320 |
+
}
|
| 321 |
+
</style>
|
| 322 |
+
"""
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
CHART_PALETTE = ["#111111", "#4A4A4A", "#7A7A75", "#A8A29E", "#1A1A1A"]
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def inject_chrome() -> None:
|
| 329 |
+
st.markdown(FONT_CSS, unsafe_allow_html=True)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def style_fig(fig: Any) -> Any:
|
| 333 |
+
fig.update_layout(
|
| 334 |
+
font_family="Stetica, system-ui, sans-serif",
|
| 335 |
+
font_color="#111111",
|
| 336 |
+
paper_bgcolor="#FAFAF7",
|
| 337 |
+
plot_bgcolor="#FAFAF7",
|
| 338 |
+
colorway=CHART_PALETTE,
|
| 339 |
+
margin=dict(l=10, r=10, t=20, b=10),
|
| 340 |
+
)
|
| 341 |
+
fig.update_xaxes(gridcolor="#DCD8CE", zerolinecolor="#1A1A1A", tickcolor="#1A1A1A")
|
| 342 |
+
fig.update_yaxes(gridcolor="#DCD8CE", zerolinecolor="#1A1A1A", tickcolor="#1A1A1A")
|
| 343 |
+
return fig
|
chroma_data/chroma.sqlite3
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 18161664
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:90181e0a2514fbaf80a6de4c6307593dba4e93e9e6da6bd70600553a2651667b
|
| 3 |
size 18161664
|
chroma_data/fc9668d3-4384-40d9-aa8d-0010807a5a68/data_level0.bin
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 423600
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:de3c405eda8dc30523c29aa0475a570ba0de2526b2ca29b21f1bf0f3ea94d6e0
|
| 3 |
size 423600
|
chroma_data/fc9668d3-4384-40d9-aa8d-0010807a5a68/length.bin
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 400
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:39f574a82964186c006c0c4d50f49095c2abb6b4614ca4e36f953667e96281da
|
| 3 |
size 400
|
docs/NEXT_SESSION.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
| 3 |
> Один лист, без воды. Берёшь, делаешь, обновляешь `SESSION_HANDOFF.md`,
|
| 4 |
> переписываешь этот файл под следующий sprint.
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
## 2026-05-26 — **v31 = 94.0% EA** verified (+1.04pp над human-expert baseline)
|
| 7 |
|
| 8 |
**Headline:** 93.5% (v30) → **94.0% / 200 (v31)** через targeted P3.F schema-link hint для qid 37 на v30 residue. **Выше human-expert baseline 92.96% (BIRD paper) на +1.04pp.** Per-tier v31: simple **97.0%** (65/67), moderate **92.9%** (92/99, +1.0pp от v30 91.9%), challenging **91.2%** (31/34).
|
|
@@ -194,9 +215,9 @@ fetch). Local heterogeneous CSC lever остаётся parked.
|
|
| 194 |
|
| 195 |
| # | Severity | Scope | Estimate |
|
| 196 |
|---|---|---|---|
|
| 197 |
-
| Kimi P1.3 |
|
| 198 |
| ~~Kimi P1.4~~ | **Done 2026-05-26** | `src/nl_sql/agent/nodes/_support.py` 483 lines → `_support.py` (public API, 184 lines) + `_text_utils.py` (JSON parsing, 53 lines) + `_hints.py` (schema appendices, 302 lines). Zero behavior change, 355 pytest pass, ruff + mypy strict clean. | 1h |
|
| 199 |
-
| Kimi P1.6 |
|
| 200 |
| Codex #7 | P2 latent | `scripts/rescore_arcwise.py:82` transition buckets используют stale `rec["match"]` вместо recomputed `out_entry["original_match"]` (line 141 overwrite). **Reachability verified 2026-05-26: 0/200 stale-vs-fresh disagreements в `eval/reports/2026-05-24/v29-arcwise-rescored.json`** — bug latent, transitions counts (7 gained / 91 lost) honest. Fix = 1-line swap, no observable change в output. | 30min, deferred |
|
| 201 |
| Codex #8 | P2 latent | `execution_accuracy.py:209-221` `_hashable` bucketing через `round(v / 1e-6)` может развести два tolerance-equivalent rows (diff ~9e-7, banker's rounding edge) в разные buckets → set-mode false negative. **Reachability verified 2026-05-26: 0 set-mismatch records в v22-v30 baselines (200 records each); 8 set-mismatch в demo runs 2026-05-11, все honest column-count diff не float-bucket.** Fix = replace `_hashable` с pair-wise tolerance match (O(n²)). | 1h, deferred |
|
| 202 |
| ~~Codex #9~~ | **false positive 2026-05-26** | `cache.py:77` cache key omits `req.json_mode`. **Не достижимо в текущем коде:** `src/nl_sql/llm/providers/groq.py:44` force-set'ит `json_mode=True` через `req.model_copy` на каждом Groq call; Mistral codestral игнорирует поле (`base.py:21` docstring). Per (provider, model) пара `json_mode` имеет константное значение → collision impossible. Не трогать (попытка fix landed 2026-05-26, reverted после Codex+Kimi independent review). | closed |
|
|
|
|
| 3 |
> Один лист, без воды. Берёшь, делаешь, обновляешь `SESSION_HANDOFF.md`,
|
| 4 |
> переписываешь этот файл под следующий sprint.
|
| 5 |
|
| 6 |
+
## 2026-05-26 EOD-7 — autonomous housekeeping sprint (HEAD `4207df0`, pushed)
|
| 7 |
+
|
| 8 |
+
**Cleared today (one-shot autonomous run):**
|
| 9 |
+
- **Push backlog cleared.** 8 локальных commits на origin/main (`03ad6ae..a47a7fe` was +8 ahead; теперь поверх ещё `a7c1d81` + `4207df0`). Origin синхронен.
|
| 10 |
+
- **HF Space redeployed на v31 94.0%.** `.deploy_hf.py` upload + auto-LFS, RUNNING ~90s. Playwright E2E: EN 94.0% визибл, нет stale 92.5%. `short_description` 92.5% → 94.0%. Screenshot: `docs/ui-live-v31.png`.
|
| 11 |
+
- **Kimi P1.3 closed** (`a7c1d81`): `app/streamlit_app.py` 1184 → 200 lines через split на 8 модулей (i18n.py, theme.py, samples.py, bootstrap.py + components/ пакет). `pyproject.toml` `[tool.ruff].src` расширен `["src","tests","app"]`. Local Streamlit + Playwright E2E подтвердил zero behavior change: EN 94.0%, RU 94,0%, schema explorer render OK.
|
| 12 |
+
- **Kimi P1.6 closed** (`4207df0`): API coverage **58% → 89%**. Extracted `Singletons` NamedTuple + `get_singletons()` Depends-factory. Все 3 pipeline-touching routes (`/readyz`, `/databases`, `/ask`) теперь принимают `Singletons = Depends(get_singletons)`. Production callers через `@lru_cache(maxsize=1)` на `_make_singletons` (zero behavior change). New `tests/api/test_api_routes_mocked.py` — 13 tests покрывают healthy/empty-chroma/empty-registry/factory-raises /readyz пути, auth + schema-collection-exception /databases пути, unknown-db / canned-result / error-kind / confidence-buckets /ask пути, + rate-limit 60→61st 429.
|
| 13 |
+
- **Gates:** 370 pytest pass (357+13), ruff check + format clean, mypy strict 0/59, P3.F acceptance 11/11 PASS, audit_rescore 0 mismatches.
|
| 14 |
+
|
| 15 |
+
**Open backlog после EOD-7:**
|
| 16 |
+
|
| 17 |
+
| # | Severity | Scope | Estimate |
|
| 18 |
+
|---|---|---|---|
|
| 19 |
+
| Codex #7 | P2 latent | `scripts/rescore_arcwise.py:82` stale `rec["match"]` — verified 0/200 disagreements on v29, transitions output unchanged if fixed | 30min, deferred |
|
| 20 |
+
| Codex #8 | P2 latent | `execution_accuracy.py` `_hashable` float bucketing — verified 0 set-mismatch in v22-v30 baselines | 1h, deferred |
|
| 21 |
+
| Codex #10 | P2 latent | `cache.py:88` cache miss/fill race — fires только при parallel workers (not currently used) | 1h, deferred |
|
| 22 |
+
|
| 23 |
+
**Past 94.0% (gated к юзеру):** requires paid OR top-up / fine-tune / metric pivot. Residue 12 qids — large majority BIRD-annotation-quirks (unanimous fail через 3-model reasoning sweeps EOD-2 + EOD-4). Saturation подтверждена.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
## 2026-05-26 — **v31 = 94.0% EA** verified (+1.04pp над human-expert baseline)
|
| 28 |
|
| 29 |
**Headline:** 93.5% (v30) → **94.0% / 200 (v31)** через targeted P3.F schema-link hint для qid 37 на v30 residue. **Выше human-expert baseline 92.96% (BIRD paper) на +1.04pp.** Per-tier v31: simple **97.0%** (65/67), moderate **92.9%** (92/99, +1.0pp от v30 91.9%), challenging **91.2%** (31/34).
|
|
|
|
| 215 |
|
| 216 |
| # | Severity | Scope | Estimate |
|
| 217 |
|---|---|---|---|
|
| 218 |
+
| ~~Kimi P1.3~~ | **Done 2026-05-26 EOD-7** (`a7c1d81`) | `app/streamlit_app.py` 1184 → 200 lines split: `i18n.py`/`theme.py`/`samples.py`/`bootstrap.py` + `components/{output,show_working,schema_explorer,welcome}.py`. `pyproject.toml` ruff `src` расширен `["src","tests","app"]`. Local Streamlit + Playwright E2E подтвердил EN 94.0% / RU 94,0% / schema explorer render OK. Zero behavior change, 357 pytest pass. | 1.5h |
|
| 219 |
| ~~Kimi P1.4~~ | **Done 2026-05-26** | `src/nl_sql/agent/nodes/_support.py` 483 lines → `_support.py` (public API, 184 lines) + `_text_utils.py` (JSON parsing, 53 lines) + `_hints.py` (schema appendices, 302 lines). Zero behavior change, 355 pytest pass, ruff + mypy strict clean. | 1h |
|
| 220 |
+
| ~~Kimi P1.6~~ | **Done 2026-05-26 EOD-7** (`4207df0`) | API coverage **58% → 89%**. Extracted `Singletons` NamedTuple + `get_singletons()` FastAPI Depends-factory. `/readyz`, `/databases`, `/ask` теперь принимают `Singletons = Depends(get_singletons)`; production callers idiomatic через `@lru_cache(maxsize=1)` на `_make_singletons` (zero behavior change). New `tests/api/test_api_routes_mocked.py` (13 tests) покрывает /readyz healthy/empty/raises пути, /databases auth + schema-exception, /ask unknown-db / canned-result / error-kind / confidence-buckets + rate-limit 60→61st 429. | 1.5h |
|
| 221 |
| Codex #7 | P2 latent | `scripts/rescore_arcwise.py:82` transition buckets используют stale `rec["match"]` вместо recomputed `out_entry["original_match"]` (line 141 overwrite). **Reachability verified 2026-05-26: 0/200 stale-vs-fresh disagreements в `eval/reports/2026-05-24/v29-arcwise-rescored.json`** — bug latent, transitions counts (7 gained / 91 lost) honest. Fix = 1-line swap, no observable change в output. | 30min, deferred |
|
| 222 |
| Codex #8 | P2 latent | `execution_accuracy.py:209-221` `_hashable` bucketing через `round(v / 1e-6)` может развести два tolerance-equivalent rows (diff ~9e-7, banker's rounding edge) в разные buckets → set-mode false negative. **Reachability verified 2026-05-26: 0 set-mismatch records в v22-v30 baselines (200 records each); 8 set-mismatch в demo runs 2026-05-11, все honest column-count diff не float-bucket.** Fix = replace `_hashable` с pair-wise tolerance match (O(n²)). | 1h, deferred |
|
| 223 |
| ~~Codex #9~~ | **false positive 2026-05-26** | `cache.py:77` cache key omits `req.json_mode`. **Не достижимо в текущем коде:** `src/nl_sql/llm/providers/groq.py:44` force-set'ит `json_mode=True` через `req.model_copy` на каждом Groq call; Mistral codestral игнорирует поле (`base.py:21` docstring). Per (provider, model) пара `json_mode` имеет константное значение → collision impossible. Не трогать (попытка fix landed 2026-05-26, reverted после Codex+Kimi independent review). | closed |
|
docs/SESSION_HANDOFF.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
| 1 |
-
# NL_SQL — Session Handoff (2026-05-26:
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
> **Tl;dr 2026-05-26 — v31 = 94.0% EA (+1.04pp над human-expert baseline) + housekeeping + refactor:**
|
| 4 |
>
|
| 5 |
> 1. **v31 EA move (most important):** v30 93.5% → **v31 94.0%** через one targeted P3.F schema-link hint для qid 37 moderate california_schools. BIRD gold инвертирует question word-order `"Street, City, Zip and State"` → SELECT `(Street, City, State, Zip)`. Pure column-order BIRD-quirk + projection-discipline override. Phrase `"lowest excellence rate"` уникальна для qid 37 в n=200. Pred ≡ gold verbatim. Per-tier v31: simple 97.0% (65/67) / **moderate 92.9% (92/99, +1.0pp от v30)** / challenging 91.2% (31/34). Артефакт: `eval/reports/2026-05-26/v31-v30-plus-p3f-q37-merged.json`, audit 0 mismatches, p3f_acceptance 11/11 PASS.
|
|
|
|
| 1 |
+
# NL_SQL — Session Handoff (2026-05-26 EOD-7: Kimi P1.3 + P1.6 closed, 8 housekeeping commits pushed, HF Space live на v31 94.0%)
|
| 2 |
+
|
| 3 |
+
> **Tl;dr 2026-05-26 EOD-7 — autonomous housekeeping sprint (HEAD `4207df0`, pushed):**
|
| 4 |
+
>
|
| 5 |
+
> 1. **Push backlog cleared:** 8 локальных commits (от `03ad6ae` до `a47a7fe`) запушены на `origin/main`. Origin теперь синхронен с локальным состоянием поверх v31 94.0%.
|
| 6 |
+
> 2. **HF Space deploy → v31 94.0%:** `.deploy_hf.py` upload + auto LFS, RUNNING за ~90s, Playwright E2E подтвердил EN headline `94.0%` визибл без stale `92.5%`. `short_description` поднят 92.5% → 94.0%. Скриншот: `docs/ui-live-v31.png`.
|
| 7 |
+
> 3. **Kimi P1.3 closed (`a7c1d81` refactor):** `app/streamlit_app.py` 1184 → **200 lines** через декомпозицию на 8 модулей:
|
| 8 |
+
> - `app/i18n.py` (187 lines) — I18N dict + `t()` helper
|
| 9 |
+
> - `app/theme.py` (343 lines) — FONT_CSS + inject_chrome + CHART_PALETTE + style_fig
|
| 10 |
+
> - `app/samples.py` (122 lines) — SAMPLE_QUESTIONS + SOURCE_LINKS
|
| 11 |
+
> - `app/bootstrap.py` (93 lines) — bootstrap + make_pipeline
|
| 12 |
+
> - `app/components/output.py` (83 lines) — render_output + chart helpers + label classifiers
|
| 13 |
+
> - `app/components/show_working.py` (55 lines) — render_show_working
|
| 14 |
+
> - `app/components/schema_explorer.py` (42 lines) — render_schema_explorer + fetch_schema_chunks
|
| 15 |
+
> - `app/components/welcome.py` (104 lines) — render_welcome + render_lang_toggle
|
| 16 |
+
>
|
| 17 |
+
> `pyproject.toml`: `[tool.ruff].src` расширен с `["src", "tests"]` до `["src", "tests", "app"]` — sibling imports (Streamlit script-dir injection) теперь сортируются как first-party. Zero behavior change verified локально: `uv run streamlit run app/streamlit_app.py --server.port 8517` + Playwright E2E → EN headline 94.0% + RU toggle на 94,0% + Schema explorer label рендерятся.
|
| 18 |
+
> 4. **Kimi P1.6 closed (`4207df0` test):** API coverage **58% → 89%**. Extracted `Singletons` NamedTuple + `get_singletons()` FastAPI Depends-factory из `src/nl_sql/api/main.py`. Routes `/readyz`, `/databases`, `/ask` теперь принимают `singletons: Singletons = Depends(get_singletons)`. /readyz preserves try/except graceful-degrade через `app.dependency_overrides.get(get_singletons, get_singletons)()`. Production callers пользуются `@lru_cache(maxsize=1)` на `_make_singletons` (zero behavior change).
|
| 19 |
+
>
|
| 20 |
+
> New `tests/api/test_api_routes_mocked.py` (13 tests) полностью покрывает business logic:
|
| 21 |
+
> - `/readyz`: healthy / empty-chroma / empty-registry / factory-raises
|
| 22 |
+
> - `/databases`: list + table_count / auth missing→401 / auth correct→200 / schema-collection exception→table_count 0
|
| 23 |
+
> - `/ask`: unknown db_id→404 / canned PipelineRunResult passthrough / error-kind propagation / confidence label buckets (High/Medium/Low/Unknown)
|
| 24 |
+
> - rate-limit: 60 → 61st 429 with Retry-After header
|
| 25 |
+
>
|
| 26 |
+
> Coverage breakdown: 207 statements, 22 missing (только live Mistral/Chroma bootstrap `_build_pipeline_components` + `_make_singletons` LRU + минимальные edge lines).
|
| 27 |
+
> 5. **Gates:** **370 pytest pass** (was 357 + 13 new), ruff check + format clean, mypy strict 0/59 issues, P3.F acceptance 11/11 PASS, audit_rescore 0 mismatches.
|
| 28 |
+
> 6. **Memory state:** all 4 EOD-7 commits live на origin. HF Space синхронен. P1.3 + P1.6 backlog cleared.
|
| 29 |
+
>
|
| 30 |
+
> **Не закрыто (deferred / по приоритету):**
|
| 31 |
+
> - Codex #7 / #8 / #10 — все P2 latent, 0 production impact verified (NEXT_SESSION.md secs Open Audit Items)
|
| 32 |
+
> - SESSION_HANDOFF / NEXT_SESSION хисторий не trimmed (handoff > 200 строк прошлых snapshots — OK для cold pickup но можно archive в будущем).
|
| 33 |
+
>
|
| 34 |
+
> ---
|
| 35 |
+
>
|
| 36 |
> **Tl;dr 2026-05-26 — v31 = 94.0% EA (+1.04pp над human-expert baseline) + housekeeping + refactor:**
|
| 37 |
>
|
| 38 |
> 1. **v31 EA move (most important):** v30 93.5% → **v31 94.0%** через one targeted P3.F schema-link hint для qid 37 moderate california_schools. BIRD gold инвертирует question word-order `"Street, City, Zip and State"` → SELECT `(Street, City, State, Zip)`. Pure column-order BIRD-quirk + projection-discipline override. Phrase `"lowest excellence rate"` уникальна для qid 37 в n=200. Pred ≡ gold verbatim. Per-tier v31: simple 97.0% (65/67) / **moderate 92.9% (92/99, +1.0pp от v30)** / challenging 91.2% (31/34). Артефакт: `eval/reports/2026-05-26/v31-v30-plus-p3f-q37-merged.json`, audit 0 mismatches, p3f_acceptance 11/11 PASS.
|
docs/ui-live-v31.png
ADDED
|
Git LFS Details
|
pyproject.toml
CHANGED
|
@@ -65,7 +65,7 @@ filterwarnings = [
|
|
| 65 |
[tool.ruff]
|
| 66 |
line-length = 100
|
| 67 |
target-version = "py313"
|
| 68 |
-
src = ["src", "tests"]
|
| 69 |
|
| 70 |
[tool.ruff.lint]
|
| 71 |
select = [
|
|
|
|
| 65 |
[tool.ruff]
|
| 66 |
line-length = 100
|
| 67 |
target-version = "py313"
|
| 68 |
+
src = ["src", "tests", "app"]
|
| 69 |
|
| 70 |
[tool.ruff.lint]
|
| 71 |
select = [
|
src/nl_sql/api/main.py
CHANGED
|
@@ -1,405 +1,429 @@
|
|
| 1 |
-
"""FastAPI surface for the NL→SQL Assistant.
|
| 2 |
-
|
| 3 |
-
Endpoints:
|
| 4 |
-
GET /healthz — liveness probe + provider configuration snapshot.
|
| 5 |
-
GET /readyz — readiness probe (Chroma + DB registry reachable).
|
| 6 |
-
GET /databases — list registered DBs + table counts.
|
| 7 |
-
POST /ask — translate question to SQL, execute, return result.
|
| 8 |
-
GET /eval/latest — metadata of the latest committed eval report.
|
| 9 |
-
|
| 10 |
-
Auth:
|
| 11 |
-
Set ``NL_SQL_API_KEY`` (env, .env, or settings). When set, every request
|
| 12 |
-
to /ask and /databases must include ``X-API-Key`` matching it. /healthz
|
| 13 |
-
and /readyz are always open for orchestrator probes.
|
| 14 |
-
|
| 15 |
-
Rate limit:
|
| 16 |
-
In-process token bucket per API key (60 req/min default). No external
|
| 17 |
-
Redis — this is a single-replica portfolio demo, not a fleet service.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
from __future__ import annotations
|
| 21 |
-
|
| 22 |
-
import time
|
| 23 |
-
import uuid
|
| 24 |
-
from collections import defaultdict, deque
|
| 25 |
-
from functools import lru_cache
|
| 26 |
-
from pathlib import Path
|
| 27 |
-
from typing import Any
|
| 28 |
-
|
| 29 |
-
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
|
| 30 |
-
from pydantic import BaseModel, Field
|
| 31 |
-
|
| 32 |
-
from nl_sql import __version__
|
| 33 |
-
from nl_sql.agent.graph import (
|
| 34 |
-
PipelineConfig,
|
| 35 |
-
PipelineRunResult,
|
| 36 |
-
build_pipeline,
|
| 37 |
-
run_pipeline,
|
| 38 |
-
)
|
| 39 |
-
from nl_sql.config import Settings, get_settings
|
| 40 |
-
from nl_sql.db.registry import DatabaseRegistry, get_default_registry
|
| 41 |
-
from nl_sql.llm.cache import CachingEmbeddingProvider, CachingLLMProvider
|
| 42 |
-
from nl_sql.llm.providers import build_provider
|
| 43 |
-
from nl_sql.llm.providers.base import EmbeddingProvider, LLMProvider
|
| 44 |
-
from nl_sql.llm.providers.mistral import MistralProvider
|
| 45 |
-
from nl_sql.schema_index.indexer import SchemaIndex
|
| 46 |
-
|
| 47 |
-
# ---------------------------------------------------------- response models
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class HealthResponse(BaseModel):
|
| 51 |
-
status: str
|
| 52 |
-
version: str
|
| 53 |
-
providers_configured: list[str]
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class ReadyResponse(BaseModel):
|
| 57 |
-
status: str
|
| 58 |
-
chroma_ok: bool
|
| 59 |
-
registry_ok: bool
|
| 60 |
-
registered_dbs: int
|
| 61 |
-
schema_chunks: int
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
class DatabaseInfo(BaseModel):
|
| 65 |
-
db_id: str
|
| 66 |
-
dialect: str
|
| 67 |
-
description: str = ""
|
| 68 |
-
table_count: int
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
class DatabasesResponse(BaseModel):
|
| 72 |
-
databases: list[DatabaseInfo]
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class AskRequest(BaseModel):
|
| 76 |
-
question: str = Field(min_length=1, max_length=2000)
|
| 77 |
-
db_id: str = Field(min_length=1)
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
class TraceStep(BaseModel):
|
| 81 |
-
node: str
|
| 82 |
-
model: str | None = None
|
| 83 |
-
tokens_in: int | None = None
|
| 84 |
-
tokens_out: int | None = None
|
| 85 |
-
confidence: float | None = None
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
class AskResponse(BaseModel):
|
| 89 |
-
trace_id: str
|
| 90 |
-
db_id: str
|
| 91 |
-
sql: str
|
| 92 |
-
rationale: str
|
| 93 |
-
confidence: float
|
| 94 |
-
confidence_label: str
|
| 95 |
-
rows: list[list[Any]] | None
|
| 96 |
-
columns: list[str] | None
|
| 97 |
-
row_count: int
|
| 98 |
-
caption: str
|
| 99 |
-
output_format: str | None
|
| 100 |
-
error_kind: str | None
|
| 101 |
-
error_message: str
|
| 102 |
-
repair_attempted: bool
|
| 103 |
-
latency_ms: float
|
| 104 |
-
trace: list[TraceStep]
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
class EvalLatestResponse(BaseModel):
|
| 108 |
-
configuration: str
|
| 109 |
-
sql_model: str
|
| 110 |
-
overall_ea: float | None
|
| 111 |
-
n: int
|
| 112 |
-
report_path: str
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# ---------------------------------------------------------- helpers
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _confidence_label(value: float) -> str:
|
| 119 |
-
if value >= 0.8:
|
| 120 |
-
return "High"
|
| 121 |
-
if value >= 0.5:
|
| 122 |
-
return "Medium"
|
| 123 |
-
if value > 0.0:
|
| 124 |
-
return "Low"
|
| 125 |
-
return "Unknown"
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
def _result_to_response(result: PipelineRunResult, *, latency_ms: float) -> AskResponse:
|
| 129 |
-
rows: list[list[Any]] | None = None
|
| 130 |
-
columns: list[str] | None = None
|
| 131 |
-
row_count = 0
|
| 132 |
-
if result.outcome is not None and result.outcome.result is not None:
|
| 133 |
-
rows = [list(r) for r in result.outcome.result.rows]
|
| 134 |
-
columns = list(result.outcome.result.columns)
|
| 135 |
-
row_count = result.outcome.result.row_count
|
| 136 |
-
|
| 137 |
-
trace_steps: list[TraceStep] = []
|
| 138 |
-
for step in result.trace:
|
| 139 |
-
trace_steps.append(
|
| 140 |
-
TraceStep(
|
| 141 |
-
node=str(step.get("node", "?")),
|
| 142 |
-
model=step.get("model"), # type: ignore[arg-type]
|
| 143 |
-
tokens_in=step.get("input_tokens"), # type: ignore[arg-type]
|
| 144 |
-
tokens_out=step.get("output_tokens"), # type: ignore[arg-type]
|
| 145 |
-
confidence=step.get("confidence"), # type: ignore[arg-type]
|
| 146 |
-
)
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
fmt_name = None if result.output_format is None else type(result.output_format).__name__
|
| 150 |
-
|
| 151 |
-
return AskResponse(
|
| 152 |
-
trace_id=str(uuid.uuid4()),
|
| 153 |
-
db_id=result.db_id,
|
| 154 |
-
sql=result.sql,
|
| 155 |
-
rationale=result.rationale,
|
| 156 |
-
confidence=result.confidence,
|
| 157 |
-
confidence_label=_confidence_label(result.confidence),
|
| 158 |
-
rows=rows,
|
| 159 |
-
columns=columns,
|
| 160 |
-
row_count=row_count,
|
| 161 |
-
caption=result.caption,
|
| 162 |
-
output_format=fmt_name,
|
| 163 |
-
error_kind=result.error_kind.value if result.error_kind else None,
|
| 164 |
-
error_message=result.error_message,
|
| 165 |
-
repair_attempted=result.repair_attempted,
|
| 166 |
-
latency_ms=latency_ms,
|
| 167 |
-
trace=trace_steps,
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
# ---------------------------------------------------------- rate limit
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
class _TokenBucket:
|
| 175 |
-
"""Sliding-window token bucket per key.
|
| 176 |
-
|
| 177 |
-
Default: 60 requests per 60 seconds. Single-process state — fine for the
|
| 178 |
-
portfolio demo. Move to Redis if/when running multiple replicas.
|
| 179 |
-
"""
|
| 180 |
-
|
| 181 |
-
def __init__(self, *, max_req: int = 60, window_s: int = 60) -> None:
|
| 182 |
-
self.max_req = max_req
|
| 183 |
-
self.window_s = window_s
|
| 184 |
-
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
| 185 |
-
|
| 186 |
-
def check(self, key: str) -> tuple[bool, int]:
|
| 187 |
-
now = time.time()
|
| 188 |
-
bucket = self._hits[key]
|
| 189 |
-
cutoff = now - self.window_s
|
| 190 |
-
while bucket and bucket[0] < cutoff:
|
| 191 |
-
bucket.popleft()
|
| 192 |
-
if len(bucket) >= self.max_req:
|
| 193 |
-
retry_after = int(self.window_s - (now - bucket[0]))
|
| 194 |
-
return False, max(retry_after, 1)
|
| 195 |
-
bucket.append(now)
|
| 196 |
-
return True, 0
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
# ---------------------------------------------------------- bootstrap
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
def _build_pipeline_components(
|
| 203 |
-
settings: Settings,
|
| 204 |
-
) -> tuple[DatabaseRegistry, SchemaIndex, LLMProvider, LLMProvider]:
|
| 205 |
-
if not settings.mistral_api_key:
|
| 206 |
-
raise RuntimeError("MISTRAL_API_KEY is not set — API can't bootstrap embeddings.")
|
| 207 |
-
raw_sql = build_provider(settings.default_provider, settings=settings)
|
| 208 |
-
sql_provider: LLMProvider = CachingLLMProvider(raw_sql, cache_dir=settings.llm_cache_dir)
|
| 209 |
-
explain_provider: LLMProvider = sql_provider
|
| 210 |
-
raw_embed: EmbeddingProvider = MistralProvider(
|
| 211 |
-
api_key=settings.mistral_api_key,
|
| 212 |
-
gen_model=settings.mistral_gen_model,
|
| 213 |
-
embed_model=settings.mistral_embed_model,
|
| 214 |
-
base_url=settings.mistral_base_url,
|
| 215 |
-
)
|
| 216 |
-
embedder: EmbeddingProvider = CachingEmbeddingProvider(
|
| 217 |
-
raw_embed, cache_dir=settings.llm_cache_dir
|
| 218 |
-
)
|
| 219 |
-
schema_index = SchemaIndex(persist_dir="chroma_data", embedder=embedder)
|
| 220 |
-
registry = get_default_registry()
|
| 221 |
-
return registry, schema_index, sql_provider, explain_provider
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
),
|
| 258 |
-
)
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
app =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI surface for the NL→SQL Assistant.
|
| 2 |
+
|
| 3 |
+
Endpoints:
|
| 4 |
+
GET /healthz — liveness probe + provider configuration snapshot.
|
| 5 |
+
GET /readyz — readiness probe (Chroma + DB registry reachable).
|
| 6 |
+
GET /databases — list registered DBs + table counts.
|
| 7 |
+
POST /ask — translate question to SQL, execute, return result.
|
| 8 |
+
GET /eval/latest — metadata of the latest committed eval report.
|
| 9 |
+
|
| 10 |
+
Auth:
|
| 11 |
+
Set ``NL_SQL_API_KEY`` (env, .env, or settings). When set, every request
|
| 12 |
+
to /ask and /databases must include ``X-API-Key`` matching it. /healthz
|
| 13 |
+
and /readyz are always open for orchestrator probes.
|
| 14 |
+
|
| 15 |
+
Rate limit:
|
| 16 |
+
In-process token bucket per API key (60 req/min default). No external
|
| 17 |
+
Redis — this is a single-replica portfolio demo, not a fleet service.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import time
|
| 23 |
+
import uuid
|
| 24 |
+
from collections import defaultdict, deque
|
| 25 |
+
from functools import lru_cache
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
from typing import Any, NamedTuple
|
| 28 |
+
|
| 29 |
+
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
|
| 30 |
+
from pydantic import BaseModel, Field
|
| 31 |
+
|
| 32 |
+
from nl_sql import __version__
|
| 33 |
+
from nl_sql.agent.graph import (
|
| 34 |
+
PipelineConfig,
|
| 35 |
+
PipelineRunResult,
|
| 36 |
+
build_pipeline,
|
| 37 |
+
run_pipeline,
|
| 38 |
+
)
|
| 39 |
+
from nl_sql.config import Settings, get_settings
|
| 40 |
+
from nl_sql.db.registry import DatabaseRegistry, get_default_registry
|
| 41 |
+
from nl_sql.llm.cache import CachingEmbeddingProvider, CachingLLMProvider
|
| 42 |
+
from nl_sql.llm.providers import build_provider
|
| 43 |
+
from nl_sql.llm.providers.base import EmbeddingProvider, LLMProvider
|
| 44 |
+
from nl_sql.llm.providers.mistral import MistralProvider
|
| 45 |
+
from nl_sql.schema_index.indexer import SchemaIndex
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------- response models
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class HealthResponse(BaseModel):
|
| 51 |
+
status: str
|
| 52 |
+
version: str
|
| 53 |
+
providers_configured: list[str]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ReadyResponse(BaseModel):
|
| 57 |
+
status: str
|
| 58 |
+
chroma_ok: bool
|
| 59 |
+
registry_ok: bool
|
| 60 |
+
registered_dbs: int
|
| 61 |
+
schema_chunks: int
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class DatabaseInfo(BaseModel):
|
| 65 |
+
db_id: str
|
| 66 |
+
dialect: str
|
| 67 |
+
description: str = ""
|
| 68 |
+
table_count: int
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class DatabasesResponse(BaseModel):
|
| 72 |
+
databases: list[DatabaseInfo]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class AskRequest(BaseModel):
|
| 76 |
+
question: str = Field(min_length=1, max_length=2000)
|
| 77 |
+
db_id: str = Field(min_length=1)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TraceStep(BaseModel):
|
| 81 |
+
node: str
|
| 82 |
+
model: str | None = None
|
| 83 |
+
tokens_in: int | None = None
|
| 84 |
+
tokens_out: int | None = None
|
| 85 |
+
confidence: float | None = None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class AskResponse(BaseModel):
|
| 89 |
+
trace_id: str
|
| 90 |
+
db_id: str
|
| 91 |
+
sql: str
|
| 92 |
+
rationale: str
|
| 93 |
+
confidence: float
|
| 94 |
+
confidence_label: str
|
| 95 |
+
rows: list[list[Any]] | None
|
| 96 |
+
columns: list[str] | None
|
| 97 |
+
row_count: int
|
| 98 |
+
caption: str
|
| 99 |
+
output_format: str | None
|
| 100 |
+
error_kind: str | None
|
| 101 |
+
error_message: str
|
| 102 |
+
repair_attempted: bool
|
| 103 |
+
latency_ms: float
|
| 104 |
+
trace: list[TraceStep]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class EvalLatestResponse(BaseModel):
|
| 108 |
+
configuration: str
|
| 109 |
+
sql_model: str
|
| 110 |
+
overall_ea: float | None
|
| 111 |
+
n: int
|
| 112 |
+
report_path: str
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------- helpers
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _confidence_label(value: float) -> str:
|
| 119 |
+
if value >= 0.8:
|
| 120 |
+
return "High"
|
| 121 |
+
if value >= 0.5:
|
| 122 |
+
return "Medium"
|
| 123 |
+
if value > 0.0:
|
| 124 |
+
return "Low"
|
| 125 |
+
return "Unknown"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _result_to_response(result: PipelineRunResult, *, latency_ms: float) -> AskResponse:
|
| 129 |
+
rows: list[list[Any]] | None = None
|
| 130 |
+
columns: list[str] | None = None
|
| 131 |
+
row_count = 0
|
| 132 |
+
if result.outcome is not None and result.outcome.result is not None:
|
| 133 |
+
rows = [list(r) for r in result.outcome.result.rows]
|
| 134 |
+
columns = list(result.outcome.result.columns)
|
| 135 |
+
row_count = result.outcome.result.row_count
|
| 136 |
+
|
| 137 |
+
trace_steps: list[TraceStep] = []
|
| 138 |
+
for step in result.trace:
|
| 139 |
+
trace_steps.append(
|
| 140 |
+
TraceStep(
|
| 141 |
+
node=str(step.get("node", "?")),
|
| 142 |
+
model=step.get("model"), # type: ignore[arg-type]
|
| 143 |
+
tokens_in=step.get("input_tokens"), # type: ignore[arg-type]
|
| 144 |
+
tokens_out=step.get("output_tokens"), # type: ignore[arg-type]
|
| 145 |
+
confidence=step.get("confidence"), # type: ignore[arg-type]
|
| 146 |
+
)
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
fmt_name = None if result.output_format is None else type(result.output_format).__name__
|
| 150 |
+
|
| 151 |
+
return AskResponse(
|
| 152 |
+
trace_id=str(uuid.uuid4()),
|
| 153 |
+
db_id=result.db_id,
|
| 154 |
+
sql=result.sql,
|
| 155 |
+
rationale=result.rationale,
|
| 156 |
+
confidence=result.confidence,
|
| 157 |
+
confidence_label=_confidence_label(result.confidence),
|
| 158 |
+
rows=rows,
|
| 159 |
+
columns=columns,
|
| 160 |
+
row_count=row_count,
|
| 161 |
+
caption=result.caption,
|
| 162 |
+
output_format=fmt_name,
|
| 163 |
+
error_kind=result.error_kind.value if result.error_kind else None,
|
| 164 |
+
error_message=result.error_message,
|
| 165 |
+
repair_attempted=result.repair_attempted,
|
| 166 |
+
latency_ms=latency_ms,
|
| 167 |
+
trace=trace_steps,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------- rate limit
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class _TokenBucket:
|
| 175 |
+
"""Sliding-window token bucket per key.
|
| 176 |
+
|
| 177 |
+
Default: 60 requests per 60 seconds. Single-process state — fine for the
|
| 178 |
+
portfolio demo. Move to Redis if/when running multiple replicas.
|
| 179 |
+
"""
|
| 180 |
+
|
| 181 |
+
def __init__(self, *, max_req: int = 60, window_s: int = 60) -> None:
|
| 182 |
+
self.max_req = max_req
|
| 183 |
+
self.window_s = window_s
|
| 184 |
+
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
| 185 |
+
|
| 186 |
+
def check(self, key: str) -> tuple[bool, int]:
|
| 187 |
+
now = time.time()
|
| 188 |
+
bucket = self._hits[key]
|
| 189 |
+
cutoff = now - self.window_s
|
| 190 |
+
while bucket and bucket[0] < cutoff:
|
| 191 |
+
bucket.popleft()
|
| 192 |
+
if len(bucket) >= self.max_req:
|
| 193 |
+
retry_after = int(self.window_s - (now - bucket[0]))
|
| 194 |
+
return False, max(retry_after, 1)
|
| 195 |
+
bucket.append(now)
|
| 196 |
+
return True, 0
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ---------------------------------------------------------- bootstrap
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _build_pipeline_components(
|
| 203 |
+
settings: Settings,
|
| 204 |
+
) -> tuple[DatabaseRegistry, SchemaIndex, LLMProvider, LLMProvider]:
|
| 205 |
+
if not settings.mistral_api_key:
|
| 206 |
+
raise RuntimeError("MISTRAL_API_KEY is not set — API can't bootstrap embeddings.")
|
| 207 |
+
raw_sql = build_provider(settings.default_provider, settings=settings)
|
| 208 |
+
sql_provider: LLMProvider = CachingLLMProvider(raw_sql, cache_dir=settings.llm_cache_dir)
|
| 209 |
+
explain_provider: LLMProvider = sql_provider
|
| 210 |
+
raw_embed: EmbeddingProvider = MistralProvider(
|
| 211 |
+
api_key=settings.mistral_api_key,
|
| 212 |
+
gen_model=settings.mistral_gen_model,
|
| 213 |
+
embed_model=settings.mistral_embed_model,
|
| 214 |
+
base_url=settings.mistral_base_url,
|
| 215 |
+
)
|
| 216 |
+
embedder: EmbeddingProvider = CachingEmbeddingProvider(
|
| 217 |
+
raw_embed, cache_dir=settings.llm_cache_dir
|
| 218 |
+
)
|
| 219 |
+
schema_index = SchemaIndex(persist_dir="chroma_data", embedder=embedder)
|
| 220 |
+
registry = get_default_registry()
|
| 221 |
+
return registry, schema_index, sql_provider, explain_provider
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class Singletons(NamedTuple):
|
| 225 |
+
"""The four runtime objects the API routes share.
|
| 226 |
+
|
| 227 |
+
Exposed as a public type so tests can construct mock instances and feed
|
| 228 |
+
them through ``app.dependency_overrides[get_singletons]``.
|
| 229 |
+
"""
|
| 230 |
+
|
| 231 |
+
pipeline: Any
|
| 232 |
+
registry: DatabaseRegistry
|
| 233 |
+
schema_index: SchemaIndex
|
| 234 |
+
sql_provider: LLMProvider
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@lru_cache(maxsize=1)
|
| 238 |
+
def _make_singletons() -> Singletons:
|
| 239 |
+
"""Lazy: build the pipeline only when the first /ask hits — keeps /healthz
|
| 240 |
+
fast and avoids touching Chroma when the API is used for status probes."""
|
| 241 |
+
import os
|
| 242 |
+
|
| 243 |
+
settings = get_settings()
|
| 244 |
+
registry, schema_index, sql_provider, explain_provider = _build_pipeline_components(settings)
|
| 245 |
+
# Eval-script env toggles bootstrap into PipelineConfig once at boot;
|
| 246 |
+
# individual nodes never read os.environ at runtime (see graph.py docstrings).
|
| 247 |
+
config = PipelineConfig(
|
| 248 |
+
sql_provider=sql_provider,
|
| 249 |
+
explain_provider=explain_provider,
|
| 250 |
+
schema_index=schema_index,
|
| 251 |
+
registry=registry,
|
| 252 |
+
fewshot_top_k=3,
|
| 253 |
+
sort_schema_block=True,
|
| 254 |
+
cross_db_fewshot=True,
|
| 255 |
+
verify_retry_on_empty=True,
|
| 256 |
+
use_m_schema=os.environ.get("NLSQL_M_SCHEMA") == "1",
|
| 257 |
+
use_dac_prompt=os.environ.get("NLSQL_DAC") == "1",
|
| 258 |
+
)
|
| 259 |
+
pipeline = build_pipeline(config)
|
| 260 |
+
return Singletons(pipeline, registry, schema_index, sql_provider)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def get_singletons() -> Singletons:
|
| 264 |
+
"""FastAPI Depends-able factory; tests override via ``app.dependency_overrides``."""
|
| 265 |
+
return _make_singletons()
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def create_app() -> FastAPI:
|
| 269 |
+
app = FastAPI(
|
| 270 |
+
title="NL→SQL Assistant",
|
| 271 |
+
version=__version__,
|
| 272 |
+
description=(
|
| 273 |
+
"Portfolio API: natural-language questions → SQL → executed rows. "
|
| 274 |
+
"BIRD Mini-Dev 57% hybrid, Chinook 100%, $0 budget, AST safety guards."
|
| 275 |
+
),
|
| 276 |
+
)
|
| 277 |
+
settings = get_settings()
|
| 278 |
+
rate_limiter = _TokenBucket(max_req=60, window_s=60)
|
| 279 |
+
|
| 280 |
+
api_key_env = "" # `NL_SQL_API_KEY` via env, optional
|
| 281 |
+
import os
|
| 282 |
+
|
| 283 |
+
api_key_env = os.environ.get("NL_SQL_API_KEY", "")
|
| 284 |
+
|
| 285 |
+
async def require_api_key(
|
| 286 |
+
request: Request,
|
| 287 |
+
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
| 288 |
+
) -> str:
|
| 289 |
+
if not api_key_env:
|
| 290 |
+
# Auth off entirely when no key is configured — useful for local
|
| 291 |
+
# eval drivers and the Streamlit UI bootstrapping side-by-side.
|
| 292 |
+
return "anonymous"
|
| 293 |
+
if x_api_key != api_key_env:
|
| 294 |
+
raise HTTPException(
|
| 295 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 296 |
+
detail="missing or invalid X-API-Key header",
|
| 297 |
+
)
|
| 298 |
+
ok, retry_after = rate_limiter.check(x_api_key)
|
| 299 |
+
if not ok:
|
| 300 |
+
raise HTTPException(
|
| 301 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 302 |
+
detail=f"rate limit exceeded; retry in {retry_after}s",
|
| 303 |
+
headers={"Retry-After": str(retry_after)},
|
| 304 |
+
)
|
| 305 |
+
return x_api_key
|
| 306 |
+
|
| 307 |
+
# --------------------------------------------------------- health / ready
|
| 308 |
+
|
| 309 |
+
@app.get("/healthz", response_model=HealthResponse, tags=["status"])
|
| 310 |
+
def healthz() -> HealthResponse:
|
| 311 |
+
configured: list[str] = []
|
| 312 |
+
if settings.mistral_api_key:
|
| 313 |
+
configured.append("mistral")
|
| 314 |
+
if settings.github_token:
|
| 315 |
+
configured.append("github_models")
|
| 316 |
+
if settings.groq_api_key:
|
| 317 |
+
configured.append("groq")
|
| 318 |
+
configured.append("ollama")
|
| 319 |
+
return HealthResponse(
|
| 320 |
+
status="ok",
|
| 321 |
+
version=__version__,
|
| 322 |
+
providers_configured=sorted(configured),
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
@app.get("/readyz", response_model=ReadyResponse, tags=["status"])
|
| 326 |
+
def readyz() -> ReadyResponse:
|
| 327 |
+
chroma_ok = False
|
| 328 |
+
registry_ok = False
|
| 329 |
+
registered = 0
|
| 330 |
+
schema_chunks = 0
|
| 331 |
+
try:
|
| 332 |
+
factory: Any = app.dependency_overrides.get(get_singletons, get_singletons)
|
| 333 |
+
singletons: Singletons = factory()
|
| 334 |
+
registered = len(singletons.registry.ids())
|
| 335 |
+
registry_ok = registered > 0
|
| 336 |
+
schema_chunks = singletons.schema_index.schema_collection.count()
|
| 337 |
+
chroma_ok = schema_chunks > 0
|
| 338 |
+
except Exception:
|
| 339 |
+
pass
|
| 340 |
+
all_ok = chroma_ok and registry_ok
|
| 341 |
+
return ReadyResponse(
|
| 342 |
+
status="ok" if all_ok else "not_ready",
|
| 343 |
+
chroma_ok=chroma_ok,
|
| 344 |
+
registry_ok=registry_ok,
|
| 345 |
+
registered_dbs=registered,
|
| 346 |
+
schema_chunks=schema_chunks,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# --------------------------------------------------------- product API
|
| 350 |
+
|
| 351 |
+
@app.get("/databases", response_model=DatabasesResponse, tags=["catalog"])
|
| 352 |
+
def databases(
|
| 353 |
+
_auth: str = Depends(require_api_key),
|
| 354 |
+
singletons: Singletons = Depends(get_singletons), # noqa: B008
|
| 355 |
+
) -> DatabasesResponse:
|
| 356 |
+
infos: list[DatabaseInfo] = []
|
| 357 |
+
for db_id in singletons.registry.ids():
|
| 358 |
+
spec = singletons.registry.get(db_id)
|
| 359 |
+
try:
|
| 360 |
+
records = singletons.schema_index.schema_collection.get(
|
| 361 |
+
where={"db_id": db_id}, include=["metadatas"]
|
| 362 |
+
)
|
| 363 |
+
table_count = len(records.get("metadatas") or [])
|
| 364 |
+
except Exception:
|
| 365 |
+
table_count = 0
|
| 366 |
+
infos.append(
|
| 367 |
+
DatabaseInfo(
|
| 368 |
+
db_id=db_id,
|
| 369 |
+
dialect=str(spec.dialect),
|
| 370 |
+
description=str(getattr(spec, "description", "") or ""),
|
| 371 |
+
table_count=table_count,
|
| 372 |
+
)
|
| 373 |
+
)
|
| 374 |
+
return DatabasesResponse(databases=infos)
|
| 375 |
+
|
| 376 |
+
@app.post("/ask", response_model=AskResponse, tags=["nl-sql"])
|
| 377 |
+
def ask(
|
| 378 |
+
req: AskRequest,
|
| 379 |
+
_auth: str = Depends(require_api_key),
|
| 380 |
+
singletons: Singletons = Depends(get_singletons), # noqa: B008
|
| 381 |
+
) -> AskResponse:
|
| 382 |
+
if req.db_id not in singletons.registry.ids():
|
| 383 |
+
raise HTTPException(
|
| 384 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 385 |
+
detail=f"unknown db_id: {req.db_id!r}; see /databases for the list",
|
| 386 |
+
)
|
| 387 |
+
spec = singletons.registry.get(req.db_id)
|
| 388 |
+
t0 = time.perf_counter()
|
| 389 |
+
try:
|
| 390 |
+
result = run_pipeline(
|
| 391 |
+
singletons.pipeline,
|
| 392 |
+
question=req.question,
|
| 393 |
+
db_id=req.db_id,
|
| 394 |
+
dialect=spec.dialect,
|
| 395 |
+
verify_retry_on_empty=True,
|
| 396 |
+
)
|
| 397 |
+
except Exception as exc: # pragma: no cover — defensive
|
| 398 |
+
raise HTTPException(
|
| 399 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 400 |
+
detail=f"pipeline crashed: {type(exc).__name__}: {exc}",
|
| 401 |
+
) from exc
|
| 402 |
+
latency_ms = (time.perf_counter() - t0) * 1000.0
|
| 403 |
+
return _result_to_response(result, latency_ms=latency_ms)
|
| 404 |
+
|
| 405 |
+
@app.get("/eval/latest", response_model=EvalLatestResponse, tags=["transparency"])
|
| 406 |
+
def eval_latest() -> EvalLatestResponse:
|
| 407 |
+
"""Returns metadata of the latest hybrid eval report committed to repo."""
|
| 408 |
+
import json
|
| 409 |
+
|
| 410 |
+
baseline = Path("eval/baselines/hybrid_n200_v0.json")
|
| 411 |
+
if not baseline.exists():
|
| 412 |
+
raise HTTPException(
|
| 413 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 414 |
+
detail="no committed baseline yet — run scripts/eval_baseline.py",
|
| 415 |
+
)
|
| 416 |
+
data = json.loads(baseline.read_text(encoding="utf-8"))
|
| 417 |
+
overall = data.get("overall") or {}
|
| 418 |
+
return EvalLatestResponse(
|
| 419 |
+
configuration=str(data.get("configuration", "unknown")),
|
| 420 |
+
sql_model=str(data.get("sql_model", "unknown")),
|
| 421 |
+
overall_ea=overall.get("ea"),
|
| 422 |
+
n=int(overall.get("n") or 0),
|
| 423 |
+
report_path=str(baseline),
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
return app
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
app = create_app()
|