Spaces:
Runtime error
Runtime error
feat: synchronize text-to-sql-bot codebase with Hugging Face Space repository, including Docker build configurations
6086e71 | """ | |
| Intent classifier for routing user input before SQL generation. | |
| Uses a hybrid approach: | |
| 1. ML model (sentence-transformers + LogisticRegression) when available and confident | |
| 2. Heuristic keyword matching as a fallback | |
| Clear chat messages return fast and never touch schema retrieval or SQL | |
| generation. Database-shaped requests continue through the existing SQL path. | |
| """ | |
| from dataclasses import dataclass | |
| from typing import Literal | |
| import re | |
| import structlog | |
| logger = structlog.get_logger() | |
| IntentKind = Literal["chat", "sql", "ambiguous"] | |
| RouteIntent = Literal["chat", "ambiguous", "meta_query", "data_query", "aggregation", "comparison", "explanation"] | |
| # Minimum confidence from the ML model to trust its classification | |
| _ML_CONFIDENCE_THRESHOLD = 0.70 | |
| class IntentClassification: | |
| intent: IntentKind | |
| route_intent: RouteIntent | |
| complexity: Literal["simple", "moderate", "complex"] = "simple" | |
| reason: str = "heuristic" | |
| GREETING_PATTERNS = { | |
| "hello", "hi", "hey", "good morning", "good afternoon", "good evening", | |
| "hola", "sup", "what's up", "howdy", "yo", "greetings", "namaste", "bonjour", | |
| } | |
| CHAT_EXACT = { | |
| "thanks", "thank you", "thank u", "thx", "ty", "ok", "okay", "cool", | |
| "great", "nice", "awesome", "got it", "bye", "goodbye", "see you", | |
| "see ya", "later", "yes", "no", "yep", "nope", "sure", "nah", | |
| "help", "help me", "what can you do", "who are you", "what are you", | |
| "how are you", "how do you work", "how does this work", | |
| "tell me about yourself", "what is this", "what is plainsql", | |
| } | |
| CHAT_PREFIXES = { | |
| "thanks for", "thank you for", "can you help", "please help", | |
| "i need help", "i don't understand", "what do you do", "what can i ask", | |
| "how do i use", "tell me about you", "who made you", "are you", | |
| "tell me a joke", "how is the weather", | |
| "tell me about yourself", "do you know about", | |
| } | |
| DATA_SIGNAL_PHRASES = { | |
| "how many", "how much", "group by", "order by", "by region", | |
| "by department", "by category", "by month", "by year", | |
| } | |
| # Strong signals β specific enough to indicate a real SQL query | |
| DATA_SIGNAL_WORDS_STRONG = { | |
| "show", "list", "find", "get", "select", "count", "total", "average", | |
| "avg", "sum", "top", "bottom", "highest", "lowest", "maximum", | |
| "minimum", "max", "min", "most", "least", "revenue", "sales", | |
| "salary", "salaries", "price", "stock", "spend", "employees", | |
| "employee", "customers", "customer", "products", "product", | |
| "departments", "department", "orders", "order", | |
| "where", "between", "greater", "less", "equal", "filter", "sort", | |
| "compare", "vs", "versus", "per", "each", "join", "query", "report", | |
| } | |
| # Weak signals β too generic alone, need a strong signal to confirm SQL intent | |
| DATA_SIGNAL_WORDS_WEAK = { | |
| "table", "tables", "column", "columns", "rows", "records", | |
| "all", "every", "data", "database", "info", "information", | |
| "about", "details", | |
| } | |
| DATA_SIGNAL_WORDS = DATA_SIGNAL_WORDS_STRONG | DATA_SIGNAL_WORDS_WEAK | |
| # Known database table names β used to validate ambiguous queries | |
| KNOWN_TABLES = { | |
| "employees", "employee", "departments", "department", | |
| "products", "product", "customers", "customer", | |
| "sales", "sale", "orders", "order", | |
| } | |
| META_KEYWORDS = { | |
| "what tables", "show tables", "list tables", "what columns", | |
| "describe table", "schema", "what database", | |
| } | |
| AGGREGATION_KEYWORDS = { | |
| "count", "total", "sum", "average", "avg", "minimum", "maximum", | |
| "min", "max", "group by", "by region", "by department", "by category", | |
| "by month", "by year", | |
| } | |
| COMPARISON_KEYWORDS = {"compare", " vs ", " versus ", "between"} | |
| EXPLANATION_KEYWORDS = {"explain this", "why did", "what does this query"} | |
| def classify_intent(user_query: str) -> IntentClassification: | |
| """ | |
| Classify input as chat, SQL, or ambiguous and choose the downstream graph route. | |
| Strategy: | |
| 1. Try ML model first (sentence-transformers + LogisticRegression) | |
| 2. If model unavailable or confidence < threshold, fall back to heuristic | |
| """ | |
| query = _normalize(user_query) | |
| if not query: | |
| return IntentClassification("chat", "chat", reason="empty") | |
| # ββ ML Classification (preferred) ββββββββββββββββββββ | |
| ml_result = _try_ml_classification(user_query) | |
| if ml_result is not None: | |
| return ml_result | |
| # ββ Heuristic Fallback βββββββββββββββββββββββββββββββ | |
| # Meta queries are always SQL | |
| if any(kw in query for kw in META_KEYWORDS): | |
| return IntentClassification( | |
| "sql", | |
| "meta_query", | |
| complexity="simple", | |
| reason="meta_keyword", | |
| ) | |
| has_data_signal = _has_data_signal(query) | |
| # Mixed inputs like "hi, show top 5 employees" should still be SQL. | |
| if has_data_signal: | |
| # Check if only weak signals present (e.g. "do you have info about admin") | |
| words = set(re.findall(r"[a-z_]+", query)) | |
| has_strong = bool(words.intersection(DATA_SIGNAL_WORDS_STRONG)) | |
| has_known_table = bool(words.intersection(KNOWN_TABLES)) | |
| has_phrase = any(phrase in query for phrase in DATA_SIGNAL_PHRASES) | |
| if has_strong or has_phrase or has_known_table: | |
| return IntentClassification( | |
| "sql", | |
| _classify_sql_route(query), | |
| complexity=_estimate_complexity(query), | |
| reason="data_signal", | |
| ) | |
| else: | |
| # Weak signal only β ambiguous query | |
| return IntentClassification( | |
| "ambiguous", | |
| "ambiguous", | |
| complexity="simple", | |
| reason="weak_data_signal_only", | |
| ) | |
| if _is_chat(query): | |
| return IntentClassification("chat", "chat", reason="chat_signal") | |
| if len(query.split()) <= 4: | |
| return IntentClassification("chat", "chat", reason="short_without_data_signal") | |
| # Longer queries without any data signal β likely ambiguous | |
| words = set(re.findall(r"[a-z_]+", query)) | |
| has_known_table = bool(words.intersection(KNOWN_TABLES)) | |
| if not has_known_table: | |
| return IntentClassification( | |
| "ambiguous", | |
| "ambiguous", | |
| complexity="simple", | |
| reason="no_table_reference", | |
| ) | |
| # Preserve existing text-to-SQL behavior for ambiguous analytical requests. | |
| return IntentClassification( | |
| "sql", | |
| _classify_sql_route(query), | |
| complexity=_estimate_complexity(query), | |
| reason="ambiguous_default_sql", | |
| ) | |
| def build_chat_response(user_query: str) -> str: | |
| """Return a simple conversational response without invoking SQL generation.""" | |
| query = _normalize(user_query) | |
| if any(kw in query for kw in ("what can you do", "what do you do", "how do you work", "how does this work", "help", "what can i ask", "how do i use")): | |
| return ( | |
| "I'm PlainSQL, your data assistant. Ask me a database question in plain " | |
| "English and I can generate safe read-only SQL, run it, and summarize the result. " | |
| "Try: 'Show top 5 employees by salary' or 'Total sales by region'." | |
| ) | |
| if any(kw in query for kw in ("who are you", "what are you", "tell me about you", "what is plainsql", "what is this")): | |
| return ( | |
| "I'm PlainSQL, an assistant for querying your database with natural language. " | |
| "Describe the data you want and I'll handle the SQL path." | |
| ) | |
| if any(kw in query for kw in ("thanks", "thank", "thx", "ty")): | |
| return "You're welcome. Ask me any database question when you're ready." | |
| if any(kw in query for kw in ("bye", "goodbye", "see you", "see ya", "later")): | |
| return "Goodbye. Come back anytime you want to explore your data." | |
| return ( | |
| "Hello. I can help you query your database in plain English. " | |
| "Try asking something like 'Show top 5 employees by salary'." | |
| ) | |
| def _normalize(value: str) -> str: | |
| cleaned = re.sub(r"\s+", " ", value.lower()).strip() | |
| return cleaned.strip(" \t\r\n.!?") | |
| def _has_data_signal(query: str) -> bool: | |
| if any(phrase in query for phrase in DATA_SIGNAL_PHRASES): | |
| return True | |
| words = set(re.findall(r"[a-z_]+", query)) | |
| return bool(words.intersection(DATA_SIGNAL_WORDS)) | |
| def _is_chat(query: str) -> bool: | |
| if query in CHAT_EXACT or query in GREETING_PATTERNS: | |
| return True | |
| if any(query.startswith(prefix) for prefix in CHAT_PREFIXES): | |
| return True | |
| return any(query == greeting or query.startswith(greeting + " ") for greeting in GREETING_PATTERNS) | |
| def _classify_sql_route(query: str) -> RouteIntent: | |
| if any(kw in query for kw in META_KEYWORDS): | |
| return "meta_query" | |
| if any(kw in query for kw in COMPARISON_KEYWORDS): | |
| return "comparison" | |
| if any(kw in query for kw in EXPLANATION_KEYWORDS): | |
| return "explanation" | |
| if any(kw in query for kw in AGGREGATION_KEYWORDS): | |
| return "aggregation" | |
| return "data_query" | |
| def _estimate_complexity(query: str) -> Literal["simple", "moderate", "complex"]: | |
| if any(kw in query for kw in ("subquery", "window", "rank", "running total", "percentile")): | |
| return "complex" | |
| if any(kw in query for kw in ("join", "compare", " vs ", " versus ", "group by", " by ")): | |
| return "moderate" | |
| return "simple" | |
| # ββ ML Classification Bridge ββββββββββββββββββββββββββββββββ | |
| def _try_ml_classification(user_query: str) -> IntentClassification | None: | |
| """ | |
| Attempt to classify using the ML model. | |
| Returns an IntentClassification if the model is available and confident, | |
| or None to signal the caller should use the heuristic fallback. | |
| """ | |
| import os | |
| if os.environ.get("DISABLE_ML_INTENT", "false").lower() in ("true", "1", "yes"): | |
| logger.debug("ml_classification_disabled_by_env") | |
| return None | |
| try: | |
| from app.agents.ml_classifier import get_ml_classifier | |
| classifier = get_ml_classifier() | |
| if not classifier.available: | |
| return None | |
| result = classifier.classify(user_query) | |
| if result is None: | |
| return None | |
| # Only trust the ML model when it's confident enough | |
| if result.confidence < _ML_CONFIDENCE_THRESHOLD: | |
| logger.debug( | |
| "ml_confidence_below_threshold", | |
| confidence=result.confidence, | |
| threshold=_ML_CONFIDENCE_THRESHOLD, | |
| ml_label=result.intent, | |
| fallback="heuristic", | |
| ) | |
| return None | |
| # Map ML result to the full IntentClassification with complexity | |
| query_normalized = _normalize(user_query) | |
| complexity = _estimate_complexity(query_normalized) | |
| # For SQL intents, refine the route using the heuristic sub-classifier | |
| if result.intent == "sql": | |
| route = _classify_sql_route(query_normalized) | |
| else: | |
| route = result.route_intent | |
| logger.info( | |
| "intent_classified", | |
| method="ml", | |
| intent=result.intent, | |
| route_intent=route, | |
| confidence=round(result.confidence, 3), | |
| ) | |
| return IntentClassification( | |
| intent=result.intent, | |
| route_intent=route, | |
| complexity=complexity, | |
| reason=f"ml_model(conf={result.confidence:.2f})", | |
| ) | |
| except Exception as e: | |
| logger.debug("ml_classification_unavailable", error=str(e)) | |
| return None | |