import concurrent.futures
import gc
import html
import inspect
import os
import re
import threading
import time
import traceback
import unicodedata
import gradio as gr
import sqlparse
import chat_state as chat_core
import intent as intent_core
import model_io as model_core
import sql_tools as sql_core
FINE_TUNED_MODEL_ID = "Shizu0n/phi3-mini-sql-generator-merged"
FINE_TUNED_MODEL_KEY = "fine_tuned"
DEFAULT_MODEL_KEY = FINE_TUNED_MODEL_KEY
FALLBACK_RESPONSE = (
"Select a schema and ask a SQL question, "
"or ask to create or edit a table. "
"Example: 'what is the most expensive product?' or "
"'create table products with id name price'."
)
UNSUPPORTED_MUTATION_RESPONSE = (
"This demo does not generate INSERT, UPDATE, DELETE, or DROP statements. "
"It only supports SELECT/WITH model SQL plus deterministic CREATE TABLE tools."
)
SOURCE_FINE_TUNED_MODEL = "Source: fine-tuned model"
SOURCE_DETERMINISTIC_SQL_TEMPLATE = "Source: deterministic SQL template"
SOURCE_DETERMINISTIC_SCHEMA_PARSER = "Source: deterministic schema parser"
SOURCE_STATIC_FALLBACK = "Source: static fallback"
MODEL_CATALOG = {
FINE_TUNED_MODEL_KEY: {
"label": "Fine-tuned QLoRA model",
"short_label": "Fine-tuned",
"tag": "Fine-tuned",
"title": "QLoRA merged",
"model_id": FINE_TUNED_MODEL_ID,
"exact_match": "73.5%",
"trust_remote_code": False,
"ready_text": "Fine-tuned model ready",
"metadata": (
"Model: Shizu0n/phi3-mini-sql-generator-merged\n"
"Base: microsoft/Phi-3-mini-4k-instruct\n"
"Fine-tuning data: b-mc2/sql-create-context, 1,000 examples\n"
"Metric: 73.5% exact match vs 2.0% base (+71.5pp)"
),
},
}
PRESETS = {
"employees": "CREATE TABLE employees (id INTEGER, name TEXT, department TEXT, salary NUMERIC)",
"orders": "CREATE TABLE orders (id INTEGER, customer_id INTEGER, product TEXT, amount NUMERIC, date DATE)",
"students": "CREATE TABLE students (id INTEGER, name TEXT, course TEXT, grade NUMERIC, year INTEGER)",
"products": "CREATE TABLE products (id INTEGER, name TEXT, category TEXT, price NUMERIC, stock INTEGER)",
"sales": "CREATE TABLE sales (id INTEGER, product_id INTEGER, quantity INTEGER, total NUMERIC, date DATE)",
}
PROMPT_TEMPLATE = (
"<|user|>\n"
"Given the following SQL table, write a SQL query.\n\n"
"Table: {schema}\n\n"
"Question: {question}<|end|>\n"
"<|assistant|>"
)
GENERAL_PROMPT_TEMPLATE = (
"<|user|>\n"
"You are a SQL assistant. Answer the user's question.\n\n"
"Question: {message}<|end|>\n"
"<|assistant|>"
)
EMPTY_VALIDATOR = 'No SQL yet'
CHAT_VALIDATOR = 'Chat response'
EMPTY_CHAT_OUTPUT = ""
LOAD_TIMEOUT_SECONDS = 900
GENERATION_MAX_TIME_SECONDS = 285
GENERATION_TIMEOUT_SECONDS = 320
LOCAL_FILES_ONLY_ENV = "PHI3_SQL_LOCAL_FILES_ONLY"
LOAD_SCROLL_JS = """
(selectedKey) => {
setTimeout(() => {
document.querySelector("#query-section")?.scrollIntoView({
behavior: "smooth",
block: "start"
});
}, 50);
return selectedKey;
}
"""
_current_model_id = None
_model = None
_tokenizer = None
_model_lock = threading.RLock()
_model_activity_lock = threading.Lock()
def import_model_runtime():
try:
import torch
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
except ModuleNotFoundError as exc:
missing = exc.name or "a runtime dependency"
raise RuntimeError(
f"Missing dependency: {missing}. Install the Space dependencies with "
"`python -m pip install -r requirements.txt` in the same Python environment "
"that runs app.py. HuggingFace Spaces installs requirements.txt during build."
) from exc
return torch, AutoConfig, AutoModelForCausalLM, AutoTokenizer
def log_load_step(model_id, step, started=None):
elapsed = "" if started is None else f" elapsed={time.time() - started:.1f}s"
print(f"[LOAD_STEP] model={model_id} step={step}{elapsed}", flush=True)
def cached_model_weights_available(model_id):
try:
from huggingface_hub import try_to_load_from_cache
except ModuleNotFoundError:
return False
weight_files = (
"model.safetensors",
"model.safetensors.index.json",
"pytorch_model.bin",
"pytorch_model.bin.index.json",
)
for filename in weight_files:
try:
cached_path = try_to_load_from_cache(model_id, filename)
except Exception:
cached_path = None
if isinstance(cached_path, str) and os.path.exists(cached_path):
return True
return False
def cached_file_path(model_id, filename):
try:
from huggingface_hub import try_to_load_from_cache
except ModuleNotFoundError:
return None
try:
cached_path = try_to_load_from_cache(model_id, filename)
except Exception:
return None
if isinstance(cached_path, str) and os.path.exists(cached_path):
return cached_path
return None
def cached_snapshot_path(model_id):
config_path = cached_file_path(model_id, "config.json")
if not config_path or not cached_model_weights_available(model_id):
return None
return os.path.dirname(config_path)
def local_files_only_for(model_id):
explicit_local = os.getenv(LOCAL_FILES_ONLY_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
offline_mode = bool(os.getenv("HF_HUB_OFFLINE") or os.getenv("TRANSFORMERS_OFFLINE"))
return explicit_local or offline_mode
def running_on_spaces():
return bool(os.getenv("SPACE_ID"))
def resolve_model_source(model_id):
if local_files_only_for(model_id):
return cached_snapshot_path(model_id) or model_id
return model_id
def dtype_from_name(torch, dtype_name):
if not dtype_name:
return None
normalized = str(dtype_name).replace("torch.", "")
return {
"float16": torch.float16,
"bfloat16": torch.bfloat16,
"float32": torch.float32,
}.get(normalized)
def dtype_from_safetensors(torch, source):
safetensors_path = os.path.join(source, "model.safetensors")
if not os.path.exists(safetensors_path):
return None
try:
from safetensors import safe_open
with safe_open(safetensors_path, framework="pt", device="cpu") as handle:
keys = list(handle.keys())
if not keys:
return None
return handle.get_tensor(keys[0]).dtype
except Exception:
return None
def cpu_model_dtype(torch):
return torch.bfloat16
def model_load_kwargs(torch, config, source):
return {
"attn_implementation": "eager",
"device_map": {"": "cpu"},
"low_cpu_mem_usage": True,
"torch_dtype": "auto",
}
def force_eager_attention(config):
for attr in ("attn_implementation", "_attn_implementation"):
try:
setattr(config, attr, "eager")
except Exception:
pass
return config
def _run_generation(model, inputs, kwargs):
if not _model_activity_lock.acquire(blocking=False):
raise RuntimeError(
"Another model operation is still running. Wait for it to finish before starting another request."
)
torch, _, _, _ = import_model_runtime()
try:
with torch.no_grad():
return model.generate(**inputs, **kwargs)
finally:
_model_activity_lock.release()
def _run_model_load(model_id):
return load_model(model_id)
def patch_phi3_config(config):
if hasattr(config, "rope_scaling") and config.rope_scaling:
rope_type = config.rope_scaling.get("rope_type", "longrope")
if "type" not in config.rope_scaling:
config.rope_scaling["type"] = rope_type
if hasattr(config, "rope_parameters") and config.rope_parameters is None:
config.rope_parameters = dict(config.rope_scaling)
return config
def unload_model():
global _current_model_id, _model, _tokenizer
with _model_lock:
if _model is not None:
del _model
if _tokenizer is not None:
del _tokenizer
_model = None
_tokenizer = None
_current_model_id = None
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
pass
def load_model(model_id):
global _current_model_id, _model, _tokenizer
started = time.time()
log_load_step(model_id, "requested", started)
if not _model_lock.acquire(blocking=False):
raise RuntimeError("Another model load is still running. Wait for it to finish before retrying.")
try:
if _current_model_id == model_id and _model is not None and _tokenizer is not None:
log_load_step(model_id, "already_loaded", started)
return _model, _tokenizer
if not _model_activity_lock.acquire(blocking=False):
raise RuntimeError(
"Another model operation is still running. Wait for it to finish before switching models."
)
try:
log_load_step(model_id, "runtime_import_start", started)
torch, AutoConfig, AutoModelForCausalLM, AutoTokenizer = import_model_runtime()
log_load_step(model_id, "runtime_import_done", started)
local_files_only = local_files_only_for(model_id)
model_source = resolve_model_source(model_id)
log_load_step(model_id, f"cache_mode local_files_only={local_files_only}", started)
log_load_step(model_id, f"model_source {model_source}", started)
log_load_step(model_id, "unload_previous_start", started)
unload_model()
log_load_step(model_id, "unload_previous_done", started)
model_def = model_by_id(model_id)
common_kwargs = {
"trust_remote_code": model_def["trust_remote_code"],
"local_files_only": local_files_only,
}
log_load_step(model_id, "config_start", started)
config = AutoConfig.from_pretrained(
model_source,
**common_kwargs,
)
if model_def["trust_remote_code"]:
config = patch_phi3_config(config)
config = force_eager_attention(config)
log_load_step(model_id, "config_done", started)
load_kwargs = model_load_kwargs(torch, config, model_source)
log_load_step(model_id, f"model_kwargs {load_kwargs}", started)
log_load_step(model_id, "tokenizer_start", started)
tokenizer = AutoTokenizer.from_pretrained(
model_source,
**common_kwargs,
)
if tokenizer.pad_token_id is None and tokenizer.eos_token is not None:
tokenizer.pad_token = tokenizer.eos_token
log_load_step(model_id, "tokenizer_done", started)
log_load_step(model_id, "weights_start", started)
model = AutoModelForCausalLM.from_pretrained(
model_source,
config=config,
**common_kwargs,
**load_kwargs,
)
log_load_step(model_id, "weights_done", started)
log_load_step(model_id, f"loaded_dtype {getattr(model, 'dtype', 'unknown')}", started)
log_load_step(model_id, "eval_start", started)
model.config.use_cache = False
model.eval()
log_load_step(model_id, "eval_done", started)
_model = model
_tokenizer = tokenizer
_current_model_id = model_id
log_load_step(model_id, "state_set_done", started)
return model, tokenizer
finally:
_model_activity_lock.release()
finally:
_model_lock.release()
def model_by_key(model_key):
return MODEL_CATALOG.get(model_key, MODEL_CATALOG[DEFAULT_MODEL_KEY])
def model_by_id(model_id):
for model_def in MODEL_CATALOG.values():
if model_def["model_id"] == model_id:
return model_def
raise ValueError(f"Unknown model id: {model_id}")
def model_key_by_id(model_id):
for key, model_def in MODEL_CATALOG.items():
if model_def["model_id"] == model_id:
return key
return None
def content_to_text(value):
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, dict):
for key in ("text", "content", "value"):
if key in value:
return content_to_text(value[key])
return " ".join(content_to_text(item) for item in value.values())
if isinstance(value, (list, tuple)):
return "\n".join(content_to_text(item) for item in value)
return str(value)
def normalize_text(value):
text = content_to_text(value).lower()
text = unicodedata.normalize("NFKD", text)
text = "".join(char for char in text if not unicodedata.combining(char))
return re.sub(r"\s+", " ", text).strip()
def safe_chat_fallback(_message=""):
return FALLBACK_RESPONSE
def clean_generation(text):
cleaned = content_to_text(text).strip()
if cleaned.startswith("```"):
lines = cleaned.splitlines()
if lines and lines[0].strip().lower() in {"```", "```sql"}:
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
cleaned = "\n".join(lines).strip()
for marker in ("<|end|>", "<|user|>", "<|assistant|>", ""):
if marker in cleaned:
cleaned = cleaned.split(marker, 1)[0].strip()
if cleaned.upper().startswith("SQL:"):
cleaned = cleaned[4:].strip()
return cleaned
def extract_sql_candidate(text):
cleaned = clean_generation(text)
match = re.search(r"\b(SELECT|WITH|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b", cleaned, flags=re.IGNORECASE)
if not match:
return cleaned
return cleaned[match.start() :].strip()
def is_sql_like(text):
text = (text or "").strip()
if not text:
return False
first_word = re.match(r"^\s*([A-Za-z]+)", text)
if not first_word:
return False
return first_word.group(1).upper() in {
"SELECT",
"WITH",
"INSERT",
"UPDATE",
"DELETE",
"CREATE",
"ALTER",
"DROP",
}
def render_theme_detection_style():
return """
"""
def render_header():
return """
Phi-3 Mini SQL Chatbot
Text-to-SQL demo with explicit model and guardrail boundaries.
Fine-tuned model: SQL_QUERY
Deterministic guardrails
CPU lazy load
"""
def render_step(number, title):
return f"""
{number} — {title}
"""
def render_model_card(model_key, selected_key):
model_def = model_by_key(model_key)
selected = model_key == selected_key
state_class = " selected" if selected else ""
return f"""
{model_def["tag"]}
{model_def["title"]}
{model_def["model_id"]}
{model_def["exact_match"]}
exact match
{render_baseline_evidence()}
"""
def render_status(selected_key=None, loaded_key=None, state="idle"):
selected_def = model_by_key(selected_key or DEFAULT_MODEL_KEY)
if state == "loading":
return (
''
f' {SOURCE_FINE_TUNED_MODEL}. Loading {selected_def["short_label"]} model - first load ~3-5 min'
"
"
)
if loaded_key:
loaded_def = model_by_key(loaded_key)
return (
''
f' {SOURCE_FINE_TUNED_MODEL}. {loaded_def["short_label"]} model ready - ~3-5 min per query on CPU'
"
"
)
return f' {SOURCE_FINE_TUNED_MODEL}. No model loaded
'
def render_loading_overlay(model_key=None, visible=False):
if not visible:
return ''
model_def = model_by_key(model_key or DEFAULT_MODEL_KEY)
return (
''
'
'
f'
Loading {model_def["short_label"]} model
'
'
'
'
First load: ~3-5 min — cached for session
'
"
"
"
"
)
def model_metadata(model_key=None):
return f"""
"""
def render_source_contract_grid():
return """
SQL_QUERYtemplates first; model only for SELECT/WITH
Createdeterministic CREATE TABLE parser
Editdeterministic schema updates
Fallbackstatic non-SQL response
"""
def render_source_legend(extra_class=""):
class_name = "source-panel"
if extra_class:
class_name = f"{class_name} {extra_class}"
return f"""
Source contract
always visible
{render_source_contract_grid()}
"""
def render_example_prompts():
return """
Example prompts
by source
fine-tuned modelshow employees in Engineering ordered by salary
deterministic SQL templatewhat is the most expensive product?
deterministic schema parsercreate table animals with id name species weight
static fallbackwhat can you do?
"""
def render_baseline_evidence():
return """
Offline evidence
Base
2.0%
Fine-tuned
73.5%
Gain
+71.5pp
"""
def schema_name_by_value(schema):
schema = (schema or "").strip()
for name, value in PRESETS.items():
if value == schema:
return name
table_name, _columns = sql_core.parse_create_table_schema(schema)
if table_name:
return table_name
return "custom"
def is_create_table_intent(message):
message = (message or "").strip().lower()
return bool(
re.search(r"\b(create|make|build|generate|criar|crie|cria|gerar|gere|faz|faça)\b", message)
and re.search(r"\b(table|schema|tabela)\b", message)
)
def is_table_edit_intent(message):
message = (message or "").strip().lower()
edit_terms = r"\b(edit|update|modify|alter|add|include|remove|delete|drop|edita|editar|altera|altere|alterar|mude|mudar|adicione|adicionar|inclua|incluir|acrescente|remova|remover|delete|deletar|exclua|excluir|novo|nova|troca|trocar|troquecoloque|colocar)\b"
direct_add_terms = r"\b(add|include|adicione|adicionar|adicionando|inclua|incluir|acrescente)\b"
direct_remove_terms = r"\b(remove|delete|drop|remova|remover|deletar|exclua|excluir)\b"
target_terms = r"\b(column|field|element|coluna|campo|elemento|item)\b"
# SQL aggregation keywords that indicate query, not table edit
sql_aggregation_terms = {"up", "sum", "total", "count", "average", "avg", "max", "min", "by"}
words = message.split()
# For add: require target term OR check if it's clearly a column name list
# "add up the total" is SQL query; "add email and phone" is table edit
add_match = re.search(direct_add_terms, message)
has_target = re.search(target_terms, message)
if add_match:
# Find position after "add" keyword
match_pos = add_match.start()
after_add = message[match_pos + len(add_match.group()):].strip()
first_word_after = after_add.split()[0] if after_add.split() else ""
# If first word after "add" is aggregation term, it's SQL query, not edit
is_sql_query = first_word_after in sql_aggregation_terms
is_add_intent = not is_sql_query
else:
is_add_intent = False
return bool(
is_add_intent
or re.search(direct_remove_terms, message)
or is_rename_intent(message)
or re.search(r"\b(?:altere|alterar|mude|mudar)\b.*\bter\b", message)
or (re.search(edit_terms, message) and (re.search(target_terms, message) or ":" in message or re.search(r"\bpor\b", message)))
)
def infer_column_type(column_name):
name = column_name.strip().lower()
if name == "id" or name.endswith("_id") or name in {"quantity", "quantidade", "stock", "estoque", "year"}:
return "INTEGER"
if name in {
"salary",
"price",
"preco",
"amount",
"total",
"grade",
"peso",
"weight",
"idade",
"age",
"altura",
"height",
"largura",
"width",
"comprimento",
"length",
"desconto",
"discount",
}:
return "NUMERIC"
if name in {"date", "created_at", "updated_at"} or name.endswith("_date"):
return "DATE"
return "TEXT"
def normalize_identifier(value):
identifier = re.sub(r"\W+", "_", normalize_text(value)).strip("_")
if not identifier:
return ""
if identifier[0].isdigit():
identifier = f"col_{identifier}"
return identifier
def parse_column_definition(raw_column):
raw_column = re.sub(r"\b(for me|please|por favor)\b", "", raw_column or "", flags=re.IGNORECASE)
raw_column = raw_column.strip(" .;:")
if not raw_column:
return None
# P2 fix: look for the type as the FINAL token, not the first match
# "date DATE" should be interpreted as name="date", type="DATE", not name="" type="date"
type_matches = list(
re.finditer(
r"\b(integer|int|numeric|decimal|real|float|double|text|varchar|char|date|datetime|timestamp|boolean|bool)\b",
raw_column,
flags=re.IGNORECASE,
)
)
explicit_type = type_matches[-1] if type_matches else None
if explicit_type:
name_part = raw_column[: explicit_type.start()].strip()
column_type = explicit_type.group(1).upper()
if column_type == "INT":
column_type = "INTEGER"
elif column_type == "BOOL":
column_type = "BOOLEAN"
elif column_type == "DECIMAL":
column_type = "NUMERIC"
elif column_type in {"FLOAT", "DOUBLE"}:
column_type = "REAL"
if not name_part.strip():
column_type = None
name_part = raw_column
else:
name_part = raw_column
column_type = None
name_part = re.sub(r"\b(column|field|coluna|campo)\b", "", name_part, flags=re.IGNORECASE)
column_name = normalize_identifier(name_part)
if not column_name:
return None
return column_name, column_type or infer_column_type(column_name)
def split_column_list(columns_text):
columns_text = re.sub(r"\s+(and|e)\s+", ",", columns_text or "", flags=re.IGNORECASE)
parts = []
type_pattern = (
r"\b(integer|int|numeric|decimal|real|float|double|text|varchar|char|date|datetime|timestamp|boolean|bool)\b"
)
type_tokens = {
"integer",
"int",
"numeric",
"decimal",
"real",
"float",
"double",
"text",
"varchar",
"char",
"date",
"datetime",
"timestamp",
"boolean",
"bool",
}
STOPWORDS = {
"to", "from", "into", "as", "for",
"o", "a", "os", "de", "do", "da", "dos", "das",
}
for part in (item.strip() for item in columns_text.split(",") if item.strip()):
tokens = [token.strip() for token in re.split(r"\s+", part) if token.strip()]
tokens = [t for t in tokens if t.lower() not in STOPWORDS]
if not tokens:
continue
if re.search(type_pattern, part, flags=re.IGNORECASE) and len(tokens) > 2:
index = 0
# Column names that could be confused with SQL types when followed by date/datetime/timestamp
# These should be treated as column names, not as part of type specification
inferrable_names = {"total", "date", "time", "timestamp", "int", "text", "real", "char"}
while index < len(tokens):
current = tokens[index]
next_token = tokens[index + 1].lower() if index + 1 < len(tokens) else ""
# If current could be inferred as a different type, don't pair with date/datetime/timestamp
# This preserves "total date" → "total" (inferred NUMERIC) + "date" (type)
if next_token in type_tokens and not (current.lower() in inferrable_names and next_token in {"date", "datetime", "timestamp"}):
parts.append(f"{current} {tokens[index + 1]}")
index += 2
else:
parts.append(current)
index += 1
continue
if re.search(type_pattern, part, flags=re.IGNORECASE):
parts.append(part)
continue
if len(tokens) > 1 and all(re.match(r"^[A-Za-z_][\wàáâãçèéêíóôõúÀÁÂÃÇÈÉÊÍÓÔÕÚ]*$", token) for token in tokens):
parts.extend(tokens)
else:
parts.append(part)
return parts
def format_create_table(table_name, columns):
if not table_name or not columns:
return ""
seen = set()
column_lines = []
for column_name, column_type in columns:
if column_name in seen:
continue
seen.add(column_name)
column_lines.append(f" {column_name} {column_type}")
if not column_lines:
return ""
return f"CREATE TABLE {table_name} (\n" + ",\n".join(column_lines) + "\n);"
def parse_create_table_schema(schema):
schema = (schema or "").strip()
match = re.match(
r"^\s*(?:CREATE\s+TABLE\s+)?([A-Za-z_][\w]*)\s*\((.*?)\)\s*;?\s*$",
schema,
flags=re.IGNORECASE | re.DOTALL,
)
if not match:
return "", []
table_name = normalize_identifier(match.group(1))
columns = [
parsed
for parsed in (parse_column_definition(column) for column in split_column_list(match.group(2)))
if parsed
]
return table_name, columns
def extract_create_table_statement(text):
cleaned = extract_sql_candidate(text)
match = re.search(
r"\bCREATE\s+TABLE\s+[A-Za-z_][\w]*\s*\(.*?\)\s*;?",
cleaned,
flags=re.IGNORECASE | re.DOTALL,
)
return clean_generation(match.group(0)) if match else ""
def last_create_table_from_history(chat_history):
for item in reversed(list(chat_history or [])):
if not isinstance(item, dict) or item.get("role") != "assistant":
continue
statement = extract_create_table_statement(item.get("content", ""))
if statement:
return statement
return ""
def extract_added_columns(message):
message = (message or "").strip()
patterns = (
r":\s*(.+)$",
r"\b(?:add|include|with|adicionar|adicione|adicionando|inclua|incluir|acrescente|ter)\b\s+(?:um\s+|uma\s+|a\s+|an\s+)?(?:novo\s+|nova\s+|new\s+)?(?:column|field|element|coluna|campo|elemento|item)?\s*(.+)$",
)
for pattern in patterns:
match = re.search(pattern, message, flags=re.IGNORECASE)
if not match:
continue
columns = [
parsed
for parsed in (parse_column_definition(column) for column in split_column_list(match.group(1)))
if parsed
]
if columns:
return columns
return []
def extract_removed_columns(message):
message = (message or "").strip()
patterns = (
r"\b(?:remove|delete|drop|remova|remover|deletar|exclua|excluir)\b\s+(?:a\s+|o\s+|the\s+)?(?:column|field|element|coluna|campo|elemento|item)?\s*(.+)$",
)
for pattern in patterns:
match = re.search(pattern, message, flags=re.IGNORECASE)
if not match:
continue
columns = [normalize_identifier(column) for column in split_column_list(match.group(1))]
columns = [column for column in columns if column]
if columns:
return columns
return []
def is_rename_intent(message):
message = (message or "").strip().lower()
return bool(
re.search(
r"\b(rename|edit|change|renomeie|renomear|renomeia|renomeia|altere|mude|muda|troca|trocar)\s+\w+\s+(to|para|as|como|por)\s+\w+",
message,
flags=re.IGNORECASE,
)
)
def extract_renamed_columns(message):
pattern = (
r"\b(?:rename|edit|change|renomeie|renomear|altere|mude)\s+"
r"(\w+)\s+(?:to|para|as|como)\s+(\w+)"
)
matches = re.findall(pattern, message or "", flags=re.IGNORECASE)
# Also handle "troca X por Y" pattern
troca_matches = re.findall(
r"\btroca\b\s+(\w+)\s+\bpor\b\s+(\w+)",
message or "",
flags=re.IGNORECASE,
)
all_matches = matches + troca_matches
return [
(normalize_identifier(old), normalize_identifier(new))
for old, new in all_matches
if normalize_identifier(old) and normalize_identifier(new)
]
def parse_compound_edit(message):
"""Split a compound prompt into segments and extract add/remove/rename."""
segment_pattern = (
r"\s+(?:and|e)\s+"
r"(?=\b(?:add|include|remove|delete|drop|rename|edit|change|"
r"adicione|adicionar|inclua|acrescente|remova|remover|deletar|"
r"exclua|renomeie|renomear|altere|mude|troca|trocar)\b)"
)
segments = re.split(segment_pattern, message or "", flags=re.IGNORECASE)
added, removed, renamed = [], [], []
for seg in segments:
seg = seg.strip()
if not seg:
continue
if is_rename_intent(seg):
renamed.extend(extract_renamed_columns(seg))
elif re.search(
r"\b(remove|delete|drop|remova|remover|deletar|exclua|excluir)\b",
seg,
flags=re.IGNORECASE,
):
removed.extend(extract_removed_columns(seg))
else:
cols = extract_added_columns(seg)
if cols:
added.extend(cols)
return added, removed, renamed
def render_schema_context(schema=""):
schema = (schema or "").strip()
if not schema:
return 'No active schemaSelect a preset or create a table.
'
label = schema_name_by_value(schema)
escaped_schema = html.escape(schema)
escaped_label = html.escape(label)
return (
''
f'Context: {escaped_label}'
f'{escaped_schema}'
"
"
)
def query_control_updates(can_generate):
context_updates = [gr.update(interactive=True) for _ in range(6)]
# Keep submit button enabled - model requirement is checked in generate_response
return [*context_updates, gr.update(interactive=True), gr.update(interactive=True)]
def render_message(message="", kind="error"):
if not message:
return ''
class_name = "message-ok" if kind == "ok" else "message-error"
return f'{html.escape(str(message))}
'
def load_selected_model(selected_key=FINE_TUNED_MODEL_KEY):
selected_key = FINE_TUNED_MODEL_KEY
model_def = model_by_key(selected_key)
print(
f"[LOAD_REQUEST] selected_key={selected_key} model_id={model_def['model_id']}",
flush=True,
)
yield (
None,
render_status(selected_key, None, state="loading"),
render_loading_overlay(selected_key, visible=True),
model_metadata(selected_key),
gr.update(interactive=False, visible=False),
*query_control_updates(False),
"",
EMPTY_VALIDATOR,
render_message(),
)
started = time.time()
try:
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
future = executor.submit(_run_model_load, model_def["model_id"])
try:
result = future.result(timeout=LOAD_TIMEOUT_SECONDS)
except concurrent.futures.TimeoutError:
# Timeout reached but cannot truly cancel a running thread.
# Wait for the operation to complete naturally to avoid race conditions.
# The UI stays in loading state until the operation finishes.
result = future.result()
print(f"[LOAD] Completed after timeout warning ({int(time.time() - started)}s)", flush=True)
finally:
executor.shutdown(wait=False, cancel_futures=True)
except Exception as exc:
error = f"{SOURCE_FINE_TUNED_MODEL}. Load failed for {model_def['model_id']}: {type(exc).__name__}: {exc}"
print(f"[LOAD_ERROR] {error}", flush=True)
traceback.print_exc()
yield (
None,
render_status(selected_key, None),
render_loading_overlay(visible=False),
model_metadata(selected_key),
gr.update(interactive=True, visible=True),
*query_control_updates(False),
"",
EMPTY_VALIDATOR,
render_message(error),
)
return
elapsed = int(time.time() - started)
yield (
selected_key,
render_status(selected_key, selected_key),
render_loading_overlay(visible=False),
model_metadata(selected_key),
gr.update(interactive=True, visible=True, value="Load fine-tuned model"),
*query_control_updates(True),
"",
EMPTY_VALIDATOR,
render_message(f"{SOURCE_FINE_TUNED_MODEL}. Loaded {model_def['model_id']} in {elapsed}s.", kind="ok"),
)
def set_preset(name):
schema = PRESETS[name]
return schema, render_schema_context(schema), gr.update(visible=True), chat_core.default_state(schema)
def clear_schema_context():
return "", render_schema_context(""), gr.update(visible=False), chat_core.default_state("")
def trim_chat_history(chat_history, max_exchanges=10):
history = list(chat_history or [])
return history[-max_exchanges * 2 :]
def _append_chat_turn(chat_history, message, assistant_content):
return trim_chat_history(
[
*list(chat_history or []),
{"role": "user", "content": message},
{"role": "assistant", "content": assistant_content},
]
)
def _response_tuple(
chat_history,
message,
state,
assistant_content,
status_message,
*,
sql_text="",
validator=CHAT_VALIDATOR,
status_kind="ok",
):
state = chat_core.ConversationState.from_value(state)
if sql_text and "CREATE TABLE" in sql_text.upper():
state = state.with_active_schema(sql_text)
new_history = _append_chat_turn(chat_history, message, assistant_content)
return (
new_history,
"",
state.active_schema,
message,
sql_text,
validator,
render_message(status_message, kind=status_kind),
state.to_dict(),
render_schema_context(state.active_schema),
gr.update(visible=bool(state.active_schema.strip())),
)
def deterministic_response(
chat_history,
message,
active_schema,
loaded_key,
assistant_content,
status_message,
*,
sql_text="",
validator=CHAT_VALIDATOR,
status_kind="ok",
conversation_state=None,
):
state = chat_core.ConversationState.from_value(conversation_state, active_schema=active_schema)
return _response_tuple(
chat_history,
message,
state,
assistant_content,
status_message,
sql_text=sql_text,
validator=validator,
status_kind=status_kind,
)
def _model_ready(loaded_key):
if not loaded_key or _model is None or _tokenizer is None:
return False, "Load the fine-tuned model before generating SQL."
model_def = model_by_key(loaded_key)
if _current_model_id != model_def["model_id"]:
return False, "Loaded model state is inconsistent. Reload the selected model."
return True, ""
def _generate_model_text(prompt, generation_kind=model_core.SQL_GENERATION):
started = time.time()
import_model_runtime()
with _model_lock:
model = _model
tokenizer = _tokenizer
if model is None or tokenizer is None:
raise RuntimeError("Model runtime is not loaded.")
inputs = tokenizer(prompt, return_tensors="pt")
input_length = inputs["input_ids"].shape[-1]
generation_config = getattr(model, "generation_config", None)
gen_kwargs = {
"max_new_tokens": model_core.generation_budget(generation_kind),
"max_time": GENERATION_MAX_TIME_SECONDS,
"do_sample": False,
"use_cache": False,
"repetition_penalty": 1.1,
"eos_token_id": getattr(generation_config, "eos_token_id", tokenizer.eos_token_id),
"pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
}
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
future = executor.submit(_run_generation, model, inputs, gen_kwargs)
try:
output_ids = future.result(timeout=GENERATION_TIMEOUT_SECONDS)
except concurrent.futures.TimeoutError:
executor.shutdown(wait=False, cancel_futures=False)
raise TimeoutError(f"Generation timed out after {GENERATION_TIMEOUT_SECONDS}s")
finally:
executor.shutdown(wait=False, cancel_futures=True)
generated_ids = output_ids[0][input_length:]
generated_text = tokenizer.decode(generated_ids, skip_special_tokens=True)
return generated_text, int(time.time() - started)
def _empty_generation_response(
chat_history,
message,
state,
status_message,
*,
status_kind="error",
source_label="",
):
status_text = f"{source_label}. {status_message}" if source_label else status_message
return (
chat_history,
message,
state.active_schema,
"",
"",
EMPTY_VALIDATOR,
render_message(status_text, kind=status_kind),
state.to_dict(),
render_schema_context(state.active_schema),
gr.update(visible=bool(state.active_schema.strip())),
)
def generate_response(message, chat_history, active_schema, loaded_key, conversation_state=None):
message = (message or "").strip()
chat_history = list(chat_history or [])
state = chat_core.ConversationState.from_value(conversation_state, active_schema=(active_schema or ""))
if not message:
return (
chat_history,
"",
state.active_schema,
"",
"",
EMPTY_VALIDATOR,
render_message("Type a message before sending."),
state.to_dict(),
render_schema_context(state.active_schema),
gr.update(visible=bool(state.active_schema.strip())),
)
intent_result = intent_core.classify_intent(message, state, chat_history)
state = state.with_intent(intent_result)
print(
f"[ROUTING] \"{message[:60]}\" -> intent={intent_result.intent} "
f"confidence={intent_result.confidence} reason={intent_result.reason}",
flush=True,
)
if intent_result.intent == intent_core.EDIT_TABLE:
edited_table = sql_core.edit_create_table_from_message(message, chat_history, state.active_schema)
if edited_table:
display_response = f"```sql\n{edited_table}\n```"
return _response_tuple(
chat_history,
message,
state,
display_response,
f"{SOURCE_DETERMINISTIC_SCHEMA_PARSER}. Edited CREATE TABLE without calling the model.",
sql_text=edited_table,
validator=sql_core.validate_sql(edited_table),
)
if sql_core.last_create_table_from_history(chat_history) or sql_core.create_table_from_schema(state.active_schema):
return _empty_generation_response(
chat_history,
message,
state,
"No matching schema column was changed.",
source_label=SOURCE_DETERMINISTIC_SCHEMA_PARSER,
)
return _empty_generation_response(
chat_history,
message,
state,
"I need an existing CREATE TABLE in the chat or an active schema before editing columns.",
source_label=SOURCE_DETERMINISTIC_SCHEMA_PARSER,
)
if intent_result.intent == intent_core.CREATE_TABLE:
sql_text = sql_core.create_table_from_message(message) or sql_core.create_table_from_schema(state.active_schema)
if sql_text:
display_response = f"```sql\n{sql_text}\n```"
return _response_tuple(
chat_history,
message,
state,
display_response,
f"{SOURCE_DETERMINISTIC_SCHEMA_PARSER}. Generated CREATE TABLE without calling the model.",
sql_text=sql_text,
validator=sql_core.validate_sql(sql_text),
)
return _empty_generation_response(
chat_history,
message,
state,
"CREATE TABLE needs a table name and columns.",
source_label=SOURCE_DETERMINISTIC_SCHEMA_PARSER,
)
if intent_result.intent == intent_core.UNKNOWN and intent_result.reason == "unsupported_data_mutation":
return _response_tuple(
chat_history,
message,
state,
UNSUPPORTED_MUTATION_RESPONSE,
f"{SOURCE_STATIC_FALLBACK}. Unsupported data mutation - no model call.",
sql_text="",
validator=EMPTY_VALIDATOR,
)
if intent_result.intent in {intent_core.SMALLTALK, intent_core.UNKNOWN}:
return _response_tuple(
chat_history,
message,
state,
FALLBACK_RESPONSE,
f"{SOURCE_STATIC_FALLBACK}. Static fallback - no model call.",
)
deterministic_sql = sql_core.deterministic_sql_query(message, state.active_schema)
if deterministic_sql:
return _response_tuple(
chat_history,
message,
state,
f"```sql\n{deterministic_sql}\n```",
f"{SOURCE_DETERMINISTIC_SQL_TEMPLATE}. Generated SQL with a deterministic SQL template.",
sql_text=deterministic_sql,
validator=sql_core.validate_sql(deterministic_sql),
)
ready, error = _model_ready(loaded_key)
if not ready:
return _empty_generation_response(
chat_history,
message,
state,
error if "inconsistent" in error else "Load a model before generating SQL.",
source_label=SOURCE_FINE_TUNED_MODEL,
)
try:
prompt = model_core.build_sql_prompt(state.active_schema, message, chat_history)
generated_text, elapsed = _generate_model_text(prompt, model_core.SQL_GENERATION)
except Exception as exc:
return _empty_generation_response(
chat_history,
message,
state,
f"Generation failed: {type(exc).__name__}: {exc}",
source_label=SOURCE_FINE_TUNED_MODEL,
)
sql_text, _chat_text, validator = model_core.format_generation_result(
generated_text,
state.active_schema,
)
model_def = model_by_key(loaded_key)
if not sql_text:
rejection_reason = model_core.model_sql_rejection_reason(generated_text, state.active_schema)
rejection_detail = (
f"The fine-tuned model output was rejected because {rejection_reason}."
if rejection_reason
else "The fine-tuned model output was rejected by SQL/schema guardrails."
)
return _response_tuple(
chat_history,
message,
state,
rejection_detail,
f"{SOURCE_FINE_TUNED_MODEL}. Rejected non-SELECT/WITH model output or schema-invalid model output from {model_def['model_id']} in {elapsed}s.",
sql_text="",
validator=validator,
status_kind="error",
)
display_response = f"```sql\n{sql_text}\n```"
return _response_tuple(
chat_history,
message,
state,
display_response,
f"{SOURCE_FINE_TUNED_MODEL}. Generated SQL with {model_def['model_id']} in {elapsed}s.",
sql_text=str(sql_text),
validator=validator,
)
def is_sql_intent(message, schema):
return sql_core.is_sql_intent(message, schema)
def build_generation_prompt(schema, message, chat_history=None):
return model_core.build_sql_prompt(schema, message, chat_history)
def normalize_sql_question_to_english(message, schema=""):
return sql_core.normalize_sql_question_to_english(message, schema)
def format_generation_result(text, schema=""):
return model_core.format_generation_result(text, schema)
def validate_sql(sql_text, schema=""):
return sql_core.validate_sql(sql_text, schema)
def create_table_from_message(message):
return sql_core.create_table_from_message(message)
def create_table_from_schema(schema):
return sql_core.create_table_from_schema(schema)
def edit_create_table_from_message(message, chat_history, active_schema):
return sql_core.edit_create_table_from_message(message, chat_history, active_schema)
def sync_on_load():
if _model is not None and _current_model_id is not None:
loaded_key = model_key_by_id(_current_model_id)
if loaded_key:
return (
loaded_key,
render_status(loaded_key, loaded_key),
render_loading_overlay(visible=False),
model_metadata(loaded_key),
gr.update(interactive=True, visible=True, value="Load fine-tuned model"),
*query_control_updates(True),
"",
EMPTY_VALIDATOR,
render_message(f"{SOURCE_FINE_TUNED_MODEL}. Model already loaded: {_current_model_id}", kind="ok"),
)
return (
None,
render_status(DEFAULT_MODEL_KEY, None),
render_loading_overlay(visible=False),
model_metadata(DEFAULT_MODEL_KEY),
gr.update(interactive=True, visible=True),
*query_control_updates(False),
"",
EMPTY_VALIDATOR,
render_message(),
)
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;500;700&display=swap');
/* Keep app contrast stable regardless of Spaces light/dark host theme. */
[class*="badge"],
[class*="validator-"],
[class*="model-tag"],
[class*="stat-card"] {
color: inherit !important;
}
:root {
color-scheme: dark;
--bg-base: #0c0c0b;
--bg-surface: #1a1a18;
--bg-raised: #242422;
--border: rgba(255, 255, 255, 0.1);
--border-hi: rgba(255, 255, 255, 0.22);
--text-hi: #f2ede4;
--text-mid: #9e9a93;
--text-lo: #5f5d58;
--text-primary: var(--text-hi);
--text-secondary: var(--text-mid);
--text-muted: var(--text-lo);
--teal: #1d9e75;
--teal-soft: #dff8ef;
--teal-text: #0f6e56;
--amber-soft: #faeeda;
--amber-text: #854f0b;
--overlay-bg: rgba(0, 0, 0, 0.6);
--chat-bubble-bot-bg: #242422;
--chat-bubble-user-bg: #2b2822;
--chat-pending-bg: #d9d9de;
--chat-pending-text: #111827;
--chat-copy-btn-bg: #1a1a18;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg-base: #f4f6fa;
--bg-surface: #ffffff;
--bg-raised: #edf1f6;
--border: rgba(17, 24, 39, 0.12);
--border-hi: rgba(17, 24, 39, 0.24);
--text-hi: #111827;
--text-mid: #374151;
--text-lo: #6b7280;
--text-primary: var(--text-hi);
--text-secondary: var(--text-mid);
--text-muted: var(--text-lo);
--teal: #0f766e;
--teal-soft: #dff8ef;
--teal-text: #0f5f4c;
--amber-soft: #faeeda;
--amber-text: #854f0b;
--overlay-bg: rgba(17, 24, 39, 0.28);
--chat-bubble-bot-bg: #eef3fb;
--chat-bubble-user-bg: #f9f1df;
--chat-pending-bg: #e8eef8;
--chat-pending-text: #1f2937;
--chat-copy-btn-bg: #ffffff;
}
}
* {
box-sizing: border-box;
}
.gradio-container,
.gradio-container .main,
.gradio-container .wrap,
.gradio-container .contain {
background: var(--bg-base) !important;
color: var(--text-primary) !important;
font-family: Space Mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
--body-text-color: var(--text-primary) !important;
--body-text-color-subdued: var(--text-secondary) !important;
--block-title-text-color: var(--text-secondary) !important;
--block-label-text-color: var(--text-secondary) !important;
--input-placeholder-color: var(--text-muted) !important;
--chatbot-bubble-color: var(--chat-bubble-bot-bg) !important;
--chatbot-user-bubble-color: var(--chat-bubble-user-bg) !important;
--chatbot-user-text-color: var(--text-primary) !important;
--chatbot-assistant-text-color: var(--text-primary) !important;
--chatbot-text-color: var(--text-primary) !important;
}
.top-panel h1,
.model-card h3,
.model-score span,
.evidence-copy h2,
.evidence-card strong,
.loading-title {
color: var(--text-primary) !important;
}
.top-panel p,
.step-title,
.model-card code,
.model-score small,
.model-card-footer,
.evidence-copy p,
.evidence-card span,
.evidence-card small,
.status-pill,
.schema-context,
.field-label,
.preset-label,
.message-box {
color: var(--text-secondary) !important;
}
.app-shell {
max-width: 1120px;
margin: 22px auto 44px;
padding: 0 20px;
}
.top-panel {
align-items: center;
background: var(--bg-surface);
border: 0.5px solid var(--border);
border-radius: 6px;
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 14px 16px;
}
.top-panel h1 {
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
letter-spacing: 0;
line-height: 1.25;
margin: 0 0 4px;
}
.top-panel p {
color: var(--text-secondary);
font-size: 13px;
font-weight: 400;
letter-spacing: 0;
line-height: 1.35;
margin: 0;
}
.top-badges {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.badge,
.validator-badge,
.model-tag {
border-radius: 5px;
display: inline-flex;
font-size: 11px;
font-weight: 500;
letter-spacing: 0;
line-height: 1;
padding: 6px 8px;
}
.badge-green,
.validator-ok {
background: var(--teal-soft);
color: var(--teal-text) !important;
}
.badge-cream,
.validator-warn {
background: var(--amber-soft);
color: var(--amber-text) !important;
}
.badge-light,
.validator-empty {
background: var(--bg-raised);
color: var(--text-secondary) !important;
border: 0.5px solid var(--border);
}
.step-title {
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
line-height: 1;
margin: 32px 0 12px;
text-transform: uppercase;
}
.step-title span {
display: inline-flex;
}
.model-grid,
.stats-row {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.model-grid > div,
.stats-row > div {
min-width: 0;
}
.model-card {
background: var(--bg-surface);
border: 0.5px solid var(--border);
border-radius: 6px;
min-height: 176px;
padding: 16px;
transition: border-color 160ms ease, background 160ms ease;
}
.model-card.selected {
border: 1.5px solid var(--teal);
}
.model-tag {
background: var(--amber-soft);
color: var(--amber-text) !important;
margin-bottom: 18px;
}
.model-card.selected .model-tag {
background: var(--teal-soft);
color: var(--teal-text) !important;
}
.model-card h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
letter-spacing: 0;
line-height: 1.3;
margin: 0 0 8px;
}
.model-card code {
color: var(--text-secondary);
display: block;
font-family: inherit;
font-size: 12px;
font-weight: 400;
line-height: 1.35;
margin-bottom: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-score {
align-items: baseline;
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.model-score span {
color: var(--text-primary);
font-size: 28px;
font-weight: 500;
letter-spacing: 0;
line-height: 1;
}
.model-card.selected .model-score span {
color: var(--teal) !important;
}
.model-score small,
.model-card-footer {
color: var(--text-secondary);
font-size: 13px;
font-weight: 400;
}
.model-card-footer {
display: flex;
}
.evidence-panel {
background: var(--bg-surface);
border: 0.5px solid var(--border);
border-radius: 6px;
margin-top: 12px;
padding: 16px;
}
.evidence-copy h2 {
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
line-height: 1.3;
margin: 0 0 6px;
}
.evidence-copy p {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
margin: 0;
}
.evidence-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 14px;
}
.evidence-card {
background: var(--bg-raised);
border: 0.5px solid var(--border);
border-radius: 6px;
padding: 10px;
}
.evidence-card.highlighted {
border-color: rgba(29, 158, 117, 0.5);
}
.evidence-card span,
.evidence-card small {
color: var(--text-secondary);
display: block;
font-size: 10px;
line-height: 1.25;
}
.evidence-card strong {
color: var(--text-primary);
display: block;
font-size: 20px;
font-weight: 500;
line-height: 1.1;
margin: 5px 0;
}
#load-button,
#generate-button {
width: 100% !important;
}
.gradio-container button {
border-radius: 6px !important;
font-family: Space Mono, ui-monospace, monospace !important;
font-size: 11px !important;
font-weight: 500 !important;
letter-spacing: 0 !important;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease, opacity 160ms ease !important;
}
#load-button button,
#generate-button button {
background: var(--bg-raised) !important;
border: 0.5px solid var(--border-hi) !important;
color: var(--text-primary) !important;
min-height: 40px !important;
width: 100% !important;
}
#generate-button button {
height: 42px !important;
min-height: 42px !important;
}
#load-button button:hover,
#generate-button button:hover {
background: var(--text-primary) !important;
color: var(--bg-base) !important;
}
#generate-button button:disabled {
opacity: 0.4 !important;
}
.status-pill {
align-items: center;
background: var(--bg-surface);
border: 0.5px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
display: inline-flex;
font-size: 13px;
font-weight: 400;
gap: 8px;
margin: 12px 0 0;
padding: 8px 10px;
}
.status-pill span {
background: var(--text-muted);
border-radius: 50%;
display: inline-flex;
height: 7px;
width: 7px;
}
.status-ready span,
.status-loading span {
background: var(--teal);
}
.stats-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 12px;
}
.stat-card {
background: var(--bg-surface);
border: 0.5px solid var(--border);
border-radius: 6px;
padding: 12px;
}
.stat-card strong {
color: var(--text-primary) !important;
display: block;
font-size: 15px;
font-weight: 500;
line-height: 1.2;
margin-bottom: 4px;
}
.stat-card span {
color: var(--text-secondary) !important;
display: block;
font-size: 11px;
font-weight: 400;
line-height: 1.25;
}
.query-section {
padding-bottom: 10px;
scroll-margin-top: 24px;
}
.chat-history {
background: var(--bg-surface) !important;
border: 0.5px solid var(--border) !important;
border-radius: 6px !important;
margin-bottom: 8px;
}
.chat-history .bubble-wrap,
.chat-history .message,
.chat-history .prose {
font-family: Space Mono, ui-monospace, monospace !important;
font-size: 13px !important;
line-height: 1.45 !important;
color: var(--text-primary) !important;
}
.chat-history .message,
.chat-history .bubble-wrap .message {
background: var(--chat-bubble-bot-bg) !important;
border: 0.5px solid var(--border) !important;
}
.chat-history [data-testid*="user"] .message,
.chat-history [class*="user"] .message {
background: var(--chat-bubble-user-bg) !important;
}
.chat-history .message *,
.chat-history .prose * {
color: var(--text-primary) !important;
}
.chat-history .message-row {
padding: 6px 8px !important;
}
.chat-history pre,
.chat-history code {
font-family: Space Mono, ui-monospace, monospace !important;
font-size: 12px !important;
color: var(--text-primary) !important;
}
.chat-history [class*="pending"],
.chat-history [class*="loading"],
.chat-history [class*="typing"],
.chat-history [class*="spinner"],
.chat-history [class*="generating"] {
background: var(--chat-pending-bg) !important;
border-color: var(--border) !important;
color: var(--chat-pending-text) !important;
}
.chat-history [class*="pending"] *,
.chat-history [class*="loading"] *,
.chat-history [class*="typing"] *,
.chat-history [class*="spinner"] *,
.chat-history [class*="generating"] * {
color: var(--chat-pending-text) !important;
}
.chat-history button,
.chat-history [role="button"] {
background: var(--chat-copy-btn-bg) !important;
border: 0.5px solid var(--border) !important;
color: var(--text-secondary) !important;
}
.chat-history button:hover,
.chat-history [role="button"]:hover {
border-color: var(--border-hi) !important;
color: var(--text-primary) !important;
}
.schema-context-row {
align-items: center;
gap: 6px !important;
margin: 2px 0 4px;
}
.schema-context {
align-items: center;
background: var(--schema-context-bg, var(--bg-surface));
border: 0.5px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
display: inline-flex;
gap: 8px;
min-height: 28px;
padding: 5px 10px;
max-width: 100%;
}
.schema-context.empty {
background: transparent;
border-style: dashed;
color: var(--text-muted);
min-height: 24px;
padding: 4px 8px;
}
.schema-context.empty span {
color: var(--text-muted) !important;
font-size: 11px;
flex-shrink: 0;
}
.schema-context.empty code {
color: var(--text-muted);
font-size: 10px;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schema-context span {
color: var(--teal) !important;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.schema-context code {
background: var(--code-bg);
border: 0.5px solid var(--code-border);
border-radius: 4px;
color: var(--code-text);
display: block;
font-family: var(--font-code, "JetBrains Mono", ui-monospace, monospace);
font-size: 10px;
line-height: 1.4;
overflow: hidden;
padding: 3px 6px;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.schema-strip {
align-items: center !important;
flex-wrap: wrap !important;
gap: 4px !important;
margin: 4px 0 0 !important;
padding: 0 !important;
}
.schema-strip > div {
flex: 0 0 auto !important;
min-width: 0 !important;
}
.schema-strip button {
background: var(--bg-raised) !important;
border: 0.5px solid var(--border) !important;
border-radius: 999px !important;
color: var(--text-secondary) !important;
font-size: 10px !important;
min-height: 22px !important;
padding: 0 8px !important;
}
.schema-strip button:hover {
border-color: var(--border-hi) !important;
color: var(--text-primary) !important;
}
.schema-strip-label {
color: var(--text-muted);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
}
.composer-row {
align-items: flex-end !important;
display: flex !important;
gap: 6px !important;
padding-bottom: 12px !important;
}
.composer-row > div {
display: flex !important;
flex-direction: column !important;
justify-content: flex-end !important;
}
#message-input {
flex: 1 1 auto;
}
#message-input textarea {
min-height: 42px !important;
max-height: 120px !important;
height: 42px !important;
transform: translateY(10px) !important;
resize: none !important;
overflow-y: auto !important;
}
#generate-button {
align-self: flex-end !important;
height: 42px !important;
margin-bottom: 0 !important;
margin-top: 0 !important;
min-height: 42px !important;
}
#generate-button button {
height: 42px !important;
min-height: 42px !important;
margin-bottom: 0 !important;
}
#clear-schema-button button {
background: transparent !important;
border: 0.5px solid var(--border) !important;
color: var(--text-secondary) !important;
min-height: 30px !important;
min-width: 34px !important;
width: 34px !important;
}
#clear-schema-button button:hover {
border-color: var(--border-hi) !important;
color: var(--text-primary) !important;
}
.preset-row-gradio {
gap: 6px !important;
margin-bottom: 6px;
}
.preset-row-gradio button {
background: var(--bg-raised) !important;
border: 0.5px solid var(--border) !important;
border-radius: 999px !important;
color: var(--text-secondary) !important;
font-size: 11px !important;
min-height: 26px !important;
padding: 0 10px !important;
}
/* Queue/processing indicators aligned to the dark UI. */
.gradio-container [data-testid="status-tracker"],
.gradio-container [class*="status-tracker"] {
background: var(--bg-raised) !important;
border: 0.5px solid var(--border) !important;
border-radius: 6px !important;
color: var(--text-secondary) !important;
font-size: 10px !important;
letter-spacing: 0.08em;
padding: 4px 8px !important;
text-transform: uppercase;
}
.gradio-container [data-testid="status-tracker"] * {
color: var(--text-secondary) !important;
}
.gradio-container [class*="processing"],
.gradio-container [class*="queue"],
.gradio-container [class*="progress"] {
color: var(--text-secondary) !important;
}
.gradio-container [class*="spinner"],
.gradio-container [class*="spinner"] * {
color: var(--teal) !important;
}
/* SVG loading spinners - override Gradio default fill/stroke */
.gradio-container svg[class*="spinner"],
.gradio-container svg[class*="loading"],
.gradio-container [data-testid="loader"] svg,
.gradio-container [class*="loader"] svg {
stroke: var(--teal) !important;
fill: none !important;
}
.gradio-container svg[class*="spinner"] circle,
.gradio-container svg[class*="spinner"] path,
.gradio-container [data-testid="loader"] circle,
.gradio-container [data-testid="loader"] path {
stroke: var(--teal) !important;
fill: none !important;
}
/* Hide default Gradio loading animation and replace with styled container */
.gradio-container [data-testid="loader"],
.gradio-container [class*="loader"] {
opacity: 1 !important;
background: transparent !important;
}
/* Hide skeleton/shimmer animations that don't match dark theme */
.gradio-container [class*="skeleton"],
.gradio-container [class*="shimmer"],
.gradio-container [class*="pulse"] {
background: var(--bg-raised) !important;
animation: none !important;
}
.preset-row-gradio button:hover {
border-color: var(--border-hi) !important;
color: var(--text-primary) !important;
}
.gradio-container .block,
.gradio-container .form,
.gradio-container .panel {
background: transparent !important;
border: 0 !important;
}
.gradio-container label,
.gradio-container .label-wrap span {
color: var(--text-secondary) !important;
font-family: Space Mono, ui-monospace, monospace !important;
font-size: 11px !important;
font-weight: 500 !important;
}
textarea,
input,
.cm-editor {
background: var(--bg-raised) !important;
border: 0.5px solid var(--border) !important;
border-radius: 6px !important;
color: var(--text-primary) !important;
font-family: Space Mono, ui-monospace, monospace !important;
font-size: 13px !important;
font-weight: 400 !important;
line-height: 1.45 !important;
}
textarea::placeholder,
input::placeholder {
color: var(--text-muted) !important;
}
textarea {
min-height: 132px !important;
}
.section-divider {
border-top: 0.5px solid var(--border);
margin: 28px 0 0;
}
.output-shell {
background: var(--bg-surface);
border: 0.5px solid var(--border);
border-radius: 6px;
margin-top: 12px;
overflow: hidden;
}
.output-head {
align-items: center;
background: var(--bg-surface);
border-bottom: 0.5px solid var(--border);
display: flex;
justify-content: space-between;
min-height: 34px;
padding: 0 12px;
}
.output-head span:first-child {
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.validator-detail {
color: var(--text-secondary) !important;
font-size: 11px;
margin-left: 8px;
}
.output-shell .cm-editor,
.output-shell pre,
.output-shell code {
border: 0 !important;
font-size: 12px !important;
font-weight: 400 !important;
}
.message-box {
color: var(--text-secondary);
font-size: 11px;
font-weight: 400;
min-height: 24px;
padding-top: 8px;
}
.message-error {
color: var(--amber-text);
}
.message-ok {
color: var(--teal);
}
.loading-overlay {
align-items: center;
background: var(--overlay-bg);
bottom: 0;
display: flex;
justify-content: center;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 1000;
}
.loading-overlay.hidden {
display: none;
}
.loading-card {
background: var(--bg-surface);
border: 0.5px solid var(--border-hi);
border-radius: 6px;
max-width: 480px;
padding: 18px;
width: min(90vw, 480px);
}
.loading-title {
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
line-height: 1.35;
margin-bottom: 12px;
}
.loading-line {
background: var(--bg-raised);
border-radius: 999px;
height: 8px;
overflow: hidden;
}
.loading-line span {
animation: loadingPulse 1.3s infinite ease-in-out;
background: var(--teal);
border-radius: inherit;
display: block;
height: 100%;
width: 45%;
}
.loading-card p {
color: var(--text-secondary);
font-size: 11px;
font-weight: 400;
line-height: 1.4;
margin: 12px 0 0;
}
@keyframes loadingPulse {
0% { transform: translateX(-70%); }
50% { transform: translateX(70%); }
100% { transform: translateX(220%); }
}
@media (max-width: 860px) {
.top-panel,
.model-grid,
.evidence-grid {
grid-template-columns: 1fr;
}
.stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.top-badges {
justify-content: flex-start;
}
.app-shell {
padding: 0 14px;
}
}
/* Finalized Lab Workbench direction: dark-first technical UI, source contract always visible. */
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
color-scheme: dark;
--bg-base: #101214;
--bg-surface: #1b2027;
--bg-raised: #242b35;
--border: #2d3642;
--border-hi: #3c4654;
--text-hi: #edf1f5;
--text-mid: #a8b3c1;
--text-lo: #8491a2;
--text-primary: var(--text-hi);
--text-secondary: var(--text-mid);
--text-muted: var(--text-lo);
--teal: #1d9e75;
--teal-soft: #153f34;
--teal-text: #87e8c5;
--amber-soft: #473111;
--amber-text: #f0c878;
--overlay-bg: rgba(0, 0, 0, 0.55);
--chat-bubble-bot-bg: #242b35;
--chat-bubble-user-bg: #1c252f;
--chat-pending-bg: #27313d;
--chat-pending-text: #edf1f5;
--chat-copy-btn-bg: #1b2027;
--code-bg: #0b1020;
--code-border: #26344d;
--code-text: #d7e3f3;
--schema-context-bg: #10251f;
--disabled-bg: #536171;
--font-ui: "IBM Plex Sans", ui-sans-serif, sans-serif;
--font-code: "JetBrains Mono", ui-monospace, monospace;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg-base: #edf2f7;
--bg-surface: #ffffff;
--bg-raised: #f4f7fb;
--border: #d8e0ea;
--border-hi: #c5cfdd;
--text-hi: #172033;
--text-mid: #667085;
--text-lo: #8a95a6;
--teal: #0d8b67;
--teal-soft: #dff6ec;
--teal-text: #076348;
--amber-soft: #fff3d8;
--amber-text: #7a4700;
--overlay-bg: rgba(23, 32, 51, 0.28);
--chat-bubble-bot-bg: #f4f7fb;
--chat-bubble-user-bg: #edf6ff;
--chat-pending-bg: #e8eef8;
--chat-pending-text: #172033;
--chat-copy-btn-bg: #ffffff;
--code-bg: #101828;
--code-border: #25344d;
--schema-context-bg: #f3fbf8;
--disabled-bg: #b7c3cf;
}
}
.gradio-container,
.gradio-container .main,
.gradio-container .wrap,
.gradio-container .contain {
background: var(--bg-base) !important;
font-family: var(--font-ui) !important;
}
.app-shell {
max-width: 1240px;
padding: 0 24px;
}
.header-wrapper {
border: 0 !important;
padding: 0 !important;
}
.theme-style-wrapper {
display: none !important;
}
.html-container:has(.top-panel) {
padding: 0 !important;
}
.top-panel,
.model-card,
.stats-row,
.source-panel,
.example-panel,
.evidence-panel,
.chat-history,
.output-shell {
background: var(--bg-surface) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
box-shadow: none !important;
}
.top-panel {
padding: 16px !important;
}
.top-panel h1 {
font-family: var(--font-ui) !important;
font-size: 20px !important;
font-weight: 700 !important;
letter-spacing: 0 !important;
}
.top-panel p {
font-family: var(--font-ui) !important;
font-size: 15px !important;
line-height: 1.45 !important;
max-width: 720px;
}
.badge,
.validator-badge,
.model-tag,
.status-pill,
.schema-context code,
.preset-row-gradio button,
.output-head span:first-child,
.panel-heading span,
.source-row strong,
.example-group code {
font-family: var(--font-code) !important;
}
.badge,
.validator-badge,
.model-tag {
border-radius: 6px !important;
font-size: 12px !important;
font-weight: 600 !important;
min-height: 28px !important;
padding: 7px 9px !important;
}
.workbench-grid {
align-items: start !important;
display: grid !important;
gap: 16px !important;
grid-template-columns: minmax(0, 1.6fr) 320px !important;
margin-top: 16px !important;
}
.main-stack,
.context-rail {
display: grid !important;
gap: 10px !important;
}
.context-rail {
align-content: start !important;
}
.model-side-panel {
gap: 6px !important;
}
.source-panel {
position: sticky;
top: 16px;
z-index: 2;
}
.mobile-source-panel {
display: none !important;
}
.mobile-source-wrapper {
display: none !important;
}
.panel-heading {
align-items: center;
border-bottom: 1px solid var(--border);
display: flex;
gap: 12px;
justify-content: space-between;
min-height: 36px;
padding: 0 12px;
}
.panel-heading h2 {
color: var(--text-primary) !important;
flex: 0 0 auto;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
line-height: 1;
margin: 0;
text-transform: uppercase;
white-space: nowrap;
}
.panel-heading span {
color: var(--text-secondary) !important;
font-size: 10px;
min-width: 0;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-list,
.example-list {
display: grid;
gap: 6px;
padding: 10px 12px 12px;
}
.example-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.source-row {
border-bottom: 1px solid var(--border);
display: grid;
gap: 3px;
padding-bottom: 6px;
}
.source-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.source-row strong {
color: var(--text-primary) !important;
font-size: 11px;
}
.source-row span,
.example-group span,
.evidence-copy p {
color: var(--text-secondary) !important;
font-size: 11px !important;
line-height: 1.3 !important;
}
.example-group {
display: grid;
gap: 5px;
}
.example-group code {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary) !important;
font-size: 11px;
line-height: 1.35;
padding: 7px 8px;
white-space: normal;
}
.model-card {
min-height: auto !important;
padding: 12px !important;
}
.model-card code {
font-family: var(--font-code) !important;
white-space: normal !important;
}
.model-score span {
color: var(--teal) !important;
font-size: 24px !important;
}
.stats-row {
gap: 8px !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
padding: 12px !important;
}
.stat-card {
background: var(--bg-raised) !important;
border: 1px solid var(--border) !important;
padding: 10px !important;
}
#load-button,
#generate-button {
--button-primary-background-fill: var(--teal) !important;
--button-primary-background-fill-hover: #076348 !important;
--button-primary-text-color: #ffffff !important;
background: var(--teal) !important;
background-color: var(--teal) !important;
background-image: none !important;
border: 0 !important;
color: var(--bg-surface) !important;
font-family: var(--font-ui) !important;
font-size: 13px !important;
font-weight: 700 !important;
min-height: 44px !important;
}
#generate-button {
background: var(--teal) !important;
color: #ffffff !important;
}
.composer-row #generate-button {
height: 42px !important;
margin-top: 0 !important;
min-height: 42px !important;
}
.composer-row #generate-button button {
height: 42px !important;
min-height: 42px !important;
}
#load-button:hover,
#generate-button:hover {
opacity: 0.88 !important;
}
.status-pill {
align-items: center !important;
background: var(--bg-surface) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
display: flex !important;
font-family: var(--font-ui) !important;
font-size: 13px !important;
line-height: 1.4 !important;
margin: 0 !important;
min-height: 44px !important;
padding: 10px 12px !important;
width: 100%;
}
.model-side-panel {
display: grid !important;
gap: 12px !important;
}
.query-section {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
scroll-margin-top: 24px;
}
.chat-section-heading {
border-bottom: 1px solid var(--border);
}
.chat-history {
border: 0 !important;
border-radius: 0 !important;
margin-bottom: 0 !important;
}
.chat-history .bubble-wrap,
.chat-history .message,
.chat-history .prose {
font-family: var(--font-ui) !important;
font-size: 15px !important;
line-height: 1.5 !important;
}
.chat-history .message,
.chat-history .bubble-wrap .message {
background: var(--chat-bubble-bot-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
}
.chat-history [data-testid*="user"] .message,
.chat-history [class*="user"] .message {
background: var(--chat-bubble-user-bg) !important;
}
.field-label {
border-top: 1px solid var(--border);
color: var(--text-secondary) !important;
font-family: var(--font-ui) !important;
font-size: 13px !important;
letter-spacing: 0 !important;
margin: 0 !important;
padding: 6px 16px 0;
text-transform: none !important;
}
.preset-row-gradio {
border-bottom: 1px solid var(--border);
gap: 6px !important;
margin: 0 !important;
padding: 6px 16px !important;
}
.preset-row-gradio button {
background: var(--bg-raised) !important;
border: 1px solid var(--border) !important;
color: var(--text-secondary) !important;
min-height: 28px !important;
padding: 2px 10px !important;
font-size: 12px !important;
}
.schema-pill-container {
flex: 1 1 auto !important;
min-width: 0 !important;
overflow: hidden !important;
}
.schema-context-row {
background: var(--schema-context-bg);
border-bottom: 1px solid var(--border);
margin: 0 !important;
padding: 6px 16px !important;
}
#clear-schema-button {
flex: 0 0 auto !important;
min-width: 0 !important;
}
.schema-context-row:has(.schema-context.empty) #clear-schema-button {
display: none !important;
}
#clear-schema-button button {
min-height: 28px !important;
padding: 0 12px !important;
font-size: 12px !important;
}
.composer-row {
border-top: 1px solid var(--border);
gap: 6px !important;
padding: 8px 16px 14px !important;
}
.gradio-container label,
.gradio-container .label-wrap span {
font-family: var(--font-ui) !important;
}
textarea,
input,
.cm-editor {
background: var(--bg-surface) !important;
border: 1px solid var(--border-hi) !important;
border-radius: 6px !important;
color: var(--text-primary) !important;
font-family: var(--font-ui) !important;
font-size: 14px !important;
}
.output-shell {
background: var(--code-bg) !important;
border-color: var(--code-border) !important;
margin-top: 0 !important;
}
.output-head {
background: var(--code-bg) !important;
border-bottom: 1px solid var(--code-border) !important;
min-height: 44px !important;
padding: 0 16px !important;
}
.output-head span:first-child,
.output-head .validator-detail {
color: #c3cfdd !important;
}
.output-shell .cm-editor,
.output-shell pre,
.output-shell code {
background: var(--code-bg) !important;
color: var(--code-text) !important;
font-family: var(--font-code) !important;
font-size: 13px !important;
line-height: 1.6 !important;
}
.message-box {
font-family: var(--font-ui) !important;
font-size: 13px !important;
padding-top: 0 !important;
}
.message-output-wrapper:has(.message-empty) {
display: none !important;
}
.evidence-panel {
margin-top: 0 !important;
padding: 0 !important;
}
.evidence-copy {
padding: 14px 16px 0;
}
.evidence-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
margin-top: 0 !important;
padding: 14px 16px 16px;
}
.evidence-card {
background: var(--bg-raised) !important;
border: 1px solid var(--border) !important;
}
@media (max-width: 1024px) {
.workbench-grid {
grid-template-columns: 1fr !important;
}
.source-panel {
position: static;
}
.mobile-source-panel {
display: block !important;
}
.mobile-source-wrapper {
display: block !important;
margin-top: 16px !important;
}
.context-rail .source-panel {
display: none !important;
}
}
@media (max-width: 768px) {
.app-shell {
padding: 0 16px;
}
.top-panel {
grid-template-columns: 1fr !important;
}
.top-badges {
justify-content: flex-start !important;
}
.composer-row {
align-items: stretch !important;
flex-direction: column !important;
}
#generate-button {
align-self: stretch !important;
margin-top: 0 !important;
transform: none;
}
#message-input textarea {
transform: none !important;
}
.evidence-grid {
grid-template-columns: 1fr !important;
}
}
.metadata-panel {
background: var(--bg-surface) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
box-shadow: none !important;
}
.metadata-body {
display: grid;
gap: 8px;
padding: 10px 12px 12px;
}
.metadata-source-list {
gap: 6px !important;
padding: 0 !important;
}
.metadata-source-list .source-row {
padding-bottom: 6px;
}
.metadata-source-list .source-row strong {
font-size: 11px;
}
.metadata-source-list .source-row span {
font-size: 11px !important;
line-height: 1.3 !important;
}
.stats-row-compact {
gap: 5px !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
margin-top: 0 !important;
padding: 0 !important;
}
.stats-row-compact .stat-card {
min-height: 58px;
padding: 7px !important;
}
.stats-row-compact .stat-card strong {
display: block;
font-size: 12px !important;
line-height: 1.15;
margin-bottom: 4px;
}
.stats-row-compact .stat-card span {
display: block;
font-size: 9px !important;
line-height: 1.25 !important;
}
.model-card {
min-height: auto !important;
padding: 12px !important;
}
.model-card code {
margin-bottom: 12px !important;
}
.model-score {
margin-bottom: 10px !important;
}
.model-card-footer {
margin-bottom: 10px;
}
.model-card-evidence {
border-top: 1px solid var(--border);
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
}
.model-card-evidence-heading {
color: var(--text-primary) !important;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.model-card-evidence-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.model-card-evidence-chip {
background: var(--bg-raised) !important;
border: 1px solid var(--border) !important;
border-radius: 6px;
padding: 7px;
}
.model-card-evidence-chip.highlighted {
border-color: rgba(29, 158, 117, 0.5);
}
.model-card-evidence-chip strong {
color: var(--text-primary) !important;
display: block;
font-size: 12px;
line-height: 1.15;
margin-bottom: 3px;
}
.model-card-evidence-chip small {
color: var(--text-secondary) !important;
display: block;
font-size: 9px;
line-height: 1.15;
}
.context-rail {
gap: 10px !important;
}
.model-side-panel {
gap: 6px !important;
}
.panel-heading {
min-height: 36px;
padding: 0 12px;
}
.panel-heading h2 {
font-size: 12px;
}
.panel-heading span {
font-size: 10px;
}
.example-panel .example-list {
grid-template-columns: 1fr;
padding: 10px 12px 12px;
}
.example-group code {
padding: 7px 8px;
}
/* Late alignment overrides for Gradio wrapper defaults. */
.gradio-container .header-wrapper {
padding: 0 !important;
}
.gradio-container #load-button,
.gradio-container #generate-button {
--button-primary-background-fill: var(--teal) !important;
--button-primary-background-fill-hover: #076348 !important;
--button-primary-text-color: #ffffff !important;
background: var(--teal) !important;
background-color: var(--teal) !important;
background-image: none !important;
box-shadow: none !important;
color: #ffffff !important;
}
.gradio-container #generate-button:disabled {
background: var(--disabled-bg) !important;
color: #ffffff !important;
opacity: 1 !important;
}
.gradio-container .message-output-wrapper:has(.message-empty) {
display: none !important;
}
/* Keep dark as the fallback default; only explicit light devices get the light workbench tokens. */
@media (prefers-color-scheme: light) {
:root,
.gradio-container,
* {
color-scheme: light;
--bg-base: #edf2f7;
--bg-surface: #ffffff;
--bg-raised: #f4f7fb;
--border: #d8e0ea;
--border-hi: #c5cfdd;
--text-hi: #172033;
--text-mid: #667085;
--text-lo: #8a95a6;
--text-primary: var(--text-hi);
--text-secondary: var(--text-mid);
--text-muted: var(--text-lo);
--teal: #0d8b67;
--teal-soft: #dff6ec;
--teal-text: #076348;
--amber-soft: #fff3d8;
--amber-text: #7a4700;
--overlay-bg: rgba(23, 32, 51, 0.28);
--chat-bubble-bot-bg: #f4f7fb;
--chat-bubble-user-bg: #edf6ff;
--chat-pending-bg: #e8eef8;
--chat-pending-text: #172033;
--chat-copy-btn-bg: #ffffff;
--code-bg: #101828;
--code-border: #25344d;
--code-text: #d7e3f3;
--schema-context-bg: #f3fbf8;
--disabled-bg: #b7c3cf;
}
}
"""
with gr.Blocks(title="Phi-3 Mini SQL Chatbot") as demo:
loaded_key_state = gr.State(value=None)
active_schema = gr.State(value="")
conversation_state = gr.State(value=chat_core.default_state())
last_user_message = gr.State(value="")
with gr.Column(elem_classes=["app-shell"]):
loading_overlay = gr.HTML(render_loading_overlay(visible=False))
gr.HTML(render_theme_detection_style(), elem_classes=["theme-style-wrapper"])
gr.HTML(render_header(), elem_classes=["header-wrapper"])
gr.HTML(render_source_legend("mobile-source-panel"), elem_classes=["mobile-source-wrapper"])
with gr.Row(elem_classes=["workbench-grid"]):
with gr.Column(elem_classes=["main-stack"]):
with gr.Column(elem_id="query-section", elem_classes=["query-section"]):
gr.HTML(
''
'
Chat workbench
schema + prompt'
)
chatbot_kwargs = {
"label": "",
"height": 360,
"show_label": False,
"elem_classes": ["chat-history"],
}
if "type" in inspect.signature(gr.Chatbot).parameters:
chatbot_kwargs["type"] = "messages"
chatbot = gr.Chatbot(**chatbot_kwargs)
with gr.Row(elem_classes=["schema-strip"]):
gr.HTML('Schema')
employees_preset = gr.Button("employees", size="sm")
orders_preset = gr.Button("orders", size="sm")
students_preset = gr.Button("students", size="sm")
products_preset = gr.Button("products", size="sm")
sales_preset = gr.Button("sales", size="sm")
with gr.Row(elem_classes=["schema-context-row"]):
active_schema_pill = gr.HTML(render_schema_context(""), elem_classes=["schema-pill-container"])
clear_schema_button = gr.Button("Clear", size="sm", elem_id="clear-schema-button", scale=0)
with gr.Row(elem_classes=["composer-row"]):
message_input = gr.Textbox(
label="Message",
value="",
placeholder="Ask a SQL question against the active schema",
lines=1,
max_lines=5,
interactive=True,
show_label=True,
elem_id="message-input",
)
send_button = gr.Button(
"Send",
variant="primary",
interactive=False,
elem_id="generate-button",
)
with gr.Column(elem_classes=["output-shell"]):
with gr.Row(elem_classes=["output-head"]):
gr.HTML("SQL artifact")
validator_output = gr.HTML(validate_sql(""))
sql_output = gr.Code(
label="",
language="sql",
lines=7,
interactive=False,
show_label=False,
)
error_output = gr.HTML(render_message(), elem_classes=["message-output-wrapper"])
with gr.Column(elem_classes=["context-rail"]):
fine_tuned_model_card = gr.HTML(render_model_card(FINE_TUNED_MODEL_KEY, DEFAULT_MODEL_KEY))
with gr.Column(elem_classes=["model-side-panel"]):
load_button = gr.Button("Load fine-tuned model", variant="primary", elem_id="load-button")
model_status = gr.HTML(render_status(DEFAULT_MODEL_KEY, None))
model_info = gr.HTML(model_metadata(DEFAULT_MODEL_KEY))
gr.HTML(render_example_prompts())
model_state_outputs = [
fine_tuned_model_card,
model_status,
model_info,
employees_preset,
orders_preset,
students_preset,
products_preset,
sales_preset,
clear_schema_button,
message_input,
send_button,
error_output,
]
load_button.click(
load_selected_model,
inputs=None,
outputs=[
loaded_key_state,
model_status,
loading_overlay,
model_info,
load_button,
employees_preset,
orders_preset,
students_preset,
products_preset,
sales_preset,
clear_schema_button,
message_input,
send_button,
sql_output,
validator_output,
error_output,
],
js=LOAD_SCROLL_JS,
)
schema_context_outputs = [active_schema, active_schema_pill, clear_schema_button, conversation_state]
employees_preset.click(set_preset, inputs=gr.State("employees"), outputs=schema_context_outputs)
orders_preset.click(set_preset, inputs=gr.State("orders"), outputs=schema_context_outputs)
students_preset.click(set_preset, inputs=gr.State("students"), outputs=schema_context_outputs)
products_preset.click(set_preset, inputs=gr.State("products"), outputs=schema_context_outputs)
sales_preset.click(set_preset, inputs=gr.State("sales"), outputs=schema_context_outputs)
clear_schema_button.click(clear_schema_context, outputs=schema_context_outputs)
chat_generation_outputs = [
chatbot,
message_input,
active_schema,
last_user_message,
sql_output,
validator_output,
error_output,
conversation_state,
active_schema_pill,
clear_schema_button,
]
send_button.click(
generate_response,
inputs=[message_input, chatbot, active_schema, loaded_key_state, conversation_state],
outputs=chat_generation_outputs,
)
message_input.submit(
generate_response,
inputs=[message_input, chatbot, active_schema, loaded_key_state, conversation_state],
outputs=chat_generation_outputs,
)
demo.load(
sync_on_load,
outputs=[
loaded_key_state,
model_status,
loading_overlay,
model_info,
load_button,
employees_preset,
orders_preset,
students_preset,
products_preset,
sales_preset,
clear_schema_button,
message_input,
send_button,
sql_output,
validator_output,
error_output,
],
)
queue_kwargs = {}
if "default_concurrency_limit" in inspect.signature(demo.queue).parameters:
queue_kwargs["default_concurrency_limit"] = 1
demo.queue(**queue_kwargs)
if __name__ == "__main__":
demo.launch(css=CSS)