liovina commited on
Commit
424ea19
·
verified ·
1 Parent(s): 38764ca

Deploy NL_SQL HEAD to HF Space

Browse files
.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 nl_sql.agent.graph import PipelineConfig, PipelineRunResult, build_pipeline, run_pipeline
28
- from nl_sql.config import get_settings
29
- from nl_sql.db.registry import DatabaseRegistry, get_default_registry
30
- from nl_sql.llm.cache import CachingEmbeddingProvider, CachingLLMProvider
31
- from nl_sql.llm.providers import build_provider
32
- from nl_sql.llm.providers.base import EmbeddingProvider, LLMProvider
33
- from nl_sql.llm.providers.mistral import MistralProvider
34
- from nl_sql.render.formats import (
35
- BarChart,
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=_t("page_title"),
1014
  layout="wide",
1015
  )
1016
 
1017
- _inject_chrome()
1018
 
1019
  try:
1020
- registry, schema_index, sql_provider, explain_provider = _bootstrap()
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
- _render_lang_toggle()
1030
 
1031
  st.markdown(
1032
- f"<div class='nl-side-sub'>{_t('db_label')}</div>",
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"{_t('db_dialect')}: `{spec.dialect}`")
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"{_t('db_source')}: [{label}]({url})")
1054
 
1055
- _render_schema_explorer(db_id)
1056
 
1057
  st.markdown(
1058
- f"<div class='nl-side-sub'>{_t('mode_header')}</div>",
1059
  unsafe_allow_html=True,
1060
  )
1061
  mode = st.radio(
1062
- _t("mode_header"),
1063
- options=(_t("mode_accurate"), _t("mode_fast"), _t("mode_debug")),
1064
  index=0,
1065
  captions=(
1066
- _t("mode_accurate_caption"),
1067
- _t("mode_fast_caption"),
1068
- _t("mode_debug_caption"),
1069
  ),
1070
  label_visibility="collapsed",
1071
  )
1072
- if mode == _t("mode_fast"):
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(_t("advanced_header"), expanded=False):
1080
- schema_top_k = st.slider(_t("schema_top_k"), 1, 10, 5)
1081
- fk_hops = st.slider(_t("fk_hops"), 0, 2, 1)
1082
- table_budget = st.slider(_t("table_budget"), 4, 20, 12)
1083
- sort_schema_block = st.checkbox(_t("sort_schema"), value=True)
1084
- extended_sample_size = st.slider(_t("sample_size"), 0, 8, 0)
1085
 
1086
  st.markdown("<div style='height:1.4rem'></div>", unsafe_allow_html=True)
1087
- if st.button(_t("clear_chat"), use_container_width=True):
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
- _render_welcome(db_id)
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(_t("ask_placeholder"))
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 = _make_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(_t("spinner_generating")):
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(_t("pipeline_crashed", kind=type(exc).__name__, msg=str(exc)))
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
- _render_output(result.output_format, caption=result.caption)
1149
 
1150
  if result.sql:
1151
- st.markdown(f"**{_t('sql_label')}**")
1152
  st.code(result.sql, language="sql")
1153
  else:
1154
- st.warning(_t("no_sql"))
1155
 
1156
- st.caption(_t("wall_model", wall=wall_ms, model=sql_provider.model))
1157
 
1158
- _render_show_working(result)
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(_t("pipeline_crashed", kind="prior", msg=msg["error"]))
1174
  return
1175
  result = cast(PipelineRunResult, msg["result"])
1176
- _render_output(result.output_format, caption=result.caption)
1177
  if result.sql:
1178
  st.code(result.sql, language="sql")
1179
- st.caption(_t("wall_model", wall=msg.get("wall_ms", 0), model=msg.get("model", "?")))
1180
- _render_show_working(result)
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:c491d528453ed7ac5a3c1307de64b6d6dce423e776519786ebd2b53f400bdd9b
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:3b52e26715cd41449869575cd2942cc073cd0f050a973a5ff0457a23c0fdc980
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:636f62e3e134c06897a7369cb4bf5148c611d55b11f01ae626d4322d78d10691
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 | P1 | `app/streamlit_app.py` 1184 lines → split (`components/`, `theme.py`, `i18n/`) | 1.5h |
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 | P1 | API coverage 58% → DI для `_make_singletons` + mock provider в API tests | 1.5h |
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: v31 = 94.0% EA via P3.F qid 37 + Kimi P1.4 `_support.py` split + Codex P2 reachability audit; HEAD будет два новых commit'а поверх `3c82e37`, **push gated к юзеру**)
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

  • SHA256: b979e7c575b6f9fa9b3460f3e823d89d56cdf3e21ec0738ad27b79425bda9e5e
  • Pointer size: 131 Bytes
  • Size of remote file: 414 kB
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
- @lru_cache(maxsize=1)
225
- def _make_singletons() -> tuple[Any, DatabaseRegistry, SchemaIndex, LLMProvider]:
226
- """Lazy: build the pipeline only when the first /ask hits — keeps /healthz
227
- fast and avoids touching Chroma when the API is used for status probes."""
228
- import os
229
-
230
- settings = get_settings()
231
- registry, schema_index, sql_provider, explain_provider = _build_pipeline_components(settings)
232
- # Eval-script env toggles bootstrap into PipelineConfig once at boot;
233
- # individual nodes never read os.environ at runtime (see graph.py docstrings).
234
- config = PipelineConfig(
235
- sql_provider=sql_provider,
236
- explain_provider=explain_provider,
237
- schema_index=schema_index,
238
- registry=registry,
239
- fewshot_top_k=3,
240
- sort_schema_block=True,
241
- cross_db_fewshot=True,
242
- verify_retry_on_empty=True,
243
- use_m_schema=os.environ.get("NLSQL_M_SCHEMA") == "1",
244
- use_dac_prompt=os.environ.get("NLSQL_DAC") == "1",
245
- )
246
- pipeline = build_pipeline(config)
247
- return pipeline, registry, schema_index, sql_provider
248
-
249
-
250
- def create_app() -> FastAPI:
251
- app = FastAPI(
252
- title="NL→SQL Assistant",
253
- version=__version__,
254
- description=(
255
- "Portfolio API: natural-language questions → SQL → executed rows. "
256
- "BIRD Mini-Dev 57% hybrid, Chinook 100%, $0 budget, AST safety guards."
257
- ),
258
- )
259
- settings = get_settings()
260
- rate_limiter = _TokenBucket(max_req=60, window_s=60)
261
-
262
- api_key_env = "" # `NL_SQL_API_KEY` via env, optional
263
- import os
264
-
265
- api_key_env = os.environ.get("NL_SQL_API_KEY", "")
266
-
267
- async def require_api_key(
268
- request: Request,
269
- x_api_key: str | None = Header(default=None, alias="X-API-Key"),
270
- ) -> str:
271
- if not api_key_env:
272
- # Auth off entirely when no key is configured — useful for local
273
- # eval drivers and the Streamlit UI bootstrapping side-by-side.
274
- return "anonymous"
275
- if x_api_key != api_key_env:
276
- raise HTTPException(
277
- status_code=status.HTTP_401_UNAUTHORIZED,
278
- detail="missing or invalid X-API-Key header",
279
- )
280
- ok, retry_after = rate_limiter.check(x_api_key)
281
- if not ok:
282
- raise HTTPException(
283
- status_code=status.HTTP_429_TOO_MANY_REQUESTS,
284
- detail=f"rate limit exceeded; retry in {retry_after}s",
285
- headers={"Retry-After": str(retry_after)},
286
- )
287
- return x_api_key
288
-
289
- # --------------------------------------------------------- health / ready
290
-
291
- @app.get("/healthz", response_model=HealthResponse, tags=["status"])
292
- def healthz() -> HealthResponse:
293
- configured: list[str] = []
294
- if settings.mistral_api_key:
295
- configured.append("mistral")
296
- if settings.github_token:
297
- configured.append("github_models")
298
- if settings.groq_api_key:
299
- configured.append("groq")
300
- configured.append("ollama")
301
- return HealthResponse(
302
- status="ok",
303
- version=__version__,
304
- providers_configured=sorted(configured),
305
- )
306
-
307
- @app.get("/readyz", response_model=ReadyResponse, tags=["status"])
308
- def readyz() -> ReadyResponse:
309
- chroma_ok = False
310
- registry_ok = False
311
- registered = 0
312
- schema_chunks = 0
313
- try:
314
- _pipeline, registry, schema_index, _sql = _make_singletons()
315
- registered = len(registry.ids())
316
- registry_ok = registered > 0
317
- schema_chunks = schema_index.schema_collection.count()
318
- chroma_ok = schema_chunks > 0
319
- except Exception:
320
- pass
321
- all_ok = chroma_ok and registry_ok
322
- return ReadyResponse(
323
- status="ok" if all_ok else "not_ready",
324
- chroma_ok=chroma_ok,
325
- registry_ok=registry_ok,
326
- registered_dbs=registered,
327
- schema_chunks=schema_chunks,
328
- )
329
-
330
- # --------------------------------------------------------- product API
331
-
332
- @app.get("/databases", response_model=DatabasesResponse, tags=["catalog"])
333
- def databases(_auth: str = Depends(require_api_key)) -> DatabasesResponse:
334
- _pipeline, registry, schema_index, _sql = _make_singletons()
335
- infos: list[DatabaseInfo] = []
336
- for db_id in registry.ids():
337
- spec = registry.get(db_id)
338
- try:
339
- records = schema_index.schema_collection.get(
340
- where={"db_id": db_id}, include=["metadatas"]
341
- )
342
- table_count = len(records.get("metadatas") or [])
343
- except Exception:
344
- table_count = 0
345
- infos.append(
346
- DatabaseInfo(
347
- db_id=db_id,
348
- dialect=str(spec.dialect),
349
- description=str(getattr(spec, "description", "") or ""),
350
- table_count=table_count,
351
- )
352
- )
353
- return DatabasesResponse(databases=infos)
354
-
355
- @app.post("/ask", response_model=AskResponse, tags=["nl-sql"])
356
- def ask(req: AskRequest, _auth: str = Depends(require_api_key)) -> AskResponse:
357
- pipeline, registry, _schema, _sql = _make_singletons()
358
- if req.db_id not in registry.ids():
359
- raise HTTPException(
360
- status_code=status.HTTP_404_NOT_FOUND,
361
- detail=f"unknown db_id: {req.db_id!r}; see /databases for the list",
362
- )
363
- spec = registry.get(req.db_id)
364
- t0 = time.perf_counter()
365
- try:
366
- result = run_pipeline(
367
- pipeline,
368
- question=req.question,
369
- db_id=req.db_id,
370
- dialect=spec.dialect,
371
- verify_retry_on_empty=True,
372
- )
373
- except Exception as exc: # pragma: no cover — defensive
374
- raise HTTPException(
375
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
376
- detail=f"pipeline crashed: {type(exc).__name__}: {exc}",
377
- ) from exc
378
- latency_ms = (time.perf_counter() - t0) * 1000.0
379
- return _result_to_response(result, latency_ms=latency_ms)
380
-
381
- @app.get("/eval/latest", response_model=EvalLatestResponse, tags=["transparency"])
382
- def eval_latest() -> EvalLatestResponse:
383
- """Returns metadata of the latest hybrid eval report committed to repo."""
384
- import json
385
-
386
- baseline = Path("eval/baselines/hybrid_n200_v0.json")
387
- if not baseline.exists():
388
- raise HTTPException(
389
- status_code=status.HTTP_404_NOT_FOUND,
390
- detail="no committed baseline yet — run scripts/eval_baseline.py",
391
- )
392
- data = json.loads(baseline.read_text(encoding="utf-8"))
393
- overall = data.get("overall") or {}
394
- return EvalLatestResponse(
395
- configuration=str(data.get("configuration", "unknown")),
396
- sql_model=str(data.get("sql_model", "unknown")),
397
- overall_ea=overall.get("ea"),
398
- n=int(overall.get("n") or 0),
399
- report_path=str(baseline),
400
- )
401
-
402
- return app
403
-
404
-
405
- app = create_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()