QueryLensV2b / Agent /agentic_workflow.py
Aniruddha7's picture
Fix: < > operators falsely rejected as placeholders; Groq TTS models filtered; reason prints
86b079e verified
Raw History Blame Contribute Delete
359 kB
"""
Agentic Workflow Main Module
---------------------------
This file implements the core agentic workflow for text-to-SQL, including:
- LLM orchestration (Ollama, MCP, fallback)
- Memory management and safety checks
- SQL cleaning, validation, and artifact stripping
- Agentic orchestration (plan/micro/LLM phases)
- Robust fallback and error handling
All agent logic is preserved. Comments clarify key logic and utility functions.
"""
import os
import sys
# Windows console safety: module-level prints (emoji etc.) crash the whole
# import when the console codec is cp1252/latin-1. Reconfigure to UTF-8 with
# replacement so logging can never take the app down.
try:
_enc = (getattr(sys.stdout, "encoding", "") or "").lower()
if _enc and _enc not in ("utf-8", "utf8") and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import asyncio
import re
import gc # Explicit garbage collection import
import signal # For timeouts
import threading # For thread management
import weakref # For weak references to avoid memory leaks
import time # For timing operations
from typing import Dict, Any, List, Optional
import json
import uuid
from datetime import datetime
from pathlib import Path
import psutil # Import for memory monitoring
import requests
# Database-related imports
from sqlalchemy import create_engine, text, Table, MetaData, inspect
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.exc import NoSuchTableError
try:
from .db_uri import normalize_db_uris
except ImportError: # module imported as top-level (not part of the Agent package)
from db_uri import normalize_db_uris
# LLM imports
from llama_index.core import Settings
from llama_index.llms.ollama import Ollama
from llama_index.core.agent import ReActAgent
from llama_index.core.tools import FunctionTool
from llama_index.core.llms import ChatMessage, MessageRole, LLMMetadata, CompletionResponse
try:
from llama_index.core.llms.custom import CustomLLM
except ImportError:
from llama_index.core.llms import CustomLLM
from llama_index.core.storage.chat_store import SimpleChatStore
try:
from huggingface_hub import InferenceClient
except ImportError:
InferenceClient = None
try:
from .invariants import enforce_invariants, classify_question
except (ImportError, ValueError):
try:
from Agent.invariants import enforce_invariants, classify_question
except ImportError:
def enforce_invariants(*args, **kwargs): return args[0] if args else None
def classify_question(*args, **kwargs): return "unknown"
from dotenv import load_dotenv
import os as _os
_dotenv_candidates = [
_os.path.join(_os.path.dirname(__file__), '..', '.env'), # project root .env
_os.path.join(_os.path.dirname(__file__), '.env'), # Agent/.env (most specific)
]
for _dotenv_path in _dotenv_candidates:
if _os.path.isfile(_dotenv_path):
print(f"[DEBUG] Loading .env from: {_dotenv_path}")
load_dotenv(dotenv_path=_dotenv_path, override=False) # Environment variables (Docker) now win over .env files
del _os, _dotenv_candidates, _dotenv_path
print(f"[DEBUG] Before CWD load_dotenv, DB_CONNECTION_URL={os.environ.get('DB_CONNECTION_URL')}")
load_dotenv() # also load CWD .env for any remaining vars
print(f"[DEBUG] After CWD load_dotenv, DB_CONNECTION_URL={os.environ.get('DB_CONNECTION_URL')}")
# Simple initialization
DB_CONNECTION_URL = os.environ.get("DB_CONNECTION_URL", None)
print(f"[DEBUG] Final DB_CONNECTION_URL={DB_CONNECTION_URL}")
if not DB_CONNECTION_URL:
print(
"\n" + "=" * 68 +
"\n[WARN] DB_CONNECTION_URL is not set — starting WITHOUT a database.\n"
" The app still works: connect a database at runtime from the web UI's\n"
" 'Database' panel (connection URL, or upload a .sqlite/.csv file).\n"
" PRE-SET STARTING DATABASE WHERE OTHERS COME FROM:\n"
" To autoload the built-in employee demo instead, set DB_CONNECTION_URL\n"
" in Agent/.env, e.g.\n"
" DB_CONNECTION_URL=postgresql://postgres:postgres@localhost:5432/employee\n" +
"=" * 68
)
# Import our memory management utilities (robust import with fallbacks)
import importlib
import sys
memory_manager = None
safe_check_memory = None
TimeoutManager = None
_last_import_error = None
# Try several import strategies: package-relative, package-absolute, and top-level
try_names = []
if __package__:
# e.g. when imported as Agent.agentic_workflow, __package__ == 'Agent'
try_names.append(f"{__package__}.memory_manager")
# Also try the absolute package path
try_names.append("Agent.memory_manager")
# Finally try top-level fallback
try_names.append("memory_manager")
for modname in try_names:
try:
mod = importlib.import_module(modname)
# Extract expected symbols if present
memory_manager = getattr(mod, 'memory_manager', None)
safe_check_memory = getattr(mod, 'safe_check_memory', None)
TimeoutManager = getattr(mod, 'TimeoutManager', None)
if memory_manager is not None and safe_check_memory is not None and TimeoutManager is not None:
break
except Exception as e:
_last_import_error = e
if memory_manager is None or safe_check_memory is None or TimeoutManager is None:
# Provide a clear diagnostic so startup logs show why import failed
print("Failed to import Agent.memory_manager via tried paths:", try_names)
print("cwd=", __import__('os').getcwd())
print("sys.path[0]=", sys.path[0] if len(sys.path) > 0 else None)
if _last_import_error:
# Re-raise the last error to preserve traceback
raise _last_import_error
else:
raise ImportError("Could not locate memory_manager module; ensure Agent/memory_manager.py exists and package imports are correct")
# Ollama configuration
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
# --- Employee Database Schema for Prompt Injection ---
EMPLOYEE_DB_SCHEMA = (
"Database schema:\n"
"- employee(emp_no, birth_date, first_name, last_name, gender, hire_date)\n"
"- department(dept_no, dept_name)\n"
"- dept_emp(emp_no, dept_no, from_date, to_date)\n"
"- dept_manager(emp_no, dept_no, from_date, to_date)\n"
"- salary(emp_no, salary, from_date, to_date) # column name is 'salary' in this dataset\n"
"- title(emp_no, title, from_date, to_date)\n"
"- views: current_dept_emp (current department per employee), dept_emp_latest_date\n"
"\n"
"CRITICAL Rules:\n"
"- The 'department' table does NOT have a 'to_date' column. Never filter department by to_date.\n"
"- To get CURRENT records (managers, salaries, department assignments), ALWAYS filter by `to_date = '9999-01-01'` in the 'salary', 'dept_emp', 'dept_manager', or 'title' tables.\n"
"- To get MANAGER NAMES, you MUST join `dept_manager` (dm) with `employee` (e) on `emp_no`.\n"
"- To get DEPARTMENT NAMES for employees, join `dept_emp` (de) with `department` (d) on `dept_no`.\n"
"- The 'employee' table does NOT have a 'dept_no' column. To find an employee's department name, you MUST join 'dept_emp' (de) with 'department' (d) on 'dept_no'.\n"
"- Salaries are in table `salary` (column `salary`). Always use `s.to_date = '9999-01-01'` for current pay.\n"
)
# Allow a dedicated finetuned model name (merged LoRA) via FINETUNED_MODEL_NAME env.
# Resolution order:
# 1. FINETUNED_MODEL_NAME (e.g., gemma1b-text2sql)
# 2. OLLAMA_MODEL_NAME (defaults to base gemma3:1b)
# 3. Hard fallback 'gemma3:1b'
FINETUNED_MODEL_NAME = os.environ.get("FINETUNED_MODEL_NAME")
BASE_MODEL_FALLBACK = "qwen2.5-coder:1.5b" #"gemma3:1b-it-qat"
OLLAMA_MODEL_NAME = FINETUNED_MODEL_NAME or os.environ.get("OLLAMA_MODEL_NAME", BASE_MODEL_FALLBACK)
FALLBACK_MODEL_NAME = BASE_MODEL_FALLBACK # Keep fallback stable
if FINETUNED_MODEL_NAME:
print(f"🦙 Using finetuned Ollama model: {OLLAMA_MODEL_NAME} (base fallback: {FALLBACK_MODEL_NAME})")
else:
print(f"🦙 Using base Ollama model: {OLLAMA_MODEL_NAME} (fallback: {FALLBACK_MODEL_NAME})")
# Schema profile global (populated at DB init)
try:
from . import schema_profile as _schema_profile_mod
except Exception:
import Agent.schema_profile as _schema_profile_mod
ACTIVE_SCHEMA_PROFILE = _schema_profile_mod.SchemaProfile(
name="uninitialized",
db_url=None,
is_demo=False,
)
def _schema_notes_block() -> str:
"""Owner-supplied domain hints for the active database, if any.
Structure comes from live introspection, but semantics (what 'sold_date
IS NULL' means, price distinctions, aliases, revenue rules) only the
database owner knows. These notes are labeled authoritative so the LLM
treats them above its own guesses.
"""
try:
notes = (getattr(ACTIVE_SCHEMA_PROFILE, "notes", "") or "").strip()
except Exception:
notes = ""
if not notes:
return ""
return (
"SCHEMA NOTES from the database owner (authoritative for column meanings "
"and business rules):\n" + notes
)
def _dynamic_schema_block(schema_text: str) -> str:
"""Schema section for LLM prompts.
Employee demo profile -> curated EMPLOYEE_DB_SCHEMA block (battle-tested).
Any other live profile -> aggregates the live introspected schema instead,
so no hardcoded table assumptions leak into user databases.
Falls back to the per-relevant-table schema snippets when the profile is
not yet populated. Owner schema notes are appended in every branch.
"""
profile = ACTIVE_SCHEMA_PROFILE
_notes = _schema_notes_block()
try:
if profile is not None and profile.is_employee_db and profile.tables:
return EMPLOYEE_DB_SCHEMA + (("\n\n" + _notes) if _notes else "")
if profile is not None and profile.tables:
if schema_text and schema_text != "(schema unavailable)":
return (
f"Database schema (live):\n{profile.build_digest(max_tables=15)}\n"
f"Focus tables (columns):\n{schema_text}"
) + (("\n\n" + _notes) if _notes else "")
return (
f"Database schema (live):\n{profile.build_digest(max_tables=15)}"
) + (("\n\n" + _notes) if _notes else "")
except Exception as _e:
print(f"[DYNAMIC-SCHEMA] profile digest unavailable: {_e}")
base = f"Database schema:\n{schema_text}" if schema_text else EMPLOYEE_DB_SCHEMA
return base + (("\n\n" + _notes) if _notes else "")
def _current_row_rules(default_rule: str) -> str:
"""Rules text for current-row filtering. The to_date='9999-01-01' pattern is
employee-schema specific; other schemas get a generic recency hint."""
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
return default_rule
return (
"If a table has a date column indicating record validity/ends, filter to the "
"current/active rows when the question implies 'current' or 'now'. "
"Otherwise do NOT invent date filters."
)
# OCR Q&A model configuration
# PRIMARY: Granite Vision VLM (GRANITE_MODEL_PATH env) — used when image file is available
# FALLBACK: lightweight Ollama text model — used for old docs without a saved image
GRANITE_MODEL_PATH = (
os.environ.get("GRANITE_MODEL_PATH") or
os.environ.get("GRANITE_MODEL") or
"qwen2-vl:2b" # local Ollama fallback (dev only)
)
if os.environ.get("USE_HF_CLOUD", "0") in ("1", "true", "True"):
print(f"[GRANITE_VQA] Target Model: {os.environ.get('GROQ_VISION_MODEL', 'qwen/qwen3.8-27b')} (via Groq Vision)")
else:
print(f"[GRANITE_VQA] Target Model: {GRANITE_MODEL_PATH} (via Ollama)")
# Text-only fallback: Ollama model used when no image file exists for the doc.
# NOT the same as GRANITE_MODEL_PATH — this runs via Ollama REST API, not HuggingFace.
OLLAMA_OCR_QA_MODEL = os.environ.get("OLLAMA_OCR_QA_MODEL", "gemma3:1b-it-qat")
print(f"[OCR_QA] Text fallback model (Ollama): {OLLAMA_OCR_QA_MODEL}")
OCR_QA_TIMEOUT = float(os.environ.get("OCR_QA_TIMEOUT", "45"))
print(f"[OCR_QA] Text fallback timeout={OCR_QA_TIMEOUT}s")
def check_memory_safety():
"""Check if there's enough memory to safely process a request"""
# Use our improved memory checking system
return safe_check_memory()
# Global variables for database connection
available_tables = []
inspector = None
sql_database = None
async_engine = None
async_session = None
# True once initialize_database() finished successfully at least once.
# Databases without an async SQLAlchemy dialect (mssql/oracle) keep
# async_session None permanently, so "connected" must not be inferred from it.
_DB_CONNECTED = False
# Initialize chat store directory and files
chat_store_dir = Path('chat_store')
chat_store_dir.mkdir(parents=True, exist_ok=True)
# Define chat store file paths
public_store_path = chat_store_dir/'chat_store_public.json'
private_store_path = chat_store_dir/'chat_store_private.json'
# Initialize empty chat store structure with conversation key
initial_store = {"conversation": []}
# Create JSON files if they don't exist
if not public_store_path.exists():
with open(public_store_path, 'w') as f:
json.dump(initial_store, f, indent=2)
if not private_store_path.exists():
with open(private_store_path, 'w') as f:
json.dump(initial_store, f, indent=2)
# Initialize chat stores — self-healing loader.
# Abrupt process exits (OOM kills, Space runtime restarts) can truncate a
# store file mid-write; a 0-byte or invalid JSON file must not brick the
# worker at import time. Repair to the known empty structure and retry.
def _load_or_repair_chat_store(path: Path) -> SimpleChatStore:
try:
return SimpleChatStore().from_persist_path(str(path))
except (json.JSONDecodeError, ValueError) as store_err:
print(
f"[CHAT-STORE] '{path.name}' unreadable "
f"({store_err.__class__.__name__}) — resetting it"
)
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(initial_store, f, indent=2)
except Exception as repair_err:
print(f"[CHAT-STORE] reset failed: {repair_err}")
return SimpleChatStore()
try:
return SimpleChatStore().from_persist_path(str(path))
except Exception as reload_err:
print(f"[CHAT-STORE] reload after reset failed: {reload_err}")
return SimpleChatStore()
print("Debug: Initializing chat stores")
chat_store = SimpleChatStore()
chat_store_public = _load_or_repair_chat_store(public_store_path)
chat_store_private = _load_or_repair_chat_store(private_store_path)
print("Debug: Chat stores initialized")
# Utility: robustly extract clean SQL from LLM/artifact wrappers
def extract_clean_sql(raw: str) -> str:
"""Remove <<<SQL_START>>>, <<<SQL_END>>>, code fences, and similar wrappers from SQL text."""
import re
if not raw or not isinstance(raw, str):
return raw
s = raw.strip()
# Remove <<<SQL_START>>> ... <<<SQL_END>>> blocks
s = re.sub(r"<<<SQL_START>>>[\s\S]*?<<<SQL_END>>>", lambda m: m.group(0).replace('<<<SQL_START>>>','').replace('<<<SQL_END>>>','').strip(), s, flags=re.IGNORECASE)
# Remove <<<SQL_REVISED_START>>> ... <<<SQL_REVISED_END>>> blocks
s = re.sub(r"<<<SQL_REVISED_START>>>[\s\S]*?<<<SQL_REVISED_END>>>", lambda m: m.group(0).replace('<<<SQL_REVISED_START>>>','').replace('<<<SQL_REVISED_END>>>','').strip(), s, flags=re.IGNORECASE)
# Remove code fences (```sql ... ``` or ```)
s = re.sub(r"```(?:sql)?([\s\S]*?)```", lambda m: m.group(1).strip(), s, flags=re.IGNORECASE)
# Remove any remaining <<<...>>> wrappers
s = re.sub(r"<<<[A-Z_]+>>>", "", s)
# Extract only the SQL portion before any explanations.
# Handles both plain "Explanation:" and markdown "### Explanation:" headers.
_explanation_pattern = r';\s*\n[\s\S]*?(?:#{1,3}\s*[Ee]xplanation|[Ee]xplanation)\s*:'
if re.search(_explanation_pattern, s):
s = re.split(_explanation_pattern, s)[0].rstrip() + ';'
elif re.search(r';\s*\n\s*[Ee]xplanation:', s):
s = re.split(r';\s*\n\s*[Ee]xplanation:', s)[0] + ';'
# Remove leading/trailing whitespace and repeated newlines
s = s.strip()
# Remove any leading/trailing code fence lines
s = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", s, flags=re.MULTILINE)
# Remove any trailing explanation text that might still be there
lines = s.split('\n')
sql_lines = []
for line in lines:
line = line.strip()
# Stop at explanation markers — including markdown ### headers
if re.match(r'^#{1,3}\s*(explanation|note|this query)', line, re.IGNORECASE):
break
if re.match(r'^(explanation|note|this query):', line, re.IGNORECASE):
break
if line.lower().startswith('explanation:'):
break
if re.match(r'^\d+\.\s+', line): # Numbered explanation steps
break
sql_lines.append(line)
s = '\n'.join(sql_lines).strip()
# NEW: Safety check for multiple commands.
# If the LLM outputted multiple queries (separated by semicolons),
# we only want the LAST valid one (which is usually the most refined).
if s.count(';') > 1:
# Split by semicolon, keep only those that look like queries
statements = [stmt.strip() for stmt in s.split(';') if 'select' in stmt.lower()]
if statements:
# Use the last one as it's typically the final refinement
s = statements[-1] + ';'
print(f"[CLEAN_SQL] Multiple queries detected. Selected last valid statement.")
return s.strip()
# -----------------------------
# OCR Q&A dedicated helpers
# -----------------------------
def _get_ocr_text_by_doc_id(doc_id: str) -> str:
try:
p = Path('chat_store') / 'docs' / f"{doc_id}.txt"
if not p.exists():
return ""
return p.read_text(encoding='utf-8')
except Exception:
return ""
def _get_image_path_by_doc_id(doc_id: str) -> str:
"""Return the saved image file path for doc_id, or empty string if not found."""
try:
p = Path('chat_store') / 'docs' / f"{doc_id}.img_path"
if not p.exists():
return ""
return p.read_text(encoding='utf-8').strip()
except Exception:
return ""
def _granite_vision_qa(image_path: str, question: str) -> str:
"""Answer a question about an image using IBM Granite Vision 3.1-2b (direct VLM Q&A).
Loads the model from local HF cache with aggressive quantization to stay within
the RAM budget (4-bit → 8-bit → fp16 fallback chain). The model is explicitly
deleted after generation to free memory for the SQL model to reload.
"""
import gc
try:
import torch
from transformers import AutoProcessor, AutoModelForVision2Seq
from PIL import Image as PILImage
except ImportError as e:
return f"[ERROR] Granite Vision dependencies not installed: {e}"
model_path = GRANITE_MODEL_PATH
cache_dir = os.environ.get("HF_HOME") or r"D:\hf_cache"
offload_dir = os.path.join(cache_dir, "offload")
os.makedirs(offload_dir, exist_ok=True)
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"[GRANITE_VQA] Loading {model_path} on {device} from {cache_dir}")
processor = vlm_model = None
try:
processor = AutoProcessor.from_pretrained(model_path, local_files_only=True, cache_dir=cache_dir)
# Try 4-bit → 8-bit → plain fp16 to fit within RAM budget
load_kwargs = dict(local_files_only=True, cache_dir=cache_dir, low_cpu_mem_usage=True)
loaded = False
for attempt, extra in enumerate([
{"load_in_4bit": True},
{"load_in_8bit": True},
{"device_map": "auto", "offload_folder": offload_dir},
]):
try:
vlm_model = AutoModelForVision2Seq.from_pretrained(model_path, **load_kwargs, **extra)
print(f"[GRANITE_VQA] Loaded (attempt {attempt+1}: {list(extra.keys())[0]})")
loaded = True
break
except Exception as le:
print(f"[GRANITE_VQA] Load attempt {attempt+1} failed: {le}")
if not loaded:
return "[ERROR] Granite Vision could not be loaded — all quantisation attempts failed."
img = PILImage.open(image_path).convert("RGB")
conversation = [
{
"role": "user",
"content": [
{"type": "image", "url": image_path},
{"type": "text", "text": (
f"{question}\n\n"
"Answer concisely using only information visible in the image. "
"If you see a table, read the correct row and column carefully."
)},
],
}
]
inputs = processor.apply_chat_template(
conversation,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
)
# Move inputs to model device
try:
model_device = next(vlm_model.parameters()).device
inputs = {k: v.to(model_device) if hasattr(v, 'to') else v for k, v in inputs.items()}
except Exception:
pass
with torch.no_grad():
output_ids = vlm_model.generate(**inputs, max_new_tokens=256, do_sample=False)
answer = processor.decode(output_ids[0], skip_special_tokens=True)
# Strip echoed prompt (Granite echoes conversation before the answer)
if "ASSISTANT" in answer.upper():
answer = answer.split("ASSISTANT")[-1].strip(" :").strip()
elif question.lower()[:20] in answer.lower():
idx = answer.lower().rfind(question.lower()[:20])
answer = answer[idx + len(question):].strip(" :").strip() if idx != -1 else answer
print(f"[GRANITE_VQA] Answer (chars={len(answer)}): {answer[:200]}")
return answer.strip() or "[ERROR] Granite Vision returned an empty response."
except Exception as e:
print(f"[GRANITE_VQA] Generation failed: {e}")
return f"[ERROR] Granite Vision Q&A failed: {e}"
finally:
# Aggressively free VLM memory so SQL model can reload
try:
del vlm_model, processor
except Exception:
pass
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
print("[GRANITE_VQA] Model unloaded; RAM released.")
def _structure_invoice_text(raw: str) -> str:
"""Convert flat Tesseract OCR output into a structured KEY: VALUE format.
Tesseract merges field labels and values on the same line and squashes
table rows together, causing a 1B LLM to confuse GST numbers with phone
numbers, amounts with dates, etc. This function:
- Recognises common invoice field patterns via regex
- Emits explicit 'FIELD_NAME: value' lines for the LLM
- Preserves the rest of the text verbatim
So the model receives unambiguous context like:
GSTIN (Seller): 24HDE7487RE5RT4
Customer Phone: 9372346666
Customer GSTIN: 07AOLCC1206D126
"""
import re
lines = raw.replace('\r\n', '\n').replace('\r', '\n').split('\n')
structured = []
seen_keys = set()
def emit(key: str, val: str):
tag = f"{key}: {val.strip()}"
if tag not in seen_keys:
seen_keys.add(tag)
structured.append(tag)
# Regex patterns for common Indian invoice fields
patterns = [
# GSTIN – 15-char alphanumeric starting with 2 digits
(r'GSTIN\s*(?:No\.?|:)?\s*([0-9]{2}[A-Z0-9]{13})', 'GSTIN'),
# Phone numbers (10-digit Indian mobile or landline with STD)
(r'(?:PHONE|TEL|MOB(?:ILE)?)\s*[:\.]?\s*([0-9\-\+\s]{8,15})', 'Phone'),
# Invoice number
(r'Invoice\s*No\.?\s*[:\.]?\s*([A-Z0-9/\-]+)', 'Invoice Number'),
# Challan number
(r'Challan\s*No\.?\s*[:\.]?\s*([0-9]+)', 'Challan Number'),
# Invoice / Challan dates
(r'Invoice\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Invoice Date'),
(r'Challan\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Challan Date'),
(r'Due\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Due Date'),
(r'Delivery\s*Date\s*[:\.]?\s*(\d{1,2}[-/]\w{3,9}[-/]\d{2,4})', 'Delivery Date'),
# E-Way number
(r'E-?Way\s*No\.?\s*[:\.]?\s*([A-Z0-9]+)', 'E-Way Number'),
# LR / PO numbers
(r'L\.?R\.?\s*No\.?\s*[:\.]?\s*([0-9]+)', 'LR Number'),
(r'P\.?O\.?\s*No\.?\s*[:\.]?\s*([0-9]+)', 'PO Number'),
# PAN
(r'PAN\s*[:\.]?\s*([A-Z]{5}[0-9]{4}[A-Z])', 'PAN'),
# Bank details
(r'Bank\s*(?:Account|A/?C)\s*(?:No\.?|Number)?\s*[:\.]?\s*([0-9]+)', 'Bank Account Number'),
(r'IFSC\s*[:\.]?\s*([A-Z]{4}0[A-Z0-9]{6})', 'Bank IFSC'),
# Totals
(r'Total\s+(?:Amount)?\s*After\s*Tax\s*[:\.]?\s*[\u20B9Rs\.]*\s*([\d,\.]+)', 'Total Amount After Tax'),
(r'Taxable\s+(?:Amount|Value)\s*[:\.]?\s*([\d,\.]+)', 'Taxable Amount'),
(r'Add\s*[:\.]?\s*IGST\s*[:\.]?\s*([\d,\.]+)', 'IGST Amount'),
]
# Scan all lines for known field patterns
full_text = '\n'.join(lines)
# Track which GSTINs we've seen so we can label seller vs customer
gstin_count = 0
for line in lines:
for pat, label in patterns:
m = re.search(pat, line, re.IGNORECASE)
if m:
val = m.group(1).strip()
if label == 'GSTIN':
gstin_count += 1
lbl = 'Seller GSTIN' if gstin_count == 1 else 'Customer GSTIN'
emit(lbl, val)
elif label == 'Phone':
emit('Customer Phone', val)
else:
emit(label, val)
# Extract company name (typically first non-empty ALL-CAPS-ish line)
for line in lines:
stripped = line.strip()
if len(stripped) > 4 and stripped.isupper() and not re.search(r'\d{5,}', stripped):
emit('Company Name', stripped)
break
# Extract customer name after 'M/S' or 'Bill to'
for i, line in enumerate(lines):
if re.search(r'^(M/?S|Bill\s*to)\b', line.strip(), re.IGNORECASE):
# Customer name is likely the next non-empty line or on same line after M/S
rest = re.sub(r'^(M/?S|Bill\s*to)\s*', '', line.strip(), flags=re.IGNORECASE).strip()
if rest:
emit('Customer Name', rest)
elif i + 1 < len(lines) and lines[i+1].strip():
emit('Customer Name', lines[i+1].strip())
break
# --- Product line items ---
# Scan for rows like: "2 | Stanley Hammer 8295 1.00 PCS 568.00 568.00 9.00 51.12 619.12"
# OCR often squashes all rows onto one long line; use the item-number+pipe as separator.
item_pat = re.compile(
r'\b(\d{1,3})\s*[|Ill1]\s*' # item number + pipe (I/l/1 are common OCR artefacts for |)
r'([A-Za-z][A-Za-z\s\-]{2,40}?)\s+' # product name
r'(\d{4,8})\s+' # HSN / SAC code
r'(\d+(?:\.\d+)?)\s+' # quantity
r'(?:PCS|NOS|KG|LT|MT|SET|BOX|PC|NO|EA)\.?\s+' # unit (consumed but not captured)
r'([\d,]+(?:\.\d+)?)' # rate (first numeric after unit)
r'((?:\s+[\d,]+(?:\.\d+)?){1,4})', # remaining numbers (taxable value, GST, total etc.)
re.IGNORECASE
)
for m in item_pat.finditer(full_text):
item_no = m.group(1)
name = m.group(2).strip()
hsn = m.group(3)
qty = m.group(4)
rate = m.group(5)
rest_nums = re.findall(r'[\d,]+(?:\.\d+)?', m.group(6) or '')
# rest_nums order depends on invoice: [taxable_value, gst%, gst_amount, total]
taxable = rest_nums[0] if len(rest_nums) > 0 else rate
total = rest_nums[-1] if len(rest_nums) > 1 else taxable
label = f"Product {item_no} - {name}"
emit(label, f"HSN={hsn}, Qty={qty}, Rate={rate}, Taxable Value={taxable}, Total={total}")
# Build the final structured block
header = "=== STRUCTURED DOCUMENT FIELDS ==="
body = "=== RAW OCR TEXT ===\n" + raw.strip()
if structured:
return header + '\n' + '\n'.join(structured) + '\n\n' + body
return body
def _ollama_unload_model(model: str) -> bool:
"""Ask Ollama to immediately unload a model from RAM (keep_alive=0).
Uses /api/generate with a minimal prompt and keep_alive=0 to signal Ollama
to release the model. Works for models loaded by any client (LlamaIndex etc).
Returns True if the request succeeded, False otherwise (non-fatal).
"""
base = OLLAMA_BASE_URL.rstrip('/')
try:
# A single-space prompt ensures Ollama actually processes the request
# (empty string may be skipped); keep_alive=0 causes immediate unload.
resp = requests.post(
f"{base}/api/generate",
json={"model": model, "prompt": " ", "keep_alive": 0, "stream": False},
timeout=90.0,
)
print(f"[OCR_QA] Unloaded model '{model}' from Ollama RAM (status={resp.status_code})")
return resp.status_code < 300
except Exception as e:
print(f"[OCR_QA][WARN] Failed to unload '{model}': {e}")
return False
def _ollama_generate_text(model: str, prompt: str, timeout: float) -> str:
"""Minimal REST call to Ollama generate API to avoid llama_index routing.
Uses OLLAMA_BASE_URL env and returns the concatenated response text.
num_ctx is chosen adaptively based on available RAM so we don't cause
excessive paging on memory-constrained machines.
"""
url = f"{OLLAMA_BASE_URL.rstrip('/')}/api/generate"
# Pick num_ctx based on current available memory
# ≥800 MB free -> 2048 (full invoice fits comfortably)
# 400–800 MB -> 1024 (still fits most invoices)
# <400 MB -> 512 (minimal; avoid paging)
try:
stats = memory_manager.get_memory_stats()
avail_mb = stats.get("available_mb", 999.0)
except Exception:
avail_mb = 999.0
if avail_mb >= 800:
num_ctx = 1024
num_predict = 200
elif avail_mb >= 400:
num_ctx = 512
num_predict = 150
else:
num_ctx = 256
num_predict = 100
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"keep_alive": 0, # unload immediately after response — prevents gemma3 squatting in RAM
"options": {
"temperature": 0.2,
"num_ctx": num_ctx,
"num_predict": num_predict,
"num_gpu": -1, # keep on CPU same as Qwen; prevents GPU/CPU swap latency
"num_thread": 2, # 2 threads is sufficient for 1B model on CPU
}
}
try:
# Debug request meta (do not print full prompt to avoid huge logs here)
try:
print(f"[DEBUG][OLLAMA_REQUEST] url={url} model={model} prompt_chars={len(prompt)} timeout={timeout} num_ctx={num_ctx} avail_mb={avail_mb:.0f}")
except Exception:
pass
r = requests.post(url, json=payload, timeout=timeout)
r.raise_for_status()
data = r.json()
# Log only response length, not full body (can be very large)
resp_text = data.get('response') or data.get('text') or str(data)
print(f"[DEBUG][OLLAMA_RESPONSE] model={model} response_chars={len(resp_text)}")
# Non-stream returns {response: "..."}
txt = resp_text
return txt
except Exception as e:
print(f"[ERROR][OLLAMA_API] Ollama generate failed: {e}")
return f"Error: Ollama generate failed: {e}"
def ocr_agent_qa(question: str, doc_id: str, *, model: str | None = None, timeout: float | None = None) -> str:
"""Answer a question about a previously uploaded document.
PRIMARY: If the original image is available (.img_path), routes to Granite Vision
via MCP (granite_vision.qa tool) for direct visual Q&A.
FALLBACK: If only OCR text is available (.txt), builds a grounded prompt and
calls OCR_TEXT_FALLBACK_MODEL (gemma3) via Ollama.
"""
text = _get_ocr_text_by_doc_id(doc_id)
# PRIMARY PATH: Granite Vision direct visual Q&A
image_path = _get_image_path_by_doc_id(doc_id)
# ── DIAGNOSTIC (temporary) ──────────────────────────────────────────
_img_path_file = Path('chat_store') / 'docs' / f"{doc_id}.img_path"
print(f"[OCR_QA_DIAG] doc_id = {doc_id}")
print(f"[OCR_QA_DIAG] .img_path file = {_img_path_file.resolve()} | exists={_img_path_file.exists()}")
print(f"[OCR_QA_DIAG] image_path = {image_path!r}")
print(f"[OCR_QA_DIAG] isfile(image) = {os.path.isfile(image_path) if image_path else 'N/A (empty)'}")
# ────────────────────────────────────────────────────────────────────
if image_path and os.path.isfile(image_path):
print(f"[OCR_QA] Using Granite Vision for doc_id={doc_id}, image={image_path}")
# Only perform RAM swapping/unloading if we are NOT in cloud mode
if not (os.environ.get("USE_HF_CLOUD", "0") in ("1", "true", "True")):
print(f"[OCR_QA] Swapping: unloading SQL model '{OLLAMA_MODEL_NAME}' to free RAM for Granite Vision")
_ollama_unload_model(OLLAMA_MODEL_NAME)
time.sleep(1.0) # let OS reclaim pages
else:
print(f"[OCR_QA] 🌩️ CLOUD MODE: Skipping local RAM swap.")
# Route through MCP so Granite Vision runs as a proper registered agent tool
try:
from .mcp_client import mcp_call_tool
except ImportError:
from Agent.mcp_client import mcp_call_tool
try:
ctx = {
"tool_call": {
"name": "granite_vision.qa",
"args": {
"image_path": image_path,
"question": question,
"model_path": GRANITE_MODEL_PATH,
},
}
}
resp = mcp_call_tool(prompt="run granite vision qa", timeout=300.0, context=ctx)
tool_result = resp.get("tool_result") if isinstance(resp, dict) else None
if isinstance(tool_result, dict):
return tool_result.get("answer", "[ERROR] No answer returned from granite_vision.qa")
return str(tool_result) if tool_result else "[ERROR] granite_vision.qa returned no result"
except Exception as e:
print(f"[OCR_QA] MCP granite_vision.qa failed: {e}")
return f"[ERROR] Vision Q&A timed out or failed. Please ensure Docker has enough RAM (6GB+) and try again. ({e})"
# No image available — this doc was uploaded before the Granite Vision upgrade.
# Returning a clear message instead of a wrong answer from the old gemma3 pipeline.
print(f"[OCR_QA] No image found for doc_id={doc_id} — document predates Granite Vision upgrade")
return (
"⚠️ This document was uploaded before the Granite Vision upgrade. "
"Please click 'Upload Image' to re-upload the image — "
"Granite Vision will then answer your question directly from the image."
)
# ---------------------------------------------------------------------------
# Conversation Memory Index (improves recall vs naive substring search)
# ---------------------------------------------------------------------------
question_memory_index = {} # normalized_question -> last answer
def normalize_question(q: str) -> str:
"""Create a stable normalization of a user question for indexing.
Steps: lowercase, remove punctuation except underscores, collapse whitespace.
"""
import re
q = q.lower()
# remove any document snippet brackets if present (they make later repeats unmatchable)
q = re.sub(r"\[document .*?excerpt end\]", "", q, flags=re.IGNORECASE|re.DOTALL)
# strip bracketed metadata sections
q = re.sub(r"\[[^\]]+\]", "", q)
# punctuation -> space (keep underscores)
q = re.sub(r"[^a-z0-9_]+", " ", q)
q = re.sub(r"\s+", " ", q).strip()
return q
def rebuild_memory_index():
"""Rebuild in-memory index from persisted chat history."""
question_memory_index.clear()
msgs = chat_store_private.get_messages(key="conversation")
last_user = None
for m in msgs:
if m.role == MessageRole.USER:
last_user = m.content
elif m.role == MessageRole.ASSISTANT and last_user:
key = normalize_question(last_user)
question_memory_index[key] = m.content
last_user = None
print(f"[MEM-INDEX] Rebuilt memory index with {len(question_memory_index)} entries")
# Build initial index
try:
rebuild_memory_index()
except Exception as e:
print(f"[MEM-INDEX][WARN] Failed to build initial index: {e}")
# ---------------------------------------------------------------------------
# Feature flags / global controls
# ---------------------------------------------------------------------------
# Disable ALL template-based fallback logic to enforce pure LLM agentic path
DISABLE_TEMPLATE_FALLBACK = True
# Feature toggles
ENABLE_INVARIANTS = True # can be turned off for debugging
USE_SIMPLE_LLM_CALL = True # bypass TimeoutManager for main generation
# Configurable generation timeouts (seconds); can override via env vars
FIRST_ATTEMPT_TIMEOUT = float(os.environ.get("FIRST_ATTEMPT_TIMEOUT", 180)) # Increased to 180s for initial attempt
SECOND_ATTEMPT_TIMEOUT = float(os.environ.get("SECOND_ATTEMPT_TIMEOUT", 150)) # Increased to 150s for retry
THIRD_ATTEMPT_TIMEOUT = float(os.environ.get("THIRD_ATTEMPT_TIMEOUT", 120)) # Increased to 120s for final attempt
DEFAULT_LLM_REQUEST_TIMEOUT = float(os.environ.get("LLM_REQUEST_TIMEOUT", 150))
# Micro-verification timeouts
MICRO_VERIFY_MCP_TIMEOUT = float(os.environ.get("MICRO_VERIFY_MCP_TIMEOUT", 30))
MICRO_VERIFY_LLM_TIMEOUT = float(os.environ.get("MICRO_VERIFY_LLM_TIMEOUT", 60))
# Debug: Print timeout values to verify they're correct
print(f"[TIMEOUT_DEBUG] FIRST_ATTEMPT_TIMEOUT={FIRST_ATTEMPT_TIMEOUT}s")
print(f"[TIMEOUT_DEBUG] SECOND_ATTEMPT_TIMEOUT={SECOND_ATTEMPT_TIMEOUT}s")
print(f"[TIMEOUT_DEBUG] THIRD_ATTEMPT_TIMEOUT={THIRD_ATTEMPT_TIMEOUT}s")
STABLE_REUSE_LLM = True # don't re-init on pure timeout, only on MemoryError
# Absolute safety thresholds to avoid system instability / BSOD risk
ABS_MIN_FREE_MB = float(os.environ.get("LLM_ABSOLUTE_MIN_FREE_MB", "200")) # Lowered for GPU mode
SAFE_POST_QUERY_TARGET_MB = float(os.environ.get("LLM_SAFE_POST_QUERY_TARGET_MB", "300")) # Lowered for GPU mode
# Placeholder tokens that should never appear in final executable SQL
PLACEHOLDER_TOKENS = {
"your title", "some title", "sample title", "your table", "table_name",
"column_name", "some_column", "placeholder", "value_here"
}
PLACEHOLDER_REGEXES = [
r"your_[a-z0-9_]*", # generic your_ placeholder tokens
]
# (Duplicate EMPLOYEE_DB_SCHEMA removed to prevent overwriting)
# Global readiness flag so we can re-attempt handshake before first real generation
LLM_HANDSHAKE_COMPLETE = False
LAST_GENERATED_SQL = None # updated each time a final SQL statement is produced
LAST_MCP_HINT_SQL = None # last validated fast-SQL hint (deterministic fallback path)
# Cached real department names from DB — populated during initialize_database
# Fallback: known employee-DB departments (used if DB query fails)
_cached_dept_names: set = {
'Customer Service', 'Development', 'Finance', 'Human Resources',
'Marketing', 'Production', 'Quality Management', 'Research', 'Sales'
}
# Function to initialize LLM with better memory management
_HF_QUOTA_DOWN = False # sticky flag: set once HF Inference is confirmed out of credits
def build_cloud_llm():
"""Construct the HF Inference API LLM (default: Qwen/Qwen2.5-Coder-7B-Instruct).
This is the same path used on Hugging Face Spaces. It is also used as an
automatic fallback inside initialize_llm() when the configured local
Ollama model is not installed.
Credits-resilience: on HF Inference 402/401/429 (credits exhausted, expired
token, rate limit) the same prompt is re-issued to Groq's OpenAI-compatible
endpoint (GROQ_API_KEY is already a Space secret). The Groq model is
auto-discovered from Groq's live /models list; override with GROQ_LLM_MODEL."""
hf_token = os.environ.get("HF_API_TOKEN")
hf_model = os.environ.get("HF_LLM_MODEL", "Qwen/Qwen2.5-Coder-7B-Instruct")
if not hf_token:
raise RuntimeError("HF_API_TOKEN is not set; cannot use the HF Inference API")
if InferenceClient is None:
raise RuntimeError("huggingface_hub is not installed; cannot use the HF Inference API")
print(f"[INFO] CLOUD MODE: Initializing Official HF InferenceClient ({hf_model})")
class HFClientWrapper(CustomLLM):
# Use a dummy model name that LlamaIndex accepts
model_name: str = hf_model
@property
def metadata(self) -> LLMMetadata:
return LLMMetadata(
context_window=4096,
num_output=1024,
model_name=self.model_name,
)
def complete(self, prompt: str, **kwargs) -> CompletionResponse:
# Provider routing: Qwen via HF Inference is primary. Once HF has
# been confirmed out of credits/rate-limited this process, go
# straight to Groq (saves one dead 402 round-trip per level).
global _HF_QUOTA_DOWN
provider = "hf"
if _HF_QUOTA_DOWN:
provider = "groq"
if provider == "hf":
hf_client = InferenceClient(model=self.model_name, token=hf_token)
try:
resp = hf_client.chat_completion(
messages=[{"role": "user", "content": prompt}],
max_tokens=1024
)
return CompletionResponse(text=resp.choices[0].message.content)
except Exception as hf_exc:
if _is_provider_quota_error(hf_exc):
_HF_QUOTA_DOWN = True
print(f"[LLM-FALLBACK] HF Inference unavailable "
f"({hf_exc.__class__.__name__}); falling back to Groq "
f"(sticky for this process)", flush=True)
else:
raise hf_exc
try:
groq_text = _groq_text_completion(prompt)
return CompletionResponse(text=groq_text)
except Exception as groq_exc:
print(f"[LLM-FALLBACK] Groq fallback failed: {groq_exc}", flush=True)
raise
def stream_complete(self, prompt: str, **kwargs):
# Not needed for our agent, but required by base class
raise NotImplementedError()
def chat(self, messages, **kwargs):
# Convert ChatMessage list to string prompt for stability
prompt = ""
for m in messages:
role = getattr(m, 'role', 'user')
content = getattr(m, 'content', str(m))
prompt += f"{role}: {content}\n"
return self.complete(prompt)
llm = HFClientWrapper()
# Register it in Settings so downstream tools can use it
global LLM_HANDSHAKE_COMPLETE
Settings.llm = llm
LLM_HANDSHAKE_COMPLETE = True
return llm
def _is_provider_quota_error(exc: Exception) -> bool:
"""Detect HF Inference failures that a Groq fallback can plausibly fix:
out of credits (402), expired/missing token (401) and rate limits (429)."""
status = getattr(exc, "status_code", None)
if status is None:
response = getattr(exc, "response", None)
status = getattr(response, "status_code", None)
if status in (401, 402, 429):
return True
msg = str(exc)
return any(sig in msg for sig in (
"402", "Payment Required", "no remaining credits",
"401", "Unauthorized", "429", "rate limit",
))
GROQ_TEXT_MODEL = os.environ.get("GROQ_LLM_MODEL", "") # optional override; empty = auto-discover
_GROQ_MODEL_CACHE = None # cached list of chat-capable Groq model ids
# Static candidates used when /models discovery is unavailable (e.g. key issue).
# Ordered: cheapest/longest-lived first; anything retired is skipped automatically.
_GROQ_CANDIDATE_FALLBACKS = [
"llama-3.1-8b-instant",
"llama-3.3-70b-versatile",
"openai/gpt-oss-20b",
"qwen/qwen3-32b",
]
def _groq_chat_models() -> list:
"""Live model list from Groq, filtered to chat-capable ids (no ASR/TTS/guard)."""
global _GROQ_MODEL_CACHE
if _GROQ_MODEL_CACHE is not None:
return _GROQ_MODEL_CACHE
groq_key = os.environ.get("GROQ_API_KEY")
text_models: list = []
if groq_key:
try:
r = requests.get(
"https://api.groq.com/openai/v1/models",
headers={"Authorization": f"Bearer {groq_key}"},
timeout=15,
)
r.raise_for_status()
ids = [m.get("id") for m in r.json().get("data", []) if m.get("id")]
# Non-chat families: ASR, TTS (incl. Orpheus/CanopyLabs voices,
# whose ids carry no "tts" token), guard models, embeddings.
exclude_tokens = ("whisper", "tts", "playai", "guard", "orpheus",
"canopylabs", "asr", "embed")
text_models = [
i for i in ids
if not any(tok in i.lower() for tok in exclude_tokens)
]
except Exception as models_err:
print(f"[LLM-FALLBACK] Groq /models listing failed: {models_err}", flush=True)
_GROQ_MODEL_CACHE = text_models
return text_models
def _groq_text_completion(prompt: str, max_tokens: int = 1024, timeout: float = 90.0) -> str:
"""Chat completion against Groq's OpenAI-compatible endpoint.
Model resolution: GROQ_LLM_MODEL override -> live /models discovery ->
static candidates. Retired model ids (404/400) are skipped automatically,
so model deprecations on Groq's side never break the fallback."""
groq_key = os.environ.get("GROQ_API_KEY")
if not groq_key:
raise RuntimeError("GROQ_API_KEY is not set; Groq text-LLM fallback unavailable")
candidates: list = []
if GROQ_TEXT_MODEL:
candidates.append(GROQ_TEXT_MODEL)
candidates.extend(_groq_chat_models())
# ALWAYS merge the static known-good ids: the live /models listing can
# return non-chat families (filtered) or be temporarily empty — the static
# tail guarantees resumable candidates. Duplicates are removed below.
candidates.extend(_GROQ_CANDIDATE_FALLBACKS)
# Prefer Qwen-family ids (closest match to the dormant HF primary,
# Qwen/Qwen2.5-Coder) while keeping the explicit override untouched.
ordered = candidates[:1] # explicit override, if set, stays first
ordered += sorted(
candidates[1:],
key=lambda m: 0 if "qwen" in m.lower() else 1, # stable sort: same bucket keeps live-list order
)
seen: set = set()
candidates = [c for c in ordered if not (c in seen or seen.add(c))][:8]
if not candidates:
raise RuntimeError("No Groq text models available (check GROQ_API_KEY)")
last_err: Exception | None = None
for model in candidates:
try:
r = requests.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {groq_key}"},
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
},
timeout=timeout,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except requests.HTTPError as http_err:
status = getattr(getattr(http_err, "response", None), "status_code", None)
if status in (404, 400):
# Model id retired/invalid on Groq's side -> try the next one
print(f"[LLM-FALLBACK] Groq model '{model}' unavailable ({status}); trying next candidate", flush=True)
_GROQ_MODEL_CACHE = None # invalidate cache; refresh on next call
last_err = http_err
continue
last_err = http_err
break # auth/quota/network errors: retrying other models won't help
raise RuntimeError(f"All Groq candidates failed: {last_err}")
def _ollama_model_available(model_name: str, base_url: str) -> bool:
"""Return True if the given model tag exists on the Ollama server.
A failed probe (server down, network error) conservatively returns True so
legacy behavior (handshake + retry warnings) is preserved.
"""
try:
r = requests.get(f"{(base_url or '').rstrip('/')}/api/tags", timeout=3)
r.raise_for_status()
installed = set()
for m in r.json().get("models", []):
nm = (m.get("name") or "").strip()
if nm:
installed.add(nm)
installed.add(nm.split(":")[0])
return model_name in installed or model_name.split(":")[0] in installed
except Exception:
return True
def initialize_llm(progressive_reduction=0):
"""
Initialize the LLM model with Ollama with proper memory management.
Args:
progressive_reduction: Level of resource reduction (0=normal, 1=reduced, 2=minimal)
Returns:
An initialized Ollama LLM instance
"""
# Make sure we have a clean slate
gc.collect()
# Get current memory stats
global LLM_HANDSHAKE_COMPLETE
stats = memory_manager.get_memory_stats()
available_mb = stats["available_mb"]
# CLOUD MODE: If USE_HF_CLOUD is enabled, skip local GPU/memory checks
if os.environ.get("USE_HF_CLOUD", "0") in ("1", "true", "True"):
if not os.environ.get("HF_API_TOKEN"):
print("[WARN] HF_API_TOKEN not found! Falling back to local Ollama even though USE_HF_CLOUD=1")
elif InferenceClient is None:
print("[WARN] huggingface_hub not installed! Falling back to local Ollama.")
else:
return build_cloud_llm()
# Hard safety abort first
if available_mb < ABS_MIN_FREE_MB:
raise MemoryError(f"Refusing to initialize LLM: free {available_mb:.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB (system safety)")
# Automatically determine if we need to increase resource reduction based on memory
if available_mb < memory_manager.critical_mb * 1.5:
# Force at least level 2 (minimal) if memory is near critical
progressive_reduction = max(progressive_reduction, 2)
print(f"Memory critically low ({available_mb:.2f}MB): Forcing minimal resource mode")
elif available_mb < memory_manager.threshold_mb:
# Force at least level 1 (reduced) if memory is below threshold
progressive_reduction = max(progressive_reduction, 1)
print(f"Memory low ({available_mb:.2f}MB): Using reduced resource mode")
# Determine resource usage level based on available memory and progressive reduction
# Allow environment overrides for mode thresholds
try:
env_ultra = float(os.environ.get("LLM_ULTRA_MB", "800"))
env_min = float(os.environ.get("LLM_MIN_MB", "600"))
except Exception:
env_ultra, env_min = 400.0, 200.0
ultra_low_memory_mode = available_mb < env_ultra or progressive_reduction >= 1
minimal_mode = available_mb < env_min or progressive_reduction >= 2
# Adjust parameters based on available memory
# Allow explicit overrides so user can test larger context to reduce repeated token delays
ctx_override = os.environ.get("LLM_CTX" )
thread_override = os.environ.get("LLM_THREADS")
try:
# Set absolute floor at 512 to prevent "forgetting" the schema hints
ctx_size = int(ctx_override) if ctx_override else (512 if minimal_mode else 768 if ultra_low_memory_mode else 1024)
except Exception:
ctx_size = 512
try:
num_thread = int(thread_override) if thread_override else 1
except Exception:
num_thread = 1
timeout = 12.0 if minimal_mode else 18.0 if ultra_low_memory_mode else 25.0
# Log mode based on memory constraints
mode_desc = "minimal" if minimal_mode else "ultra-low" if ultra_low_memory_mode else "low"
print(f"Initializing LLM with ctx_size={ctx_size}, num_thread={num_thread}, timeout={timeout:.1f}s in {mode_desc} memory mode")
# Use smaller model if in minimal mode and memory is critically low
# Permit explicit model override via env for experimentation (e.g. smaller model to prevent timeouts)
model_name = os.environ.get("LLM_MODEL_OVERRIDE") or (
FALLBACK_MODEL_NAME if minimal_mode and available_mb < memory_manager.critical_mb * 1.2 else OLLAMA_MODEL_NAME
)
# Local Ollama model missing? Auto-fallback to the HF Inference API instead
# of burning handshake budget against a 404. This mirrors the HF Spaces
# configuration (Qwen/Qwen2.5-Coder-7B-Instruct) on a local machine.
if not _ollama_model_available(model_name, OLLAMA_BASE_URL):
print(f"[LLM_INIT] Ollama model '{model_name}' not found on {OLLAMA_BASE_URL}")
if os.environ.get("HF_API_TOKEN") and InferenceClient is not None:
print(f"[LLM_INIT] Auto-falling back to HF Inference API "
f"({os.environ.get('HF_LLM_MODEL', 'Qwen/Qwen2.5-Coder-7B-Instruct')})")
try:
return build_cloud_llm()
except Exception as cloud_err:
print(f"[LLM_INIT] Cloud fallback failed: {cloud_err} — continuing with local Ollama anyway")
else:
print("[LLM_INIT] No HF_API_TOKEN set; cannot auto-fallback to cloud. "
"Set USE_HF_CLOUD=1 + HF_API_TOKEN in Agent/.env, or pull the model with 'ollama pull'.")
# Create the LLM instance with optimized settings but do NOT register yet.
# We perform a lightweight handshake first and then register only if memory allows.
llm = None
try:
llm = Ollama(
model=model_name,
base_url=OLLAMA_BASE_URL,
temperature=0.05, # Restored from 7.7
request_timeout=DEFAULT_LLM_REQUEST_TIMEOUT,
additional_kwargs={
# Optimized parameters for memory efficiency
"num_ctx": ctx_size,
"num_batch": 1, # Restored from 7.7
"num_gpu": -1, # Keep GPU but with stable batching
"f16_kv": True, # Half-precision for key/value cache
"mirostat": 0, # Disable mirostat sampling
"num_thread": num_thread,
"seed": 42 # Consistent seed
}
)
# Handshake strategy: if strict mode enabled, we block until success (within max timeout budget);
# otherwise we warn and continue, with a later retry on first real query.
strict_mode = os.environ.get("LLM_HANDSHAKE_STRICT", "0") not in ("0", "false", "False")
base_timeout = float(os.environ.get("LLM_HANDSHAKE_TIMEOUT", "30")) # single-attempt timeout
max_total = float(os.environ.get("LLM_HANDSHAKE_MAX_TOTAL", "120")) # total budget across attempts
handshake_prompts = ["SELECT 1;", "-- warmup\nSELECT 1;"]
attempt = 0
start_all = time.time()
handshake_ok = False
while not handshake_ok and (time.time() - start_all) < max_total and attempt < len(handshake_prompts):
hp = handshake_prompts[attempt]
attempt += 1
remaining_budget = max_total - (time.time() - start_all)
this_timeout = min(base_timeout, remaining_budget)
print(f"[HS] Handshake attempt {attempt} timeout={this_timeout:.1f}s remaining_budget={remaining_budget:.1f}s")
try:
# Use direct call bounded by TimeoutManager to avoid internal indefinite waits.
resp = TimeoutManager.run_with_timeout(lambda: llm.complete(hp), timeout=this_timeout)
# Some wrappers return object with .text, others raw string
_txt = getattr(resp, 'text', None)
if resp is not None and (_txt is None or 'error' not in str(_txt).lower()):
elapsed_all = time.time() - start_all
print(f"[HS] LLM handshake succeeded in {elapsed_all:.2f}s (model={model_name})")
handshake_ok = True
break
except TimeoutError:
print(f"[HS][WARN] Handshake attempt {attempt} timed out after {this_timeout:.1f}s")
except Exception as e_hs:
print(f"[HS][WARN] Handshake attempt {attempt} error: {e_hs}")
# If model just started loading, give it a short pause before next attempt
time.sleep(1.5)
if not handshake_ok:
msg = "[HS][WARN] Handshake not successful within budget; will retry on first real query." if not strict_mode else "[HS][ERROR] Strict handshake mode enabled and handshake failed."
print(msg)
if strict_mode:
# In strict mode, raise to stop startup (user explicitly wants guaranteed readiness)
raise TimeoutError("Strict LLM handshake failed")
else:
# Mark readiness
LLM_HANDSHAKE_COMPLETE = True
# Check memory after initialization/handshake to ensure we didn't consume too much
after_stats = memory_manager.get_memory_stats()
memory_used = available_mb - after_stats.get("available_mb", 0)
if memory_used > 100: # If we used more than 100MB just for initialization
print(f"⚠️ Warning: LLM initialization consumed {memory_used:.2f}MB")
# Post-init safety check
if after_stats.get('available_mb', 0) < ABS_MIN_FREE_MB:
print(f"[SAFETY] Post-init free memory {after_stats.get('available_mb',0):.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB; tearing down instance")
try:
if hasattr(llm, 'close'): llm.close()
except Exception:
pass
raise MemoryError("Post-init memory below absolute floor; aborted")
# Attempt to register the instance with the memory manager now that it's healthy.
try:
memory_manager.register_llm_instance(llm)
except MemoryError as me:
# Registration refused due to low memory; leave the llm unregistered but return it
print(f"MemoryManager refused to register LLM: {me}")
except Exception as reg_err:
# Non-fatal: log and continue returning the llm (unregistered)
print(f"Warning: unexpected error registering LLM: {reg_err}")
return llm
except Exception:
# Ensure we free any partial resources on failure
try:
if llm is not None:
try:
memory_manager.unregister_llm_instance(llm)
except Exception:
pass
finally:
# Best-effort GC and working set trim
gc.collect()
try:
memory_manager.force_collect_garbage()
except Exception:
pass
raise
# Create a function to run LLM safely with proper timeouts and cleanup
def run_llm_with_timeout(llm, prompt, timeout=30.0, retries=1):
"""
Run an LLM with proper timeout handling and resource cleanup.
Args:
llm: The LLM instance to use
prompt: The prompt to send to the LLM
timeout: Timeout in seconds
retries: Number of retries on timeout or memory error
Returns:
The LLM response
Raises:
TimeoutError: If all attempts time out
MemoryError: If memory is insufficient after retries
"""
last_error = None
for attempt in range(retries + 1):
print(f"[LLM][ATTEMPT] Attempt {attempt+1}/{retries+1} for prompt (len={len(prompt)})")
# Check memory before attempt and log status
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < ABS_MIN_FREE_MB:
print(f"[LLM][MEMORY] Insufficient memory before attempt: {stats['available_mb']:.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB")
raise MemoryError(f"Available memory {stats['available_mb']:.1f}MB < ABS_MIN_FREE_MB {ABS_MIN_FREE_MB}MB; aborting generation")
print(f"Memory check before attempt {attempt+1}: {stats['available_mb']:.2f}MB available (ABS_MIN_FREE_MB={ABS_MIN_FREE_MB}MB)")
if stats['available_mb'] < memory_manager.threshold_mb:
# Try with more aggressive memory reduction
reduction_level = attempt + 1
print(f"[LLM][MEMORY] Memory low, creating LLM with reduction level {reduction_level}")
# Release the current LLM instance if it exists
if 'llm' in locals() and llm is not None:
try:
memory_manager.unregister_llm_instance(llm)
llm = None
except Exception as e:
print(f"Error unregistering LLM: {str(e)}")
# Force garbage collection
gc.collect()
memory_manager.force_collect_garbage()
# Check if memory is critically low
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < memory_manager.critical_mb:
print(f"🚨 CRITICAL MEMORY CONDITION: Only {stats['available_mb']:.2f}MB available!")
raise MemoryError(f"System memory critically low: {stats['available_mb']:.2f}MB available")
# Create a new LLM with more aggressive resource reduction
llm = initialize_llm(progressive_reduction=reduction_level)
# Register with memory manager
memory_manager.register_llm_instance(llm)
try:
# Run the LLM with timeout using ThreadManager for safety
print(f"[LLM][RUN] Running LLM (attempt {attempt+1}/{retries+1}) with {timeout:.1f}s timeout")
# Start timing
start_time = time.time()
# First, try talking to an external MCP server if available. This centralizes
# model & tool orchestration. If MCP is unreachable, fall back to local LLM.
# Prefer local LLM first (preserves pre-MCP behaviour). If local LLM fails or times out,
# attempt to use MCP as a fallback so an unavailable MCP won't break everything.
with memory_manager.suspend_emergency("llm_generation"):
result = None # Initialize result to avoid UnboundLocalError
try:
print("Attempting local LLM completion first...")
# Wrap the LLM call to ensure we pass prompt correctly and bound it with our TimeoutManager
result = TimeoutManager.run_with_timeout(lambda: llm.complete(prompt), timeout=timeout)
except TimeoutError as local_to:
print(f"Local LLM completion timed out after {timeout}s: {local_to}; attempting MCP fallback...")
try:
from .mcp_client import mcp_complete
mcp_text = mcp_complete(prompt, timeout=min( max(5.0, timeout*0.5 ), timeout ))
class _Resp:
def __init__(self, text):
self.text = text
result = _Resp(mcp_text)
print(f"MCP fallback succeeded after local LLM timeout")
except Exception as mcp_err:
print(f"MCP completion failed or unavailable: {mcp_err}")
# If MCP is clearly unreachable (connection refused), don't treat as critical
if "connection refused" in str(mcp_err).lower() or "max retries exceeded" in str(mcp_err).lower():
print("[MCP] Server appears to be down - continuing without MCP fallback")
# Re-raise the original timeout error for retry logic
raise local_to
except Exception as local_err:
print(f"Local LLM completion failed: {local_err}; attempting MCP fallback...")
try:
from .mcp_client import mcp_complete
mcp_text = mcp_complete(prompt, timeout=min( max(5.0, timeout*0.5 ), timeout ))
class _Resp:
def __init__(self, text):
self.text = text
result = _Resp(mcp_text)
print(f"MCP fallback succeeded after local LLM failure")
except Exception as mcp_err:
print(f"MCP completion failed or unavailable: {mcp_err}")
# If MCP is clearly unreachable (connection refused), don't treat as critical
if "connection refused" in str(mcp_err).lower() or "max retries exceeded" in str(mcp_err).lower():
print("[MCP] Server appears to be down - continuing without MCP fallback")
# For timeout errors, try a longer timeout retry instead of immediate failure
if isinstance(local_err, TimeoutError) and attempt < retries:
print(f"[RETRY] Will retry with longer timeout due to initial timeout")
# Don't re-raise immediately, let the retry logic handle it
raise local_err
else:
# Re-raise original local error to be handled by outer retry logic
raise local_err
# Log completion time
elapsed = time.time() - start_time
print(f"LLM completed in {elapsed:.2f}s")
# Check memory after completion
stats = memory_manager.get_memory_stats()
print(f"Memory after LLM: {stats['available_mb']:.2f}MB available")
# If after execution we are below safety target, proactively free (soft protection)
if stats['available_mb'] < SAFE_POST_QUERY_TARGET_MB:
try:
print(f"[SAFETY] Free memory {stats['available_mb']:.1f}MB < SAFE_POST_QUERY_TARGET_MB {SAFE_POST_QUERY_TARGET_MB}MB; releasing LLM to protect system")
memory_manager.unregister_llm_instance(llm)
except Exception:
pass
memory_manager.force_collect_garbage()
# Force cleanup to prevent memory leaks
memory_manager.force_collect_garbage()
return result
except (TimeoutError, MemoryError) as e:
last_error = e
print(f"[LLM][FAIL] Attempt {attempt+1} failed: {type(e).__name__}: {str(e)}")
if isinstance(e, TimeoutError):
print(f"[LLM][TIMEOUT] Timeout occurred for prompt (len={len(prompt)}). Consider simplifying the query or increasing system resources.")
# Unregister LLM from memory manager
try:
memory_manager.unregister_llm_instance(llm)
except Exception as unreg_error:
print(f"Error unregistering LLM: {str(unreg_error)}")
# Aggressive cleanup after error
for _ in range(3): # Multiple cleanup attempts
gc.collect()
time.sleep(0.1) # Brief pause to allow OS to reclaim memory
memory_manager.force_collect_garbage()
if attempt < retries:
# INCREASE timeout for subsequent attempts to handle initialization overhead
timeout = min(180.0, timeout * 1.5) # Increase by 50% each retry, cap at 3 minutes
print(f"[LLM][RETRY] Retrying with increased timeout={timeout:.1f}s (attempt {attempt+2})")
# Escalate LLM reduction after 2 timeouts
if isinstance(e, TimeoutError) and attempt >= 1:
print(f"[LLM][ESCALATE] Escalating to more aggressive LLM reduction after repeated timeouts.")
try:
llm = initialize_llm(progressive_reduction=attempt+2)
memory_manager.register_llm_instance(llm)
except Exception as esc_err:
print(f"[LLM][ESCALATE][FAIL] Could not escalate LLM: {esc_err}")
else:
# Last attempt failed
print("[LLM][FAIL] All LLM attempts failed for this prompt.")
print(f"[LLM][USER] The LLM failed to respond after multiple attempts. Please try a simpler question, check your system resources, or increase the timeout settings if possible.")
# Final cleanup before raising error
memory_manager.force_collect_garbage()
raise last_error
except Exception as e:
# For other exceptions, cleanup and don't retry
print(f"LLM error: {type(e).__name__}: {str(e)}")
# Unregister LLM from memory manager
try:
memory_manager.unregister_llm_instance(llm)
except Exception as unreg_error:
print(f"Error unregistering LLM: {str(unreg_error)}")
# Cleanup
memory_manager.force_collect_garbage()
raise
# Initialize LLM — guard against double-import (Agent.agentic_workflow vs agentic_workflow)
# Both module paths share the same Ollama process, so re-running initialize_llm() causes
# redundant handshakes and wastes ~440MB of KV-cache allocation each time.
_LLM_INIT_SENTINEL = "Agent.__agentic_workflow_llm_initialized__"
import sys as _sys
if _sys.modules.get(_LLM_INIT_SENTINEL) is None:
_sys.modules[_LLM_INIT_SENTINEL] = True # mark as done before call (prevents re-entry)
llm = initialize_llm()
Settings.llm = llm
else:
# Module already initialized in this process — reuse the existing LLM instance
print("[LLM_INIT] Skipping duplicate initialize_llm() — module already initialized in this process")
try:
# Settings.llm should already be set; fall back to a fresh init only if truly missing
if getattr(Settings, 'llm', None) is None:
llm = initialize_llm()
Settings.llm = llm
else:
llm = Settings.llm
except Exception:
llm = initialize_llm()
Settings.llm = llm
def get_salary_column_name() -> str:
"""Helper to determine if the salary column is named 'salary' or 'amount'."""
global inspector, available_tables
if not inspector or 'salary' not in available_tables:
return 'salary' # default fallback
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name='salary')]
if 'salary' in cols:
return 'salary'
if 'amount' in cols:
return 'amount'
return cols[0] if cols else 'salary'
except Exception:
return 'salary'
async def initialize_database(uri: Optional[str] = None, schema_notes: Optional[str] = None) -> Dict[str, Any]:
"""Initialize the database connection and return metadata.
schema_notes: owner-supplied domain hints to attach to the active schema
profile. None = leave existing notes untouched.
"""
global available_tables, inspector, sql_database, async_engine, async_session, _DB_CONNECTED
metadata = {
"uri": None,
"tables": [],
"sql_database": None,
"inspector": None
}
try:
# Use provided URI or fallback to global environment-loaded variable
effective_uri = uri or DB_CONNECTION_URL
if not effective_uri:
print("[DB-INIT] No connection URI provided; skipping database initialization.")
return metadata
metadata["uri"] = effective_uri
# ---- Database-agnostic engine wiring (Agent/db_uri.py) -------------
# Maps bare schemes (mysql://, mssql://, oracle://, ...) onto the
# drivers this project ships, for both the sync and the async layer.
norm = normalize_db_uris(effective_uri)
family = norm["family"]
sync_uri = norm["sync_uri"]
async_uri = norm["async_uri"]
print(f"[DB-INIT] dialect family: {family or 'unknown'} | sync URI -> {sync_uri.split('@')[-1] if '@' in sync_uri else sync_uri}")
is_cloud_db = 'neon.tech' in effective_uri or 'sslmode=' in effective_uri
_is_postgres = family == "postgresql"
# Create sync engine for inspector / row stats / schema profile
engine_kwargs = {}
if _is_postgres and is_cloud_db:
# Neon-style managed Postgres: ensure TLS on the sync connection
print("[DB-INIT] Cloud database detected (Neon/SSL). Enabling SSL support.")
if 'sslmode=' not in sync_uri:
sep = '&' if '?' in sync_uri else '?'
sync_uri += f"{sep}sslmode=require"
metadata["sql_database"] = create_engine(sync_uri, **engine_kwargs)
sql_database = metadata["sql_database"]
# Create async engine for query execution (when a driver exists).
# mssql / oracle stay sync-only: SELECTs run through the sync engine.
# NOTE: async_engine / async_session are reset here so a profile switch
# away from an async-capable database cannot leak a stale session.
async_engine = None
async_session = None
if async_uri:
if _is_postgres:
# Strip sslmode from the URL query parameters for asyncpg
# compatibility; TLS is passed via connect_args instead.
if '?' in async_uri:
base_part, query_part = async_uri.split('?', 1)
new_params = [p for p in query_part.split('&') if not p.lower().startswith('sslmode=')]
async_uri = base_part + ('?' + '&'.join(new_params) if new_params else '')
try:
# For asyncpg, we must pass SSL requirements via connect_args
async_kwargs = {}
if _is_postgres and is_cloud_db:
async_kwargs["connect_args"] = {"ssl": "require"}
async_engine = create_async_engine(async_uri, **async_kwargs)
async_session = sessionmaker(
async_engine, class_=AsyncSession, expire_on_commit=False
)
except Exception as _async_err:
print(
f"[DB-INIT] async engine unavailable for '{family}' "
f"({_async_err.__class__.__name__}: {_async_err}) — "
"SELECTs will run through the sync engine."
)
async_engine = None
async_session = None
if async_session is None:
print(
f"[DB-INIT] no async SQLAlchemy driver for '{family}'; "
"query execution uses the sync engine in a worker thread."
)
metadata["async_ready"] = async_session is not None
# Create inspector
metadata["inspector"] = inspect(metadata["sql_database"])
inspector = metadata["inspector"]
# Get available tables
available_tables = metadata["inspector"].get_table_names() + metadata["inspector"].get_view_names()
# Populate active schema profile (after inspector available)
try:
ACTIVE_SCHEMA_PROFILE.populate_from_inspector(inspector, sql_database)
tblset = ACTIVE_SCHEMA_PROFILE.available_tables_set
if all(x in tblset for x in ["employee", "department", "dept_emp", "dept_manager", "salary", "title"]):
ACTIVE_SCHEMA_PROFILE.is_demo = True
ACTIVE_SCHEMA_PROFILE.name = "demo_employee"
else:
ACTIVE_SCHEMA_PROFILE.is_demo = False
ACTIVE_SCHEMA_PROFILE.name = "user_schema"
ACTIVE_SCHEMA_PROFILE.db_url = effective_uri
print(f"[DB-INIT] schema profile '{ACTIVE_SCHEMA_PROFILE.name}' "
f"({len(ACTIVE_SCHEMA_PROFILE.tables)} tables) ready")
except Exception as _e:
print(f"[DB-INIT] schema profile population warning: {_e}")
# Initialize tables
for table in available_tables:
metadata_obj = MetaData()
try:
table_obj = Table(table, metadata_obj, autoload_with=metadata["sql_database"])
metadata["tables"].append(table_obj)
print(f"Initialized table: {table}")
except NoSuchTableError as e:
print(f'"{e}" table not found!')
continue
# Attach owner-supplied schema notes (domain hints), if provided.
# None means "no change" so generic re-inits never wipe stored notes.
if schema_notes is not None:
ACTIVE_SCHEMA_PROFILE.notes = (schema_notes or "").strip()
print(f"[DB-INIT] schema notes attached ({len(ACTIVE_SCHEMA_PROFILE.notes)} chars)")
_DB_CONNECTED = True
return metadata
except Exception as e:
print(f"Error initializing database: {str(e)}")
_DB_CONNECTED = False
return metadata
def list_tables() -> str:
"""Returns a list of available tables in the database."""
global available_tables, inspector
try:
table_list = []
for table_name in available_tables:
table_info = inspector.get_table_comment(table_name=table_name).get('text', '')
table_info = f'Table Description: {table_info}\n' if table_info else ''
table_list.append(f'Table Name: {table_name}\n{table_info}')
result = '\n'.join(table_list)
return result
except Exception as e:
return f"Error listing tables: {str(e)}"
def describe_tables(tables: Optional[List[str]] = None) -> str:
"""Describes the specified tables in the database."""
global available_tables, inspector, sql_database
try:
table_names = tables or available_tables
table_schemas = []
for table_name in table_names:
try:
# Create table object
metadata_obj = MetaData()
table = Table(table_name, metadata_obj, autoload_with=sql_database)
# Get schema
schema = f"{table_name} columns:\n"
# Get columns
columns = inspector.get_columns(table_name=table_name)
for column in columns:
col_name = column["name"]
col_type = str(column["type"])
schema += f"- {col_name}: {col_type}\n"
table_schemas.append(f"{schema}")
except NoSuchTableError as e:
print(f"Table '{table_name}' not found: {str(e)}")
continue
if not table_schemas:
return "No valid tables found to describe."
result = "\n".join(table_schemas)
return result
except Exception as e:
return f"Error describing tables: {str(e)}"
async def _fetch_rows_via_active_engine(sql: str):
"""Execute read-only SQL through whichever engine layer is active.
Every SQLAlchemy-async-capable family driving this project (postgresql /
mysql / sqlite) goes through the async session; databases without an
async dialect (mssql via pymssql, oracle via oracledb) run through the
sync engine inside a worker thread so the event loop stays responsive.
Returns (rows, column_names) — raw SQLAlchemy Row objects + column keys.
"""
if async_session is not None:
async with async_session() as session:
result = await session.execute(text(sql))
rows = result.fetchall()
cols = result.keys()
else:
if sql_database is None:
raise RuntimeError("No database engine is connected")
def _exec_sync():
with sql_database.connect() as _conn:
_res = _conn.execute(text(sql))
_rows = _res.fetchall()
_cols = list(_res.keys())
return _rows, _cols
rows, cols = await asyncio.to_thread(_exec_sync)
return rows, cols
async def execute_query(query: str, skip_invariants: bool = False) -> List[Dict[str, Any]]:
"""Execute an SQL query and return the results as a list of dictionaries."""
global async_session
def sanitize_sql(raw: str) -> str:
"""Extract clean SQL, remove wrappers, and conservatively extract the first SQL statement (SELECT/WITH)."""
import re
s = extract_clean_sql(raw)
if not s or not isinstance(s, str):
return s
s = s.strip()
m = re.search(r"\b(select|with)\b", s, flags=re.IGNORECASE)
if not m:
return s
candidate = s[m.start():]
lines = candidate.splitlines()
kept = []
for ln in lines:
stripped = ln.strip()
if not stripped:
kept.append(ln)
continue
# Stop at bullet/annotation lines or obvious assistant notes
if re.match(r'^[-]{1,}', stripped):
break
if re.match(r'^(User Question:|Previous Attempt:|Use correct|Do NOT|Error executing|Result Presenter Agent)', stripped, flags=re.IGNORECASE):
break
kept.append(ln)
cleaned = '\n'.join(kept).strip()
# If the cleaned SQL looks like a bare SELECT of columns (no FROM), try to recover a FROM clause
if cleaned and not re.search(r"\bfrom\b", cleaned, flags=re.IGNORECASE):
fm = re.search(r"\bfrom\b[\s\S]*?(;|$)", candidate, flags=re.IGNORECASE)
if fm:
from_clause = fm.group(0).strip()
if re.search(r"^\s*select\b", from_clause, flags=re.IGNORECASE):
subfm = re.search(r"\bfrom\b[\s\S]*?(;|$)", from_clause, flags=re.IGNORECASE)
if subfm:
from_clause = subfm.group(0).strip()
cleaned = cleaned.rstrip(';').strip() + '\n' + from_clause
else:
return ""
if cleaned and not cleaned.rstrip().endswith(';'):
cleaned = cleaned + ';'
return cleaned
try:
sanitized = sanitize_sql(query)
if sanitized != query:
print("Notice: sanitizing SQL before execution (removed LLM commentary)")
print(f"Sanitized SQL: {sanitized}")
print(f"Executing query: {sanitized}")
# Ensure we actually have a SELECT/WITH to run
if not sanitized or not re.search(r"\b(select|with)\b", sanitized, flags=re.IGNORECASE):
return [{"error": "No valid SQL found after sanitization"}]
# Be conservative: SELECT statements that lack a FROM clause are likely
# partial outputs from the LLM (e.g. column list only). Reject these
# unless they are a simple literal/select expression (e.g. SELECT 1;)
if re.search(r'^\s*select\b', sanitized, flags=re.IGNORECASE) and not re.search(r"\bfrom\b", sanitized, flags=re.IGNORECASE):
# Allow very small literal selects like `SELECT 1;` or `SELECT 'x';`
if not re.match(r"^\s*select\s+[0-9'\"]+\s*;\s*$", sanitized, flags=re.IGNORECASE):
return [{"error": "Sanitized SQL missing FROM clause; rejected to avoid invalid execution"}]
rows, result_keys = await _fetch_rows_via_active_engine(sanitized)
# Convert to list of dictionaries
if rows and result_keys:
return [dict(zip(result_keys, row)) for row in rows]
elif result_keys: # We have columns but no rows
print(f"Query executed successfully but returned no rows. Columns: {list(result_keys)}")
return []
else: # No rows and potentially no columns
print("Query executed but returned no results")
return []
except Exception as e:
error_message = str(e)
print(f"Error executing query: {error_message}")
# Extract error details for better debugging
error_type = type(e).__name__
print(f"Error type: {error_type}")
# Check for specific error types and provide more context
if "column" in error_message.lower() and "does not exist" in error_message.lower():
print("Column reference error detected - this may be a schema misunderstanding")
elif "syntax error" in error_message.lower():
print("SQL syntax error detected - the query structure is invalid")
elif "relation" in error_message.lower() and "does not exist" in error_message.lower():
print("Table reference error detected - the table may not exist or has a different name")
# AUTO-FIX: Detect PostgreSQL GroupingError (missing GROUP BY) and repair inline
if ("groupingerror" in error_type.lower() or
"must appear in the group by clause" in error_message.lower()):
print("[AUTO-FIX][GROUP_BY] Detected PostgreSQL GroupingError — attempting GROUP BY repair")
try:
col_match = re.search(
r'column\s+"?([^"]+)"?\s+must appear in the group by',
error_message, re.IGNORECASE
)
fixed_query = sanitized
if col_match:
raw_col = col_match.group(1).strip() # e.g. "title.title"
col_bare = raw_col.split('.')[-1] # e.g. "title"
insertion = len(fixed_query)
for clause in [r'\bORDER\s+BY\b', r'\bLIMIT\b']:
m = re.search(clause, fixed_query, re.IGNORECASE)
if m and m.start() < insertion:
insertion = m.start()
semi = re.search(r'\s*;', fixed_query)
if semi and semi.start() < insertion:
insertion = semi.start()
if 'GROUP BY' in fixed_query.upper():
fixed_query = re.sub(
r'\b(GROUP\s+BY\b\s+\S+)',
rf'\1, {col_bare}',
fixed_query, count=1, flags=re.IGNORECASE
)
else:
fixed_query = fixed_query[:insertion] + f' GROUP BY {col_bare}' + fixed_query[insertion:]
print(f"[AUTO-FIX][GROUP_BY] Repaired: {fixed_query[:200]}")
try:
fixed_rows, fixed_keys = await _fetch_rows_via_active_engine(fixed_query)
if fixed_rows and fixed_keys:
return [dict(zip(fixed_keys, row)) for row in fixed_rows]
elif fixed_keys:
return []
return []
except Exception as _fix_err:
print(f"[AUTO-FIX][GROUP_BY] repaired query still failed: {_fix_err}")
return [{"error": str(_fix_err)}]
except Exception as gb_err:
print(f"[AUTO-FIX][GROUP_BY] Repair failed: {gb_err}")
# NEW: Detect missing FROM-clause/alias errors and trigger LLM regeneration
if not skip_invariants and ("missing from-clause entry for table" in error_message.lower() or "undefinedtableerror" in error_type.lower() or "missing from-clause entry" in error_message.lower() or "no such table" in error_message.lower() or "invalid reference to FROM-clause entry" in error_message.lower() or "missing alias" in error_message.lower() or "ambiguous column" in error_message.lower() or "alias" in error_message.lower() and "does not exist" in error_message.lower()):
print("[INVARIANTS] Detected alias/FROM-clause error. Triggering LLM regeneration for correct join order and alias usage.")
try:
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, classify_question
# Use the last question from context if available, else fallback
# (You may need to pass the question explicitly if not in scope)
regen_question = getattr(execute_query, 'last_question', None)
if not regen_question:
regen_question = "Please regenerate the SQL with correct join order and alias usage."
cat = classify_question(regen_question)
invs = build_invariants(cat, regen_question)
missing = evaluate_invariants(query, invs)
if missing:
regen_sql = regenerate_with_constraints(regen_question, query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
print("[INVARIANTS] LLM regenerated SQL after alias/FROM-clause error. Re-executing.")
# Try to execute the regenerated SQL
return await execute_query(regen_sql)
except Exception as regen_alias_err:
print(f"[INVARIANTS] Alias/FROM-clause regeneration failed: {regen_alias_err}")
# If error indicates a missing column, attempt a lightweight heuristic repair and re-run once
col_match = re.search(r'column\s+"?([a-zA-Z0-9_]+)"?\s+does not exist', error_message, flags=re.IGNORECASE)
if col_match:
missing_col = col_match.group(1)
print(f"Detected missing column '{missing_col}', attempting heuristic repair...")
repaired = repair_missing_column(query, missing_col)
if repaired:
print(f"Repaired query to attempt: {repaired}")
try:
rep_rows, rep_keys = await _fetch_rows_via_active_engine(repaired)
if rep_rows and rep_keys:
return [dict(zip(rep_keys, row)) for row in rep_rows]
elif rep_keys:
return []
else:
return []
except Exception as re_e:
print(f"Repaired query execution failed: {re_e}")
return [{"error": str(re_e)}]
# Return the error for formatting
return [{"error": error_message}]
def validate_sql_query(query: str) -> Dict[str, Any]:
"""Validate a SQL query without executing it."""
try:
# Always clean SQL before validation
sql = extract_clean_sql(query)
ql = sql.lower()
# Use regex to check for SELECT and FROM as word boundaries (robust to newlines/indentation)
if not re.search(r'\bselect\b', ql) and not re.search(r'\bwith\b', ql):
return {
"valid": False,
"reason": "Query must start with SELECT or WITH"
}
if not re.search(r'\bfrom\b', ql):
return {
"valid": False,
"reason": "Query must include FROM clause"
}
is_pure_aggregate = (
("count(" in ql or "avg(" in ql or "min(" in ql or "max(" in ql or "sum(" in ql)
and " group by " not in ql
)
if ("limit" not in ql) and not is_pure_aggregate:
return {"valid": False, "reason": "Query should include LIMIT clause to prevent excessive results"}
return {
"valid": True,
"reason": "Query syntax appears valid"
}
except Exception as e:
return {
"valid": False,
"reason": f"Validation exception: {str(e)}"
}
def repair_missing_column(query: str, missing_col: str) -> Optional[str]:
"""Attempt a small, safe repair when a column is reported missing.
Heuristic currently supports the common case where `salary` is referenced
but belongs to the `salary` table rather than `employee`.
Returns a repaired SQL string (including LIMIT) or None if no repair.
"""
try:
q = query.strip()
q_lower = q.lower()
# Find FROM clause and optional alias (e.g. FROM employee e)
m = re.search(r'\bfrom\s+([a-zA-Z_][\w]*)(?:\s+([a-zA-Z_][\w]*))?', q, flags=re.IGNORECASE)
if not m:
return None
base_table = m.group(1)
base_alias = m.group(2)
# Avoid treating SQL keywords as aliases (e.g., 'ORDER' accidentally captured)
SQL_KEYWORDS = {"order","by","where","group","limit","join","on","having","union","except","intersect"}
if base_alias and base_alias.lower() in SQL_KEYWORDS:
base_alias = None
alias = base_alias if base_alias else base_table
# Only handle the common salary case conservatively
if missing_col.lower() == 'salary':
# Avoid double-injecting if salary already joined or qualified
if re.search(r'\bjoin\s+salary\b', q_lower) or re.search(r's\.salary|salary\s+\w', q_lower):
return None
# Find end of the table name token to insert join before the next clause
table_token_re = re.search(r'\bfrom\s+' + re.escape(base_table) + r'\b', q, flags=re.IGNORECASE)
if not table_token_re:
return None
insert_pos = table_token_re.end()
join_clause = f" JOIN salary s ON {alias}.emp_no = s.emp_no"
repaired = q[:insert_pos] + join_clause + q[insert_pos:]
# Qualify ORDER BY salary -> s.salary
repaired = re.sub(r'order\s+by\s+salary\b', 'ORDER BY s.salary', repaired, flags=re.IGNORECASE)
# Ensure terminating semicolon and LIMIT presence
if 'limit' not in repaired.lower():
if repaired.strip().endswith(';'):
repaired = repaired[:-1].strip() + ' LIMIT 100;'
else:
repaired = repaired.strip() + ' LIMIT 100;'
else:
if not repaired.strip().endswith(';'):
repaired = repaired.strip() + ';'
return repaired
return None
except Exception:
return None
def enhanced_validate_sql(query: str) -> Dict[str, Any]:
"""Stricter validation to catch placeholders, unknown tables/columns.
Returns dict(valid: bool, issues: List[str]). Lightweight heuristic (no full parse).
"""
issues: List[str] = []
q_lower = query.lower()
# Reject obvious placeholders
for token in PLACEHOLDER_TOKENS:
if token in q_lower:
issues.append(f"Contains placeholder token '{token}'")
for rx in PLACEHOLDER_REGEXES:
if re.search(rx, q_lower):
issues.append("Contains generic placeholder pattern (your_*)")
# Must contain SELECT ... FROM ... pattern (robust to newlines/indentation)
if not re.search(r'\bselect\b', q_lower) or not re.search(r'\bfrom\b', q_lower):
issues.append("Missing SELECT or FROM clause")
# Extract referenced tables (rough pattern)
# IMPORTANT: Strip EXTRACT(...FROM...) expressions first to prevent false table detection.
# e.g. EXTRACT(YEAR FROM hire_date) would otherwise match 'hire_date' as a table name.
referenced_tables = set()
try:
query_for_table_scan = re.sub(r'\bEXTRACT\s*\([^)]+\)', '', query, flags=re.IGNORECASE)
table_tokens = re.findall(r'(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+as)?\s*([a-zA-Z_][a-zA-Z0-9_]*)?', query_for_table_scan, flags=re.IGNORECASE)
for base, _alias in table_tokens:
referenced_tables.add(base.lower())
except Exception:
pass
avail_lower = [t.lower() for t in available_tables]
for t in referenced_tables:
if t and t not in avail_lower:
issues.append(f"Unknown table '{t}'")
# Build schema cache once
schema_cache = {}
try:
for tbl in available_tables:
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name=tbl)]
schema_cache[tbl.lower()] = set(cols)
except Exception:
continue
except Exception:
pass
# Alias mapping
alias_map = {}
try:
alias_defs = re.findall(r'(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+(?:as\s+)?([a-zA-Z_][a-zA-Z0-9_]*)', query, flags=re.IGNORECASE)
for real, al in alias_defs:
alias_map[al.lower()] = real.lower()
except Exception:
pass
# Column references alias.column
try:
col_refs = re.findall(r'([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)', query)
# Skip common SQL functions/keywords that could appear before a dot (edge cases)
skip_alias = {"select","where","and","or","on","group","order","limit","count","avg","sum","min","max"}
for alias, col in col_refs:
if alias.lower() in skip_alias:
continue
mapped_table = alias_map.get(alias.lower(), alias.lower())
cols = schema_cache.get(mapped_table)
if cols is not None and col.lower() not in cols:
issues.append(f"Unknown column '{alias}.{col}'")
except Exception:
pass
# Detect bare (unqualified) column names used in WHERE / SELECT that don't exist in any referenced table
try:
# Extract simple bare identifiers between SELECT and FROM and after WHERE
bare_tokens = set()
select_part = query.split('FROM')[0] if 'FROM' in query.upper() else query
where_parts = re.split(r'WHERE', query, flags=re.IGNORECASE)
if len(where_parts) > 1:
where_clause = where_parts[1]
else:
where_clause = ''
token_pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*)\b'
keywords = {"select","from","where","and","or","on","group","by","order","limit","count","avg","sum","min","max","as","distinct","case","when","then","end"}
for part in [select_part, where_clause]:
for m in re.findall(token_pattern, part):
ml = m.lower()
if ml not in keywords and not any(ml == a or ml in schema_cache.get(a, set()) for a in alias_map.keys()):
# Check if token is a column in ANY referenced table
if not any(ml in cols for cols in schema_cache.values()):
bare_tokens.add(m)
# Filter obviously numeric or alias tokens
for tok in list(bare_tokens):
if tok.isdigit():
bare_tokens.remove(tok)
# Report tokens that look like columns but unknown
for tok in sorted(bare_tokens):
# Skip if it is a table name
if tok.lower() in avail_lower:
continue
# Skip placeholder patterns already captured
if tok.lower().startswith('your_'):
continue
# Only flag if appears near '=' in query (likely used as column)
if re.search(rf'\b{tok}\b\s*=|=\s*\b{tok}\b', query):
issues.append(f"Unqualified unknown identifier '{tok}'")
except Exception:
pass
# LIMIT optional for pure single-row aggregates (no GROUP BY)
is_pure_aggregate = (
("count(" in q_lower or "avg(" in q_lower or "min(" in q_lower or "max(" in q_lower or "sum(" in q_lower)
and " group by " not in q_lower
)
if "limit" not in q_lower and not is_pure_aggregate:
issues.append("Missing LIMIT clause")
if not q_lower.strip().endswith(';'):
issues.append("Missing terminating semicolon")
return {"valid": len(issues) == 0, "issues": issues}
def format_results(results: List[Dict[str, Any]], question: str) -> str:
"""Format query results into a user-friendly response."""
if not results:
try:
_db_label = ACTIVE_SCHEMA_PROFILE.name
except Exception:
_db_label = "the active database"
return f"I couldn't find any data matching your query in {_db_label}."
if "error" in results[0]:
error_msg = results[0]["error"]
# Provide more specific error messages for common errors
if "column" in error_msg.lower() and "does not exist" in error_msg.lower():
column_match = re.search(r'column "([^"]+)" does not exist', error_msg.lower())
if column_match:
bad_column = column_match.group(1)
return f"I encountered an error with the database query. The column '{bad_column}' doesn't exist in the database schema. This might be due to a typo or incorrect schema understanding."
elif "relation" in error_msg.lower() and "does not exist" in error_msg.lower():
table_match = re.search(r'relation "([^"]+)" does not exist', error_msg.lower())
if table_match:
bad_table = table_match.group(1)
return f"I encountered an error with the database query. The table '{bad_table}' doesn't exist in the database. Available tables are: {', '.join(available_tables)}."
# Generic error message
return f"I encountered an error while querying the database: {error_msg}"
# Convert any Decimal objects to float for JSON serialization
processed_results = []
for row in results:
processed_row = {}
for key, value in row.items():
# Handle Decimal type for JSON serialization
if hasattr(value, '__class__') and value.__class__.__name__ == 'Decimal':
processed_row[key] = float(value)
else:
processed_row[key] = value
processed_results.append(processed_row)
# Handle all result sets directly without using LLM to save memory
# First, check memory before potential LLM usage
if not check_memory_safety():
print("Low memory detected, using direct formatting instead of LLM")
return format_results_direct(processed_results, question)
# For result sets up to 50 rows, format directly (avoids LLM narrating from 3-sample rows)
if len(processed_results) <= 50:
return format_results_direct(processed_results, question)
# For larger result sets, try to use LLM but have a fallback ready
try:
# Check memory before LLM call
if not check_memory_safety():
print("Low memory before LLM formatting, falling back to direct formatting")
return format_results_direct(processed_results, question)
# Extract just the first 3 rows to reduce context size
sample_results = processed_results[:3]
sample_str = str(sample_results)
# Create a minimal prompt (profile-aware intro)
try:
_intro = ("Based on the employee database..." if ACTIVE_SCHEMA_PROFILE.is_employee_db
else f"Based on the {ACTIVE_SCHEMA_PROFILE.name} database...")
except Exception:
_intro = "Based on the database..."
prompt = f"""Summarize database results for: "{question}"
Sample (first 3 of {len(results)} rows):
{sample_str}
Start with "{_intro}"
"""
# Generate the formatted response with a short timeout
# Use a fresh LLM instance to avoid state issues
format_llm = initialize_llm()
response = format_llm.complete(prompt, timeout=5.0)
return response.text.strip()
except Exception as e:
print(f"Error formatting results with LLM: {str(e)}")
# Fallback to direct formatting
return format_results_direct(processed_results, question)
def format_results_direct(processed_results: List[Dict[str, Any]], question: str) -> str:
"""
Format results directly without using an LLM.
This is a memory-efficient fallback when the LLM approach isn't possible.
"""
question_lower = question.lower()
# Start with a profile-aware intro
try:
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
summary = "Based on the employee database, "
else:
summary = f"Based on the {ACTIVE_SCHEMA_PROFILE.name} database, "
except Exception:
summary = "Based on the database, "
# Determine the type of query to customize the response
# Gender distribution queries — always show a table
result_columns = list(processed_results[0].keys()) if processed_results else []
has_gender_col = any('gender' in c.lower() for c in result_columns)
if has_gender_col or 'gender' in question_lower or 'male' in question_lower or 'female' in question_lower:
summary += "here is the gender breakdown:\n\n"
headers = result_columns
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
if isinstance(val, (int, float)) and any(k in header.lower() for k in ["count", "num", "total"]):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
return summary
if "average salary" in question_lower and "department" in question_lower:
summary += "here's the average salary by department:\n\n"
# Create a markdown table for department salaries
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
# Format salary numbers with dollar signs and commas
if isinstance(val, (int, float)) and "salary" in header.lower():
values.append(f"${val:,.2f}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
elif "count" in question_lower or "how many" in question_lower or "number of" in question_lower:
# For count queries
if len(processed_results) == 1 and len(processed_results[0]) == 1:
# Simple count result (like total number of employees)
key = list(processed_results[0].keys())[0]
count = processed_results[0][key]
if "department" in question_lower and "employee" in question_lower:
# Extract department name from question if possible
dept_match = re.search(
r'(?:in|for|of|within)\s+(?:the\s+)?([A-Za-z\s&]+?)\s+department',
question_lower
)
dept_name = dept_match.group(1).strip().title() if dept_match else "that department"
summary += f"there are {count:,} employees working in the {dept_name} department."
elif "employee" in question_lower:
summary += f"there are {count:,} employees in the database."
else:
summary += f"the count is {count:,}."
else:
# Multiple rows with counts (like employees per department)
summary += "here are the counts:\n\n"
# Create a markdown table
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
# Format count numbers with commas
if isinstance(val, (int, float)) and ("count" in header.lower() or header.lower() == "count"):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
elif "manager" in question_lower or "managers" in question_lower:
# For manager queries
summary += "here are the department managers:\n\n"
# Create a markdown table
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
else:
# Generic handling for other types of queries
if len(processed_results) <= 10:
summary += "here are the results:\n\n"
# Create a markdown table
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results:
values = []
for header in headers:
val = row.get(header, "")
# Format currency values
if isinstance(val, (int, float)) and "salary" in header.lower():
values.append(f"${val:,.2f}")
# Format count values
elif isinstance(val, (int, float)) and ("count" in header.lower() or header.lower() == "count"):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
else:
# For large result sets
summary += f"I found {len(processed_results):,} results. Here are the first 10:\n\n"
# Create a markdown table with just the first 10 rows
headers = list(processed_results[0].keys())
summary += "| " + " | ".join(headers) + " |\n"
summary += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in processed_results[:10]:
values = []
for header in headers:
val = row.get(header, "")
# Format currency values
if isinstance(val, (int, float)) and "salary" in header.lower():
values.append(f"${val:,.2f}")
# Format count values
elif isinstance(val, (int, float)) and ("count" in header.lower() or header.lower() == "count"):
values.append(f"{val:,}")
else:
values.append(str(val))
summary += "| " + " | ".join(values) + " |\n"
return summary
def check_history(question: str) -> Dict[str, Any]:
"""Check if a similar question has been asked before."""
# 1. Direct normalized lookup
norm = normalize_question(question)
ans = question_memory_index.get(norm)
if ans:
return {"found": True, "previous_question": question, "previous_answer": ans}
# 2. Lightweight fuzzy / token overlap search over recent history
try:
private_messages = chat_store_private.get_messages(key="conversation")
qa_pairs = []
cur_q = None
for msg in private_messages[-400:]: # limit scan window
if msg.role == MessageRole.USER:
cur_q = msg.content
elif msg.role == MessageRole.ASSISTANT and cur_q:
qa_pairs.append((cur_q, msg.content))
cur_q = None
qa_pairs.reverse()
import re
target_tokens = set(norm.split())
best = None
best_score = 0.0
threshold = float(os.environ.get("MEMORY_FUZZY_THRESHOLD", "0.6"))
for qtext, answer in qa_pairs:
if any(k in qtext.lower() for k in ["re-execute","reexecute","regenerate","try again"]):
continue
q_norm = normalize_question(qtext)
tokens = set(q_norm.split())
if not tokens:
continue
overlap = len(tokens & target_tokens)
union = len(tokens | target_tokens)
jacc = overlap / union if union else 0.0
if jacc > best_score:
best_score = jacc
best = (qtext, answer)
if best_score >= 0.95: # early exit on near-perfect match
break
if best and best_score >= threshold:
print(f"[MEM-INDEX] Fuzzy match score={best_score:.2f} retrieved prior answer")
return {"found": True, "previous_question": best[0], "previous_answer": best[1]}
except Exception as e:
print(f"[MEM-INDEX][WARN] Fuzzy history scan failed: {e}")
return {"found": False}
def update_history(question: str, answer: str) -> None:
"""Update chat history with new question and answer."""
# Check if this is a re-execution command
is_reexecute = any(keyword in question.lower() for keyword in
["re-execute", "reexecute", "regenerate", "try again"])
# If it's a re-execution command, also save a version without the command words
if is_reexecute:
# Create a clean version of the question (without re-execute commands)
clean_question = question.lower()
for keyword in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"]:
clean_question = clean_question.replace(keyword, "").strip()
# Add the clean version to history as well
clean_user_message = ChatMessage(
role=MessageRole.USER,
content=clean_question
)
clean_assistant_message = ChatMessage(
role=MessageRole.ASSISTANT,
content=answer
)
# Add clean version to chat store
chat_store_private.add_message(key="conversation", message=clean_user_message)
chat_store_private.add_message(key="conversation", message=clean_assistant_message)
# Create messages for the original question
user_message = ChatMessage(
role=MessageRole.USER,
content=question
)
assistant_message = ChatMessage(
role=MessageRole.ASSISTANT,
content=answer
)
# Skip storing if answer appears to be an error or pure system notice
lowered = answer.lower()
error_indicators = [
"error while querying",
"syntax error",
"encountered an error",
"critically low on available memory",
"low on available memory",
"all query generation methods failed",
"cannot proceed due to low memory",
"i'm sorry, but your system is",
"failed to initialize"
]
is_error_answer = any(tok in lowered for tok in error_indicators)
if is_error_answer:
print("[MEM-HISTORY] Skipping storage of error answer")
else:
chat_store_private.add_message(key="conversation", message=user_message)
chat_store_private.add_message(key="conversation", message=assistant_message)
# Maintain a cap of recent successful QA pairs (e.g., last 200 user+assistant pairs)
try:
msgs = chat_store_private.get_messages(key="conversation")
# Count assistant messages to approximate pairs
max_pairs = int(os.environ.get("MEMORY_MAX_PAIRS", "200"))
# We'll rebuild keeping only last max_pairs*2 messages (user+assistant)
if max_pairs > 0:
trimmed = []
# Walk backwards collecting pairs
pair_msgs = []
user_seen = 0
for m in reversed(msgs):
pair_msgs.append(m)
if m.role == MessageRole.USER:
user_seen += 1
if user_seen >= max_pairs:
break
trimmed = list(reversed(pair_msgs))
if len(trimmed) < len(msgs):
chat_store_private._data["conversation"] = trimmed # internal structure update
print(f"[MEM-HISTORY] Trimmed history to last {user_seen} user turns")
except Exception as trim_err:
print(f"[MEM-HISTORY][WARN] Failed to trim history: {trim_err}")
# Persist only if stored
if not is_error_answer:
chat_store_private.persist(str(private_store_path))
print("Chat history updated successfully")
# Update memory index
try:
key = normalize_question(question)
question_memory_index[key] = answer
except Exception as e:
print(f"[MEM-INDEX][WARN] Failed to index question: {e}")
def get_doc_text(doc_id: str) -> str:
"""Read persisted OCR text for a given doc_id from chat_store/docs/{doc_id}.txt.
Returns the raw text or empty string if not found.
"""
try:
p = Path('chat_store') / 'docs' / f"{doc_id}.txt"
if not p.exists():
return ""
return p.read_text(encoding='utf-8')
except Exception:
return ""
# Define the tool functions
def memory_check(question: str) -> str:
"""Check if this question has been asked before."""
# Check if user explicitly requested to re-execute
force_reexecute = any(keyword in question.lower() for keyword in
["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"])
# Remove re-execution keywords from the question for memory checking
clean_question = question.lower()
for keyword in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"]:
clean_question = clean_question.replace(keyword, "").strip()
# If force re-execute, skip memory check and use the current question
if force_reexecute:
# Extract the actual question from the re-execute command
# clean_question already has re-execute keywords removed
if clean_question and len(clean_question.strip()) > 0:
print(f"[MEMORY] Re-execute requested. Using current question: '{clean_question}'")
return f"User requested to re-execute the query. Bypassing memory check."
else:
# If no question provided after re-execute, try to find previous question
try:
private_messages = chat_store_private.get_messages(key="conversation")
if len(private_messages) >= 2:
# Find the last user question before this re-execute command
for i in range(len(private_messages) - 1, -1, -1):
if private_messages[i].role == "user":
last_user_msg = private_messages[i].content
# Skip if this is also a re-execute command or empty
if last_user_msg and len(last_user_msg.strip()) > 0 and not any(kw in last_user_msg.lower() for kw in ["re-execute", "reexecute", "regenerate", "try again"]):
print(f"[MEMORY] Found original question for re-execute: {last_user_msg}")
return f"RE_EXECUTE_ORIGINAL: {last_user_msg}"
except Exception as e:
print(f"Error retrieving original question for re-execute: {e}")
# If we can't find a previous question, just bypass memory check
print("[MEMORY] No previous question found, bypassing memory check for re-execute")
return f"User requested to re-execute the query. Bypassing memory check."
# Check memory with the cleaned question
result = check_history(clean_question)
if result["found"]:
# Check if the previous answer was an error
if "error" in result["previous_answer"].lower() or "encountered an error" in result["previous_answer"].lower():
print("Previous answer contained an error. Suggesting to re-execute.")
return f"FOUND_IN_MEMORY_WITH_ERROR: {result['previous_answer']}"
else:
return f"FOUND_IN_MEMORY: {result['previous_answer']}"
else:
return "No previous answer found. I'll create a SQL query for this."
##### Hardcoded SQL template fallback system removed to enforce pure LLM-only generation #####
def ensure_llm_ready():
"""Ensure the global LLM has completed a successful handshake. If not, attempt one now
with an extended timeout (single attempt) so first user query doesn't fail silently.
Non-raising on failure; query generation will still attempt but will log status.
"""
global LLM_HANDSHAKE_COMPLETE, llm
if LLM_HANDSHAKE_COMPLETE:
return
print("[HS][LAZY] Performing deferred handshake before first generation...")
try:
# Attempt a single prompt with generous timeout (can be overridden)
timeout = float(os.environ.get("LLM_HANDSHAKE_TIMEOUT_FIRST_QUERY", os.environ.get("LLM_HANDSHAKE_TIMEOUT", "45")))
resp = TimeoutManager.run_with_timeout(lambda: llm.complete("SELECT 1;"), timeout=timeout)
if resp is not None:
LLM_HANDSHAKE_COMPLETE = True
print(f"[HS][LAZY] Deferred handshake succeeded (timeout={timeout}s)")
except Exception as e:
print(f"[HS][LAZY][WARN] Deferred handshake failed: {e}")
# ----------------------------------------------------------------------------
# Lightweight instrumentation utilities (no external deps) to diagnose timeouts
# ----------------------------------------------------------------------------
INSTRUMENT_GENERATION = os.environ.get("GEN_INSTRUMENT", "1") in ("1","true","True")
_GEN_EVENT_COUNTER = 0
def _approx_tokens(text: str) -> int:
# Crude heuristic; avoids importing a tokenizer. Good enough for relative scale.
if not text:
return 0
# Average 4 chars per token for English + SQL mix
return max(1, len(text)//4)
def _log_gen(stage: str, **kv):
if not INSTRUMENT_GENERATION:
return
global _GEN_EVENT_COUNTER
_GEN_EVENT_COUNTER += 1
# Build compact k=v list; truncate any large values
parts = [f"{k}={str(v)[:160]}" for k,v in kv.items()]
print(f"[GEN][INS][{_GEN_EVENT_COUNTER}][{stage}] " + " ".join(parts))
def _shrink_schema_if_needed(prompt: str, schema_text: str) -> str:
"""If prompt becomes very large (> ~9k chars) aggressively shrink schema section.
Returns (maybe) new schema_text. Keep logic minimal & reversible."""
if len(prompt) < 9000:
return schema_text
lines = schema_text.splitlines()
# Keep only first 2 table lines if huge
reduced = []
kept_tables = 0
for ln in lines:
if ln.strip():
reduced.append(ln)
if ':' in ln:
kept_tables += 1
if kept_tables >= 2:
break
reduced.append("...schema trimmed for brevity...")
new_schema = "\n".join(reduced)
return new_schema
# ===============================================================================
# 3-LEVEL AGENTIC SQL GENERATION ARCHITECTURE
# ===============================================================================
# Helper functions for the 3-level SQL generation approach:
# - Level 1: Pure LLM intelligence (no assistance)
# - Level 2: LLM + SQL Fixer enhancement
# - Level 3: LLM + MCP hints + SQL Fixer
#
# Each level attempts to generate SQL, and if it fails, escalates to the next level.
# Loop prevention: Strict level progression, single attempt per level, no recursive calls.
# ===============================================================================
def classify_fast_sql_intent(q: str):
"""
Classify user question to determine if it matches a fast SQL (MCP) intent pattern.
Returns (tool_name, tool_args) or (None, None) if no match.
This function extracts the intent classification logic from query_craft to make it reusable.
"""
ql = q.lower()
patterns = [
# Put salary range FIRST to avoid conflicts with other patterns
(r"salary range.*department|maximum and minimum salary.*department", ("fast_sql.salary_range_department", {})),
# DISABLED - NO HARDCODED QUERIES: (r"gender pay gap|pay gap|salary gap.*(male|female)|salary gap.*department|salary.*difference.*male.*female|pay.*difference.*male.*female", ("fast_sql.gender_pay_gap", {})),
(r"highest average salary|department .*highest average salary|which department has the highest average salary", ("fast_sql.department_highest_avg_salary", {})),
# Make top paid pattern more specific to avoid false matches
(r"top \d+ .*paid|highest[ -]paid employees|top paid employees", ("fast_sql.top_paid_employees", {})),
(r"employee count by department|employees per department|how many employees .*department|count of employees .*department", ("fast_sql.employee_count_by_department", {})),
(r"list departments|show departments|what departments", ("fast_sql.list_departments", {})),
(r"how many employees|total employees|employee count", ("fast_sql.count_employees", {})),
(r"highest and lowest salary|highest.*lowest salary|lowest.*highest salary", ("fast_sql.salary_extremes", {})),
(r"department managers|list managers per department|managers of each department", ("fast_sql.department_manager_listing", {})),
]
for pat, result in patterns:
try:
m = re.search(pat, ql)
except Exception:
m = None
if m:
if result[0] == 'fast_sql.top_paid_employees':
nm = re.search(r"top\s+(\d+)", ql)
top_n = int(nm.group(1)) if nm else 10
return result[0], {"top_n": top_n, "question": q}
if result[0] == 'fast_sql.salary_range_department':
dm = re.search(r"salary range .*department (?:for|of) ([A-Za-z &'-]+)", ql)
dept_name = dm.group(1).strip().title() if dm else 'Development'
return result[0], {"department_name": dept_name, "question": q}
return result[0], {"question": q}
return None, None
def _mcp_generic_schema_hint(question: str, _fast_log=None):
"""
Dynamic-schema Level 3 grounding: query the generic MCP tools
(schema.list_tables / schema.describe_table / schema.sample_data) and
build a verified schema+sample hint from the LIVE database. This replaces
the employee-specific fast_sql skeletons for any user-uploaded database.
Returns a text block (not raw SQL) used as grounding context in prompts.
"""
def _log(stage: str, **kv):
if _fast_log:
_fast_log(stage, **kv)
try:
from .mcp_client import mcp_call_tool
def _call(name, args, timeout=30.0):
ctx = {"tool_call": {"name": name, "args": args or {}}}
resp = mcp_call_tool(prompt=f"(generic schema hint) {name}", timeout=timeout, context=ctx)
return resp.get('tool_result') if isinstance(resp, dict) else None
# 1) Tables in the live DB
list_res = _call("schema.list_tables", {})
tables = list_res.get("tables") if isinstance(list_res, dict) else None
if not tables:
_log('GENERIC_HINT', result="no tables returned")
return None
# 2) Pick the tables whose names appear in the question (cheap keyword match)
ql = question.lower()
matched = [t for t in tables if t and (t.lower().replace('_', ' ') in ql or t.lower() in ql)]
if not matched:
# Fall back to FK-neighbors heuristic: take up to 3 largest tables
matched = tables[:3]
# 3) Describe + sample each matched table (bounded)
sections = []
for t in matched[:5]:
desc = _call("schema.describe_table", {"table": t})
cols = desc.get("columns") if isinstance(desc, dict) else None
if not cols:
continue
sample = _call("schema.sample_data", {"table": t, "limit": 2})
rows = sample.get("rows") if isinstance(sample, dict) else None
section = f"{t}({', '.join(map(str, cols))})"
if rows:
section += f" e.g. rows: {rows[:2]}"
sections.append(section)
if not sections:
_log('GENERIC_HINT', result="describe failed")
return None
hint = (
"VERIFIED LIVE SCHEMA (from actual database inspection):\n"
+ "\n".join(f"- {s}" for s in sections)
+ "\nUse ONLY these tables/columns unless another table is required to join them."
)
_owner_notes = _schema_notes_block()
if _owner_notes:
hint += "\n\n" + _owner_notes
_log('GENERIC_HINT', tables=matched, chars=len(hint))
return hint
except Exception as e:
_log('GENERIC_HINT_FAIL', error=str(e))
return None
def get_mcp_hint_if_available(question: str):
"""
Attempt to retrieve a SQL hint from MCP server based on question classification.
Returns SQL string as a hint (not to be used directly), or None if unavailable.
This is Level 3 assistance - MCP provides hints to guide the LLM, not hardcoded SQL.
"""
ENABLE_FAST_SQL = os.environ.get("ENABLE_MCP_FAST_TOOLS", "1") not in ("0", "false", "False")
FAST_SQL_LOG = os.environ.get("FAST_SQL_LOG", "1") not in ("0", "false", "False")
def _fast_log(stage: str, **kv):
if FAST_SQL_LOG:
parts = [f"{k}={str(v)[:140]}" for k, v in kv.items()]
print(f"[FAST][{stage}] " + " ".join(parts))
if not ENABLE_FAST_SQL:
return None
# Dynamic schema support: the 14 fast_sql.* tools are purpose-built for the
# employee demo schema. For user schemas, ground Level 3 with live schema +
# sample data from the generic MCP tools instead.
if not getattr(ACTIVE_SCHEMA_PROFILE, "is_employee_db", False):
return _mcp_generic_schema_hint(question, _fast_log)
tool_name, tool_args = classify_fast_sql_intent(question)
if not tool_name:
return None
_fast_log('INTENT', tool=tool_name, args=tool_args)
try:
from .mcp_client import mcp_call_tool
ctx = {"tool_call": {"name": tool_name, "args": tool_args or {}}}
start_fast = time.time()
resp = mcp_call_tool(prompt=f"(fast sql) {question}", timeout=45.0, context=ctx)
dur = round(time.time() - start_fast, 3)
tool_res = resp.get('tool_result') if isinstance(resp, dict) else None
if isinstance(tool_res, dict) and 'sql' in tool_res:
sql = tool_res['sql']
_fast_log('TOOL_SQL', ms=int(dur*1000), sql_preview=sql[:160])
# Remember for the deterministic post-3-level fallback (see the
# "All 3 levels failed" block): if every LLM call fails due to
# provider outage/credits, this schema-true SQL still answers.
global LAST_MCP_HINT_SQL
LAST_MCP_HINT_SQL = sql
return sql # Return as hint
else:
_fast_log('NO_SQL', raw=tool_res)
return None
except Exception as fast_err:
_fast_log('FALLBACK', error=str(fast_err))
return None
def is_sql_valid(sql: str, question: str = None) -> bool:
"""
Validation check for generated SQL with both syntactic and semantic checks.
Args:
sql: The SQL query to validate
question: Optional user question for semantic validation
Returns:
True if SQL appears valid both syntactically and semantically, False otherwise.
"""
if not sql or not isinstance(sql, str):
return False
# [LEVEL 1] Call enhanced_validate_sql for strict schema and column checking
validation = enhanced_validate_sql(sql)
if not validation["valid"]:
print(f"[VALIDATION] Rejected by enhanced_validate_sql: {validation['issues']}")
return False
sql_lower = sql.lower().strip()
# Quick placeholder check.
# NOTE: "<" and ">" are LEGITIMATE comparison operators ("price > average",
# "count < 5", "<>"). They were once listed here and SILENTLY rejected every
# greater/less-than question on every database. Real placeholders are caught
# by the regexes below (<TABLE_NAME> style) — keep operators legal.
PLACEHOLDER_TOKENS = ["...", "___", "placeholder", "example", "your_"]
PLACEHOLDER_REGEXES = [
r"<[A-Z_]+>", # <TABLE_NAME>, <COLUMN>
r"\b(table_name|column_name|your_\w+)\b"
]
for token in PLACEHOLDER_TOKENS:
if token in sql_lower:
print(f"[VALIDATION] Rejected: placeholder token '{token}'")
return False
for regex in PLACEHOLDER_REGEXES:
if re.search(regex, sql, re.IGNORECASE):
print(f"[VALIDATION] Rejected: placeholder pattern /{regex}/")
return False
# Reject hardcoded department codes (D001-D009) - indicates incomplete query
# These suggest LLM didn't join to department table to filter by name
if ACTIVE_SCHEMA_PROFILE.is_employee_db and re.search(r"'D00[0-9]'", sql, re.IGNORECASE):
print("[VALIDATION] Rejected: Contains hardcoded department code (needs dept_name filter)")
return False
# STRICT SCHEMA CHECK: Reject common hallucinations before they hit the DB
# employee (e) does NOT have dept_no or salary
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
if re.search(r'\b[eE]\.dept_no\b|employee\.dept_no\b', sql, re.IGNORECASE):
print("[VALIDATION] Rejected: Table 'employee' lacks 'dept_no'. Bridging through 'dept_emp' is required.")
return False
if re.search(r'\b[eE]\.salary\b|employee\.salary\b', sql, re.IGNORECASE):
print("[VALIDATION] Rejected: Table 'employee' lacks 'salary'. Join with 'salary' table is required.")
return False
# MANAGER JOIN CHECK: Note that dept_manager should be used for manager queries.
# We do NOT hard-reject here — adjust_manager_query() in apply_semantic_adjusters
# will regenerate the SQL using generate_manager_list_sql() if dept_manager is absent.
# Hard-rejecting here blocks all 3 levels before the adjuster ever gets a chance to fix it.
if (ACTIVE_SCHEMA_PROFILE.is_employee_db and question
and any(k in question.lower() for k in ["manager", "managers", "who manages"])):
if 'dept_manager' not in sql.lower() and 'manager_name' not in sql.lower():
print("[VALIDATION] Warning: Manager query missing dept_manager — adjuster will fix after this level succeeds.")
# ===== SEMANTIC VALIDATION =====
# Check if question asks for specific department but SQL doesn't filter by it
if question and ACTIVE_SCHEMA_PROFILE.is_employee_db:
print(f"[VALIDATION DEBUG] Checking semantic validation for question: {question[:100]}")
question_lower = question.lower()
# Department-specific query patterns
dept_patterns = [
(r'\bin\s+(development|finance|sales|marketing|research|production|hr|human\s+resources|quality\s+management|customer\s+service)\s+(department|dept)\b', 1),
(r'\b(development|finance|sales|marketing|research|production|hr|human\s+resources|quality\s+management|customer\s+service)\s+department\b', 1),
]
for pattern, group_idx in dept_patterns:
match = re.search(pattern, question_lower, re.IGNORECASE)
if match:
dept_name = match.group(group_idx).strip()
print(f"[VALIDATION DEBUG] Found department mention: '{dept_name}'")
# Normalization
dept_map = {
'hr': 'Human Resources',
'human resources': 'Human Resources',
'quality management': 'Quality Management',
'customer service': 'Customer Service'
}
dept_name_proper = dept_map.get(dept_name.lower(), dept_name.title())
print(f"[VALIDATION DEBUG] Normalized to: '{dept_name_proper}'")
print(f"[VALIDATION DEBUG] Checking if '{dept_name_proper.lower()}' is in SQL")
print(f"[VALIDATION DEBUG] SQL being checked (first 200 chars): {sql_lower[:200]}")
# Check if SQL contains this department name as a filter
if dept_name_proper.lower() not in sql_lower:
print(f"[VALIDATION] Rejected: Question asks for '{dept_name_proper}' but SQL doesn't filter by it")
print(f"[VALIDATION] This likely returns ALL departments instead of just '{dept_name_proper}'")
return False
else:
print(f"[VALIDATION DEBUG] Department filter found in SQL - validation passed")
break # First matching pattern is sufficient — no need to check remaining patterns
else:
print("[VALIDATION DEBUG] No question provided for semantic validation")
# ===== ALIAS VALIDATION =====
# Check if SQL references aliases that aren't defined in FROM/JOIN clauses
# Extract defined aliases from FROM and JOIN clauses
defined_aliases = set()
# Match FROM table alias and JOIN table alias patterns
from_pattern = r'\bFROM\s+(\w+)\s+(?:AS\s+)?(\w+)\b'
join_pattern = r'\bJOIN\s+(\w+)\s+(?:AS\s+)?(\w+)\b'
# Exclude SQL keywords and common column names from alias matching
_SQL_KEYWORDS = {'where', 'group', 'order', 'limit', 'having', 'join', 'on',
'and', 'or', 'not', 'in', 'as', 'by', 'select', 'from', 'set',
'emp_no', 'dept_no', 'from_date', 'to_date', 'first_name', 'last_name'}
# Strip EXTRACT(... FROM ...) to avoid matching column names as tables
_sql_for_alias = re.sub(r'\bEXTRACT\s*\([^)]+\)', 'EXTRACT_EXPR', sql, flags=re.IGNORECASE)
_sql_for_alias_lower = _sql_for_alias.lower()
for match in re.finditer(from_pattern, _sql_for_alias, re.IGNORECASE):
table_name = match.group(1).lower()
alias = match.group(2).lower()
# Skip SQL keywords mistakenly matched as aliases
if alias in _SQL_KEYWORDS or table_name in _SQL_KEYWORDS:
continue
if alias != table_name:
defined_aliases.add(alias)
print(f"[VALIDATION DEBUG] Found alias '{alias}' for table '{table_name}'")
for match in re.finditer(join_pattern, _sql_for_alias, re.IGNORECASE):
table_name = match.group(1).lower()
alias = match.group(2).lower()
if alias in _SQL_KEYWORDS or table_name in _SQL_KEYWORDS:
continue
if alias != table_name:
defined_aliases.add(alias)
print(f"[VALIDATION DEBUG] Found alias '{alias}' for table '{table_name}'")
# NEW: Handle comma-separated joins like FROM table1 t1, table2 t2
# Only look at the part BEFORE the first JOIN or WHERE to avoid condition noise
comma_from_pattern = r'\bFROM\s+(.*?)(?=\bJOIN\b|\bWHERE\b|$)'
comma_match = re.search(comma_from_pattern, _sql_for_alias, re.IGNORECASE | re.DOTALL)
if comma_match:
from_clause = comma_match.group(1)
# Find all pairs of word+word (table alias)
alias_pairs = re.findall(r'(\w+)\s+(?:AS\s+)?(\w+)', from_clause, re.IGNORECASE)
for table_name, alias in alias_pairs:
t_low = table_name.lower()
a_low = alias.lower()
if t_low in _SQL_KEYWORDS or a_low in _SQL_KEYWORDS:
continue
defined_aliases.add(a_low)
print(f"[VALIDATION DEBUG] Found comma-join alias '{a_low}'")
# Find all alias references (pattern: alias.column)
alias_ref_pattern = r'\b([a-z_]\w*)\.(\w+)\b'
used_aliases = set()
for match in re.finditer(alias_ref_pattern, sql_lower, re.IGNORECASE):
alias = match.group(1)
# Skip if it's a table name (not an alias) — dynamic: use the live profile's
# table set so this works for any user-uploaded schema, not just employees
_skip_tables = ACTIVE_SCHEMA_PROFILE.available_tables_set or {
'employee', 'department', 'salary', 'dept_emp', 'dept_manager', 'title', 'titles'}
if alias not in _skip_tables:
used_aliases.add(alias)
# Check for undefined aliases
undefined_aliases = used_aliases - defined_aliases
if undefined_aliases:
print(f"[VALIDATION] Rejected: SQL references undefined aliases: {undefined_aliases}")
print(f"[VALIDATION] Defined aliases: {defined_aliases}")
print(f"[VALIDATION] Used aliases: {used_aliases}")
return False
# Basic structure check
if not sql.strip().endswith(';'):
return False
return True
def level_1_pure_llm(question: str, schema_text: str, relevant_tables: list,
base_llm, hints: str = "", timeout: float = 35.0):
"""
Level 1: Pure LLM intelligence with no assistance.
Args:
question: User's natural language question
schema_text: Formatted schema information
relevant_tables: List of relevant table names
base_llm: The LLM instance to use
timeout: Timeout for LLM call in seconds
Returns:
Valid SQL string or None if generation fails
"""
print("[LEVEL_1] Attempting pure LLM generation (no hints, no assistance)")
_CURRENT_ROW_RULE_EMP = "ALWAYS filter to_date = '9999-01-01' to get current records in salary, dept_emp, and dept_manager."
prompt = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"You are an SQL expert. Convert the natural language question into a SINGLE valid PostgreSQL query.\n"
f"Question: {question}\n"
f"Available tables: {', '.join(relevant_tables)}\n"
f"Table schemas (columns):\n{schema_text}\n"
f"Rules: Do NOT invent column names. {_current_row_rules(_CURRENT_ROW_RULE_EMP)} "
f"Append LIMIT 100 ONLY if a specific count (like 'top 10') is not requested. "
f"The query must end with a single semicolon. Return ONLY raw SQL."
f"Be precise with column names and table joins."
)
try:
start_t = time.time()
raw = base_llm.complete(prompt=prompt)
gen_dur = time.time() - start_t
text_out = raw.text if hasattr(raw, 'text') else str(raw)
print(f"[LEVEL_1] LLM responded in {gen_dur:.2f}s")
# Extract SQL from code fences if present
sql_query = text_out.strip()
if '```' in sql_query:
parts = re.split(r"```(?:sql)?", sql_query, flags=re.IGNORECASE)
sql_query = '\n'.join(p for p in parts if 'select' in p.lower())
if sql_query.lower().startswith('sql'):
sql_query = sql_query[3:].strip()
# Clean and validate with semantic checks
sql_query = extract_clean_sql(sql_query)
if is_sql_valid(sql_query, question):
print(f"[LEVEL_1] ✓ Generated valid SQL: {sql_query[:100]}...")
return sql_query
else:
print(f"[LEVEL_1] ✗ Generated SQL failed validation. SQL: {sql_query}")
return None
except Exception as e:
print(f"[LEVEL_1] ✗ Failed with error: {type(e).__name__}: {e}")
return None
def generate_micro_gen_hint(question: str) -> str:
"""
Generate micro-gen SQL hint based on question pattern.
This is called LAZILY inside Level 2, only when Level 1 fails.
Args:
question: User's natural language question
Returns:
SQL hint string or None if no pattern matches
"""
question_lower = question.lower()
# Salary range by department pattern
if re.search(r'\b(maximum and minimum|max.*min|min.*max|salary range)\b.*\b(development|dept|department)\b', question_lower):
print("[MICRO-GEN][LAZY] Detected salary range in department question")
try:
# Extract department name from question
dept_name = "Development" # Default
if "development" in question_lower:
dept_name = "Development"
elif "sales" in question_lower:
dept_name = "Sales"
elif "marketing" in question_lower:
dept_name = "Marketing"
elif "finance" in question_lower:
dept_name = "Finance"
elif "human resources" in question_lower:
dept_name = "Human Resources"
elif "research" in question_lower:
dept_name = "Research"
elif "production" in question_lower:
dept_name = "Production"
# Smart Hint: If question asks for EACH/ALL/Distribution, don't filter by a single department
is_global = any(k in question_lower for k in ["each", "all", "every", "distribution", "per department", "by department"])
# Build SQL hint
where_clause = f"WHERE d.dept_name = '{dept_name}' " if not is_global else " "
hint_sql = (
f"SELECT d.dept_name, MAX(s.salary) AS max_salary, MIN(s.salary) AS min_salary "
f"FROM salary s "
f"JOIN employee e ON s.emp_no = e.emp_no "
f"JOIN dept_emp de ON e.emp_no = de.emp_no "
f"JOIN department d ON de.dept_no = d.dept_no "
f"{where_clause}"
f"AND s.to_date = '9999-01-01' "
f"AND de.to_date = '9999-01-01' "
f"{'GROUP BY d.dept_name' if is_global else ''};"
)
if is_global:
print(f"[MICRO-GEN][LAZY] Generated GLOBAL hint (no department filter)")
else:
print(f"[MICRO-GEN][LAZY] Generated hint for {dept_name}")
return hint_sql
except Exception as e:
print(f"[MICRO-GEN][LAZY] Failed: {e}")
return None
# Add more patterns here as needed
# ...
return None
def level_2_llm_with_fixer(question: str, schema_text: str, relevant_tables: list,
base_llm, hints: str = "", timeout: float = 45.0):
"""
Level 2: LLM + Micro-gen hints + SQL Fixer enhancement.
This level LAZILY generates schema-aware hints from micro-generators
(only when Level 1 fails and Level 2 is invoked).
Then applies fix_common_sql_errors for repair.
Args:
question: User's natural language question
schema_text: Formatted schema information
relevant_tables: List of relevant table names
base_llm: The LLM instance to use
timeout: Timeout for LLM call in seconds
Returns:
Valid SQL string or None if generation fails
"""
print("[LEVEL_2] Attempting LLM + Micro-gen hints + SQL Fixer")
# LAZY GENERATION: Only generate micro-hint NOW (not before Level 1)
micro_gen_hint = generate_micro_gen_hint(question)
# Build hint text if micro-gen provided one
hint_text = ""
if micro_gen_hint:
hint_text = (
f"\n💡 MICRO-GEN HINT (schema-aware pattern):\n{micro_gen_hint}\n\n"
f"Use this as a guide but adapt column/table names to match the actual schema.\n"
)
print(f"[LEVEL_2] Micro-gen hint available: {micro_gen_hint[:100]}...")
else:
print("[LEVEL_2] No micro-gen hint available, using enhanced prompt only")
_CURRENT_ROW_RULE_EMP = "ALWAYS filter to_date = '9999-01-01' to get current records in salary, dept_emp, and dept_manager."
# Enhanced prompt with optional micro-gen hint
prompt = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{hint_text}"
f"Question: {question}\n"
f"Tables: {', '.join(relevant_tables)}\n"
f"Table schemas:\n{schema_text}\n"
f"Rules: {_current_row_rules(_CURRENT_ROW_RULE_EMP)} "
f"Append LIMIT 100 ONLY if a specific count (like 'top 10') is not requested. "
f"The query must end with a single semicolon. Return ONLY SQL (PostgreSQL)."
f"Be precise with column names and table joins."
)
try:
start_t = time.time()
raw = base_llm.complete(prompt=prompt)
gen_dur = time.time() - start_t
text_out = raw.text if hasattr(raw, 'text') else str(raw)
print(f"[LEVEL_2] LLM responded in {gen_dur:.2f}s")
# Extract SQL from code fences if present
sql_query = text_out.strip()
if '```' in sql_query:
parts = re.split(r"```(?:sql)?", sql_query, flags=re.IGNORECASE)
sql_query = '\n'.join(p for p in parts if 'select' in p.lower())
if sql_query.lower().startswith('sql'):
sql_query = sql_query[3:].strip()
# Clean and apply fixer
sql_query = extract_clean_sql(sql_query)
sql_query = fix_common_sql_errors(sql_query)
# Additional validation
base_val = validate_sql_query(sql_query)
if not base_val['valid']:
sql_query = fix_query(sql_query, base_val['reason'])
if is_sql_valid(sql_query, question):
print(f"[LEVEL_2] ✓ Generated and fixed valid SQL: {sql_query[:100]}...")
return sql_query
else:
print(f"[LEVEL_2] ✗ Generated SQL failed validation after fixes")
return None
except Exception as e:
print(f"[LEVEL_2] ✗ Failed with error: {type(e).__name__}: {e}")
return None
def level_3_mcp_hints_llm_fixer(question: str, schema_text: str, relevant_tables: list,
base_llm, hints: str = "", timeout: float = 55.0):
"""
Level 3: LLM + MCP hints + SQL Fixer (MCP provides hints to guide the LLM).
This is the final escalation level. MCP tools provide SQL patterns as hints,
which are injected into the prompt to guide the LLM's generation.
Args:
question: User's natural language question
schema_text: Formatted schema information
relevant_tables: List of relevant table names
base_llm: The LLM instance to use
timeout: Timeout for LLM call in seconds
Returns:
Valid SQL string or None if generation fails
"""
print("[LEVEL_3] Attempting LLM + MCP hints + SQL Fixer")
# Try to get MCP hint
mcp_hint = get_mcp_hint_if_available(question)
# Construct hint text
micro_hint_text = ""
if mcp_hint:
micro_hint_text = (
f"\n🎯 HINT FROM MCP - GROUNDING CONTEXT:\n{mcp_hint}\n\n"
f"Use this verified context (schema, sample data or SQL pattern) as guidance but adapt as needed for the schema.\n"
)
print(f"[LEVEL_3] MCP hint available: {mcp_hint[:100]}...")
else:
print("[LEVEL_3] No MCP hint available, proceeding with enhanced prompt")
# Enhanced prompt with MCP hint if available
prompt = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{micro_hint_text}"
f"You are an SQL expert. Convert the natural language question into a SINGLE valid PostgreSQL query.\n"
f"Question: {question}\n"
f"Available tables: {', '.join(relevant_tables)}\n"
f"Table schemas (columns):\n{schema_text}\n"
f"Rules: Do NOT invent column names. Use existing columns from provided schemas. "
f"Use the LIMIT value requested in the question, or default to LIMIT 100; if none is mentioned. "
f"End with a semicolon. Return ONLY raw SQL.\n"
f"CRITICAL: If a hint is provided above, use it as a pattern guide but ensure it matches the actual schema."
)
try:
start_t = time.time()
raw = base_llm.complete(prompt=prompt)
gen_dur = time.time() - start_t
text_out = raw.text if hasattr(raw, 'text') else str(raw)
print(f"[LEVEL_3] LLM responded in {gen_dur:.2f}s")
# Extract SQL from code fences if present
sql_query = text_out.strip()
if '```' in sql_query:
parts = re.split(r"```(?:sql)?", sql_query, flags=re.IGNORECASE)
sql_query = '\n'.join(p for p in parts if 'select' in p.lower())
if sql_query.lower().startswith('sql'):
sql_query = sql_query[3:].strip()
# Clean and apply comprehensive fixes
sql_query = extract_clean_sql(sql_query)
sql_query = fix_common_sql_errors(sql_query)
# Additional validation and fixes
base_val = validate_sql_query(sql_query)
if not base_val['valid']:
sql_query = fix_query(sql_query, base_val['reason'])
if is_sql_valid(sql_query, question):
print(f"[LEVEL_3] ✓ Generated valid SQL with MCP guidance: {sql_query[:100]}...")
return sql_query
else:
print(f"[LEVEL_3] ✗ Generated SQL failed validation after all fixes")
return None
except Exception as e:
print(f"[LEVEL_3] ✗ Failed with error: {type(e).__name__}: {e}")
return None
def query_craft(question: str) -> str:
"""Generate SQL via LLM (no hardcoded templates) with minimal retries and semantic post-fixes.
Reconstructed clean version after refactor issues.
"""
# Make sure LLM handshake succeeded or attempt deferred handshake now
ensure_llm_ready()
print(f"[QUERY_CRAFT] Received question: {question}")
question_lower = question.lower()
# ---------------- DISABLED: Old MCP tool dispatch (replaced by 3-level architecture) -----------------
# The 3-level architecture handles MCP hints in Level 3 via get_mcp_hint_if_available()
# This old code was causing duplicate MCP calls and wasting ~31s
ENABLE_FAST_SQL = False # FORCE DISABLED to prevent duplicate MCP calls
FAST_SQL_LOG = os.environ.get("FAST_SQL_LOG", "1") not in ("0","false","False")
def _fast_log(stage: str, **kv):
if FAST_SQL_LOG:
parts = [f"{k}={str(v)[:140]}" for k,v in kv.items()]
print(f"[FAST][{stage}] " + " ".join(parts))
def classify_fast_sql_intent(q: str):
ql = q.lower()
patterns = [
# Put salary range FIRST to avoid conflicts with other patterns
(r"salary range.*department|maximum and minimum salary.*department", ("fast_sql.salary_range_department", {})),
# DISABLED - NO HARDCODED QUERIES: (r"gender pay gap|pay gap|salary gap.*(male|female)|salary gap.*department|salary.*difference.*male.*female|pay.*difference.*male.*female", ("fast_sql.gender_pay_gap", {})),
(r"highest average salary|department .*highest average salary|which department has the highest average salary", ("fast_sql.department_highest_avg_salary", {})),
# Make top paid pattern more specific to avoid false matches
(r"top \d+ .*paid|highest[- ]paid employees|top paid employees", ("fast_sql.top_paid_employees", {})),
(r"employee count by department|employees per department|how many employees .*department|count of employees .*department", ("fast_sql.employee_count_by_department", {})),
(r"list departments|show departments|what departments", ("fast_sql.list_departments", {})),
(r"how many employees|total employees|employee count", ("fast_sql.count_employees", {})),
(r"highest and lowest salary|highest.*lowest salary|lowest.*highest salary", ("fast_sql.salary_extremes", {})),
(r"department managers|list managers per department|managers of each department", ("fast_sql.department_manager_listing", {})),
]
for pat, result in patterns:
try:
m = re.search(pat, ql)
except Exception:
m = None
if m:
if result[0] == 'fast_sql.top_paid_employees':
nm = re.search(r"top\s+(\d+)", ql)
top_n = int(nm.group(1)) if nm else 10
return result[0], {"top_n": top_n, "question": q}
if result[0] == 'fast_sql.salary_range_department':
dm = re.search(r"salary range .*department (?:for|of) ([A-Za-z &'-]+)", ql)
dept_name = dm.group(1).strip().title() if dm else 'Development'
return result[0], {"department_name": dept_name, "question": q}
return result[0], {"question": q}
return None, None
micro_hint = None
if ENABLE_FAST_SQL:
tool_name, tool_args = classify_fast_sql_intent(question)
if tool_name:
_fast_log('INTENT', tool=tool_name, args=tool_args)
try:
from .mcp_client import mcp_call_tool
ctx = {"tool_call": {"name": tool_name, "args": tool_args or {}}}
start_fast = time.time()
resp = mcp_call_tool(prompt=f"(fast sql) {question}", timeout=45.0, context=ctx)
dur = round(time.time() - start_fast, 3)
tool_res = resp.get('tool_result') if isinstance(resp, dict) else None
if isinstance(tool_res, dict) and 'sql' in tool_res:
sql = tool_res['sql']
_fast_log('TOOL_SQL', ms=int(dur*1000), sql_preview=sql[:160])
# Use as a hint, not as a direct return
micro_hint = sql
else:
_fast_log('NO_SQL', raw=tool_res)
except Exception as fast_err:
_fast_log('FALLBACK', error=str(fast_err))
# EARLY SHORT-CIRCUIT for salary extremes: use as hint only
if tool_name == 'fast_sql.salary_extremes':
try:
if 'salary' in available_tables:
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
scol = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
scol = 'salary'
parts = [f"SELECT MAX(s.{scol}) AS highest_salary, MIN(s.{scol}) AS lowest_salary", "FROM salary s"]
try:
if any(c['name'] == 'to_date' for c in inspector.get_columns(table_name='salary')):
parts.append("WHERE s.to_date IS NULL")
except Exception:
pass
agg_sql = " ".join(parts)
if not agg_sql.strip().endswith(';'):
agg_sql += ';'
_fast_log('EARLY_EXTREMES', sql=agg_sql)
micro_hint = agg_sql
except Exception as early_err:
_fast_log('EARLY_EXTREMES_FAIL', err=str(early_err))
# EARLY SHORT-CIRCUIT for salary range by department: use as hint only
elif tool_name == 'fast_sql.salary_range_department':
try:
if 'salary' in available_tables and 'department' in available_tables:
dept_name = tool_args.get('department_name', 'Development')
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
scol = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
scol = 'salary'
parts = [
f"SELECT MAX(s.{scol}) AS max_salary, MIN(s.{scol}) AS min_salary",
"FROM salary s",
"JOIN employee e ON s.emp_no = e.emp_no",
"JOIN dept_emp de ON e.emp_no = de.emp_no",
"JOIN department d ON de.dept_no = d.dept_no",
f"WHERE d.dept_name = '{dept_name}'"
]
try:
if any(c['name'] == 'to_date' for c in inspector.get_columns(table_name='salary')):
parts.append("AND s.to_date IS NULL")
if any(c['name'] == 'to_date' for c in inspector.get_columns(table_name='dept_emp')):
parts.append("AND de.to_date IS NULL")
except Exception:
pass
range_sql = " ".join(parts)
if not range_sql.strip().endswith(';'):
range_sql += ';'
_fast_log('EARLY_RANGE_DEPT', sql=range_sql, dept=dept_name)
micro_hint = range_sql
except Exception as early_err:
_fast_log('EARLY_RANGE_DEPT_FAIL', err=str(early_err))
# proceed to normal path
# Infer relevant tables heuristically from question.
# Employee-keyword routing only applies to the demo employee schema; for
# user schemas we use a keyword-to-table-name matcher over the LIVE tables.
table_set = set()
if ACTIVE_SCHEMA_PROFILE.is_employee_db:
if any(k in question_lower for k in ["manager", "managers"]):
table_set.update(["department", "dept_manager", "employee"])
if any(k in question_lower for k in ["average salary", "avg salary", "salary"]):
table_set.update(["salary", "dept_emp", "department", "employee"])
if "department" in question_lower:
table_set.add("department")
if any(k in question_lower for k in ["engineer", "title", "analyst", "developer", "staff"]):
table_set.add("title")
if not table_set:
# default minimal context
table_set.add("employee")
else:
# Generic matcher: match question keywords against live table names
for t in available_tables:
t_norm = (t or "").replace('_', ' ').lower().strip()
if t_norm and len(t_norm) > 3 and t_norm.rstrip('s') in question_lower:
table_set.add(t)
# Also consider FK-reachable neighbors of matched tables
if table_set and ACTIVE_SCHEMA_PROFILE.tables:
for matched in list(table_set):
for fk in ACTIVE_SCHEMA_PROFILE.fk_by_table.get(matched, []) or []:
ref = fk.get("referred_table")
if ref and ref in available_tables:
table_set.add(ref)
if not table_set:
table_set.update(ACTIVE_SCHEMA_PROFILE.tables[:4])
# Keep only existing tables
relevant_tables = [t for t in table_set if t in available_tables]
if not relevant_tables:
relevant_tables = available_tables[:4] # small slice fallback
# Build lightweight schema snippets for relevant tables (column names only) to reduce hallucinated columns
def _collect_schema_snippets(tables: List[str], per_table_col_cap: int = 15) -> List[str]:
out: List[str] = []
try:
for tbl in tables:
try:
cols = [c['name'] for c in inspector.get_columns(table_name=tbl)]
if cols:
shown = cols[:per_table_col_cap]
out.append(f"{tbl}: {', '.join(shown)}")
except Exception:
continue
except Exception:
pass
return out
schema_snippets = _collect_schema_snippets(relevant_tables)
schema_text = "\n".join(schema_snippets) if schema_snippets else "(schema unavailable)"
# ---------------- DISABLED: Plan Phase (not needed by 3-level architecture) ----------------
# The 3-level architecture generates SQL directly without needing a separate planning step
# This was adding ~25s overhead
ENABLE_PLAN_PHASE = False # FORCE DISABLED to eliminate redundant LLM call
plan_dict: Optional[Dict[str, Any]] = None
if ENABLE_PLAN_PHASE:
plan_prompt = (
"You are a planning assistant. Given a natural language question, output ONLY JSON describing the SQL plan.\n"
"Keys: tables (list of table names), joins (list of join conditions like 'a.col = b.col'), filters (list of simple predicates),"
" group_by (list of columns), metrics (list of aggregate expressions), order_by (optional string), limit (int or null).\n"
"Do not include explanatory text. Return strict JSON only.\n"
f"Question: {question}\nJSON:"
)
try:
# Short timeout plan step to avoid long stall
_plan_start = time.time()
plan_resp = Settings.llm.complete(plan_prompt, timeout=10.0)
_plan_dur = time.time() - _plan_start
_log_gen("plan", dur_s=round(_plan_dur,2), prompt_chars=len(plan_prompt), est_tokens=_approx_tokens(plan_prompt))
raw_plan = plan_resp.text if hasattr(plan_resp, 'text') else str(plan_resp)
# Extract first JSON object
import json as _json, re as _re
m = _re.search(r"\{.*\}", raw_plan, flags=_re.DOTALL)
if m:
candidate = m.group(0)
try:
plan_dict = _json.loads(candidate)
except Exception:
plan_dict = None
# Basic validation
if plan_dict:
ptables = plan_dict.get('tables') or []
if not isinstance(ptables, list):
plan_dict = None
else:
# Ensure tables exist
cleaned = [t for t in ptables if isinstance(t, str) and t in available_tables]
if not cleaned:
plan_dict = None
else:
plan_dict['tables'] = cleaned
# Replace relevant_tables with planned subset to shrink prompt
relevant_tables = cleaned
schema_snippets = _collect_schema_snippets(relevant_tables, per_table_col_cap=12)
schema_text = "\n".join(schema_snippets) if schema_snippets else "(schema unavailable)"
print(f"[PLAN] Accepted plan tables={cleaned} joins={plan_dict.get('joins')} metrics={plan_dict.get('metrics')}")
else:
print("[PLAN][WARN] Could not parse plan JSON; continuing without plan phase")
except Exception as plan_err:
print(f"[PLAN][WARN] Plan phase failed: {plan_err}")
def _build_sql_prompt_from_plan(plan: Dict[str, Any]) -> str:
"""Construct a concise prompt using the validated plan dict."""
return (
f"You are an expert SQL generator. Use ONLY the provided plan and schema to write ONE PostgreSQL query.\n"
f"Question: {question}\n"
f"Plan tables: {', '.join(plan.get('tables', []))}\n"
f"Plan joins: {plan.get('joins', [])}\n"
f"Plan filters: {plan.get('filters', [])}\n"
f"Plan group_by: {plan.get('group_by', [])}\n"
f"Plan metrics: {plan.get('metrics', [])}\n"
f"Plan order_by: {plan.get('order_by', '')}\n"
f"Plan limit: {plan.get('limit', 100)}\n"
f"Table schemas (columns):\n{schema_text}\n"
"Rules: Do NOT invent columns. Apply all joins & filters. Ensure current rows by filtering to_date IS NULL where relevant."
" End with LIMIT 100; Return ONLY raw SQL."
)
# Lightweight hints (pure text, not SQL)
hints = []
if "manager" in question_lower:
hints.append("Join department d, dept_manager dm, employee e")
if "average" in question_lower and "salary" in question_lower:
# Check if a specific department is mentioned — give a targeted hint
_dept_names = ["marketing", "sales", "development", "research", "finance",
"human resources", "production", "quality management", "customer service"]
_mentioned_dept = next((d.title() for d in _dept_names if d in question_lower), None)
if _mentioned_dept:
hints.append(
f"Filter by specific department: FROM salary s "
f"JOIN dept_emp de ON s.emp_no = de.emp_no "
f"JOIN department d ON de.dept_no = d.dept_no "
f"WHERE d.dept_name = '{_mentioned_dept}' AND s.to_date IS NULL AND de.to_date IS NULL. "
f"dept_emp has NO dept_name column — you MUST join department d to filter by name."
)
else:
hints.append("AVG(s.salary) per department; join salary s, dept_emp de, department d; dept_emp has NO dept_name — join department d to get name")
if "count" in question_lower or "how many" in question_lower:
hints.append("Use COUNT(*) or COUNT(DISTINCT ...) with LIMIT 100")
# Explicit richer hint for employee count by department to encourage correct join early
if ("department" in question_lower and any(k in question_lower for k in ["how many", "count", "number of"]) and any(k in question_lower for k in ["employee", "employees"])):
hints.append("Join department d with dept_emp de ON d.dept_no = de.dept_no; filter current rows de.to_date IS NULL; SELECT d.dept_name, COUNT(*) AS employee_count GROUP BY d.dept_name ORDER BY employee_count DESC")
if "engineer" in question_lower:
hints.append("Filter title table for specific engineer title t.title")
query_hints = '; '.join(hints)
# Normalized title extraction (heuristic)
normalized_title = None
title_map = [
(r"senior engineers?", "Senior Engineer"),
(r"staff engineers?", "Staff Engineer"),
(r"assistant engineers?", "Assistant Engineer"),
(r"engineers?", "Engineer"),
(r"analysts?", "Analyst"),
(r"developers?", "Developer"),
]
for pat, canon in title_map:
if re.search(pat, question_lower):
normalized_title = canon
break
# Detect explicit top-paid / salary ranking questions to provide a stronger textual hint
is_salary_rank = False
try:
if re.search(r"top\s+\d+.*paid", question_lower) or re.search(r"highest[- ]paid", question_lower) or ("top" in question_lower and "paid" in question_lower):
is_salary_rank = True
except Exception:
is_salary_rank = False
salary_rank_hint = ""
if is_salary_rank or "salary" in question_lower:
col = get_salary_column_name()
salary_rank_hint = (
f"\nNote: Salaries are stored in table `salary` using column `{col}`. "
f"You MUST join `salary` s ON e.emp_no = s.emp_no. "
f"Always filter s.to_date IS NULL for current pay."
)
# Inject directly into question for maximum visibility to small models
question = f"{question}\n\n# HINT: {salary_rank_hint}"
# Quick targeted micro-generator fallback: for employee-count-by-department
# questions, try the small focused generator first to avoid multi-attempt LLM
# failures that result in the generic "Unable to generate SQL" error.
try:
try:
cat = classify_question(question)
except Exception:
cat = None
if cat == "EMP_COUNT_BY_DEPT":
try:
# Check if question asks about a SPECIFIC department by name
_dept_match = re.search(
r'(?:in|for|of|within|at)\s+(?:the\s+)?([A-Za-z\s&]+?)\s+department',
question_lower
)
_specific_dept_name = None
if _dept_match:
_candidate = _dept_match.group(1).strip().title()
# Validate against cached real department names (populated at DB init)
# This automatically excludes generic words like 'each', 'every', etc.
for _rd in _cached_dept_names:
if _candidate.lower() == _rd.lower():
_specific_dept_name = _rd # Use exact DB casing
break
if _specific_dept_name:
_specific_hint = _build_sql(
select_cols=["COUNT(DISTINCT de.emp_no) AS employee_count"],
base_table="dept_emp de",
joins=["JOIN department d ON de.dept_no = d.dept_no"],
where=[f"d.dept_name = '{_specific_dept_name}'", "de.to_date IS NULL"]
)
print(f"[MICRO-HINT] Injecting specific-department employee count hint for: {_specific_dept_name}")
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{_specific_hint}"
else:
# Generic all-departments query — use the existing micro-generator
rescue_sql = generate_employee_count_by_department_sql(question)
if rescue_sql:
print("[MICRO-HINT] Using micro-generated employee-count-by-department SQL as LLM hint only.")
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{rescue_sql}"
except Exception as micro_err:
print(f"[MICRO-HINT] Micro-generator failed: {micro_err}")
except Exception:
# Non-fatal; continue to normal LLM generation
pass
base_llm = Settings.llm
# If this is a straightforward top-N salary ranking question, try a safe micro-generator
# that builds a correct SQL using the actual inspector/schema to avoid hallucinated columns.
try:
if is_salary_rank:
from math import floor
def try_micro_top_paid():
m = re.search(r"top\s+(\d+)", question.lower())
n = int(m.group(1)) if m else 10
if 'salary' not in available_tables or 'employee' not in available_tables:
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
if 'salary' in cols:
col_name = 'salary'
elif 'amount' in cols:
col_name = 'amount'
elif cols:
col_name = cols[0]
else:
col_name = 'salary'
except Exception:
col_name = 'salary'
def _local_build_sql(select_cols, base_table, joins=None, where=None,
group_by=None, order_by=None, limit=None):
parts = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
if joins:
for j in joins:
parts.append(j)
if where:
parts.append("WHERE " + " AND ".join(where))
if group_by:
parts.append("GROUP BY " + ", ".join(group_by))
if order_by:
parts.append("ORDER BY " + order_by)
if limit:
parts.append(f"LIMIT {limit}")
sql = " ".join(parts)
if not sql.strip().endswith(';'):
sql = sql + ';'
return sql
select_cols = [f"e.emp_no", f"e.first_name", f"e.last_name", f"s.{col_name} AS salary"]
joins = ["JOIN salary s ON e.emp_no = s.emp_no"]
where = ["s.to_date IS NULL"]
order_by = f"s.{col_name} DESC"
sql = _local_build_sql(select_cols=select_cols, base_table="employee e", joins=joins, where=where, order_by=order_by, limit=n)
return sql
micro_sql = try_micro_top_paid()
if micro_sql:
print("[MICRO-HINT] Using micro-generated top-paid SQL as LLM hint only.")
# Inject as hint to LLM prompt, do not return directly
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{micro_sql}"
# Small helper to assemble SQL from runtime pieces (avoids storing static SQL templates)
def _build_sql(select_cols: List[str], base_table: str, joins: Optional[List[str]] = None,
where: Optional[List[str]] = None, group_by: Optional[List[str]] = None,
order_by: Optional[str] = None, limit: Optional[int] = None) -> str:
parts: List[str] = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
if joins:
for j in joins:
parts.append(j)
if where:
parts.append("WHERE " + " AND ".join(where))
if group_by:
parts.append("GROUP BY " + ", ".join(group_by))
if order_by:
parts.append("ORDER BY " + order_by)
if limit:
parts.append(f"LIMIT {limit}")
sql = " ".join(parts)
if not sql.strip().endswith(';'):
sql = sql + ';'
return sql
# Additional micro-generators for common question types
# 1) highest and lowest salary across all departments
if re.search(r"highest\s+and\s+lowest\s+salary|highest.*lowest|lowest.*highest", question.lower()):
def gen_high_low():
if 'salary' not in available_tables:
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = [f"MAX(s.{col}) AS highest_salary", f"MIN(s.{col}) AS lowest_salary"]
return _build_sql(select_cols=select_cols, base_table="salary s", where=["s.to_date IS NULL"], limit=None)
v = gen_high_low()
if v:
print('[MICRO-HINT] Built high/low salary micro SQL proposal (used as LLM hint only)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 2) department with highest average salary
if re.search(r"which department has the highest average salary|department.*highest average salary", question.lower()):
def gen_dept_highest_avg():
if not all(t in available_tables for t in ['department','dept_emp','salary']):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = ["d.dept_name", f"AVG(s.{col}) AS avg_salary"]
joins = ["JOIN dept_emp de ON d.dept_no = de.dept_no", "JOIN salary s ON de.emp_no = s.emp_no"]
where = ["de.to_date IS NULL", "s.to_date IS NULL"]
return _build_sql(select_cols=select_cols, base_table="department d", joins=joins, where=where, group_by=["d.dept_name"], order_by="avg_salary DESC", limit=1)
v = gen_dept_highest_avg()
if v:
print('[MICRO-HINT] Built department highest-average-salary SQL (used as LLM hint only)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# Manager gender queries: inject hint-only (L1 still runs full 3-level architecture)
# The 1.5b model hallucinates dept_name='Management' without guidance.
# Hint-only keeps 3-level intact while correcting the hallucination.
_mgr_gender = re.search(r'(female|male|woman|women|man|men)\s+managers|managers.*(female|male|gender)|how many.*(female|male).*(manager)', question.lower())
if _mgr_gender:
_gw = _mgr_gender.group(1) or _mgr_gender.group(2) or ''
_gv = 'F' if _gw in ('female', 'woman', 'women') else 'M' if _gw in ('male', 'man', 'men') else None
if _gv:
_hint_sql = (f"SELECT COUNT(DISTINCT dm.emp_no) AS manager_count "
f"FROM dept_manager dm "
f"JOIN employee e ON dm.emp_no = e.emp_no "
f"WHERE e.gender = '{_gv}' AND dm.to_date IS NULL;")
else:
_hint_sql = ("SELECT e.gender, COUNT(DISTINCT dm.emp_no) AS manager_count "
"FROM dept_manager dm "
"JOIN employee e ON dm.emp_no = e.emp_no "
"WHERE dm.to_date IS NULL "
"GROUP BY e.gender;")
print(f'[MICRO-HINT] Injecting manager gender hint (gender={_gv or "breakdown"})')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{_hint_sql}"
# 5) average salary by job title and department — hint-only (3-level architecture preserved)
if re.search(r"average salary by job title and department|average salary by title and department|avg salary.*title.*department|avg salary.*department.*title", question.lower()):
def gen_avg_salary_title_dept_hint():
required = ['title','dept_emp','department','salary']
if not all(t in available_tables for t in required):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = ["d.dept_name", "t.title", f"AVG(s.{col}) AS avg_salary"]
joins = ["JOIN dept_emp de ON t.emp_no = de.emp_no", "JOIN department d ON de.dept_no = d.dept_no", f"JOIN salary s ON t.emp_no = s.emp_no"]
where = ["t.to_date IS NULL", "de.to_date IS NULL", "s.to_date IS NULL"]
return _build_sql(select_cols=select_cols, base_table="title t", joins=joins, where=where, group_by=["d.dept_name", "t.title"], order_by="d.dept_name, avg_salary DESC", limit=100)
v = gen_avg_salary_title_dept_hint()
if v:
print('[MICRO-HINT] Injecting avg salary by title+dept hint (3-level preserved)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 6) employees of each gender in management positions — hint-only (3-level preserved)
# L1 hallucinates WHERE title='MANAGER' on employee table (title is a separate table).
# Hint guides it to use dept_manager JOIN instead.
if re.search(r"employees of each gender.*management|how many employees of each gender are in management|gender.*management position|management.*gender", question.lower()):
def gen_gender_in_management_hint():
if not all(t in available_tables for t in ['dept_manager', 'employee']):
return None
select_cols = ["e.gender", "COUNT(DISTINCT dm.emp_no) AS count_in_management"]
joins = ["JOIN employee e ON dm.emp_no = e.emp_no"]
return _build_sql(select_cols=select_cols, base_table="dept_manager dm", joins=joins, where=["dm.to_date IS NULL"], group_by=["e.gender"])
v = gen_gender_in_management_hint()
if v:
print('[MICRO-HINT] Injecting gender-in-management hint (3-level preserved)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 7a) senior staff by department — hint-only
# L1 misses: (1) title table JOIN, (2) LIKE '%Senior%' filter → returns all depts with no senior filter
if re.search(r'(most senior staff|senior staff.*department|department.*senior staff|which department.*senior|senior employee.*department)', question.lower()):
def gen_senior_staff_by_dept():
required = ['title', 'dept_emp', 'department']
if not all(t in available_tables for t in required):
return None
select_cols = ["d.dept_name", "COUNT(DISTINCT t.emp_no) AS senior_count"]
joins = ["JOIN dept_emp de ON t.emp_no = de.emp_no", "JOIN department d ON de.dept_no = d.dept_no"]
where = ["t.title LIKE '%Senior%'", "t.to_date IS NULL", "de.to_date IS NULL"]
return _build_sql(select_cols=select_cols, base_table="title t", joins=joins, where=where, group_by=["d.dept_name"], order_by="senior_count DESC", limit=100)
v = gen_senior_staff_by_dept()
if v:
print('[MICRO-HINT] Injecting senior-staff-by-dept hint (3-level preserved)')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 7b) gender pay gap per department: construct AVG male/female and gap percentage
# Extended regex to cover paraphrased phrasings — Qwen 1.5b fails on these without hints
if re.search(
r"gender pay gap|pay gap.*gender|gender.*pay gap"
r"|salary gap.*gender|salary gap.*(male|female)|salary gap.*department"
r"|salary difference.*(male|female)|salary difference.*gender"
r"|male.*female.*salary|female.*male.*salary"
r"|salary.*comparison.*(male|female|gender)"
r"|(male|female).*salary.*difference|(male|female).*pay.*difference"
r"|average salary.*gender|salary.*by gender",
question.lower()
):
def gen_gender_pay_gap():
required = ['department', 'dept_emp', 'employee', 'salary']
if not all(t in available_tables for t in required):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = [
"d.dept_name",
f"AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) AS avg_male_salary",
f"AVG(CASE WHEN e.gender = 'F' THEN s.{col} END) AS avg_female_salary",
f"(AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) - AVG(CASE WHEN e.gender = 'F' THEN s.{col} END)) AS gap",
f"CASE WHEN AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) = 0 THEN NULL ELSE ((AVG(CASE WHEN e.gender = 'M' THEN s.{col} END) - AVG(CASE WHEN e.gender = 'F' THEN s.{col} END)) / NULLIF(AVG(CASE WHEN e.gender = 'M' THEN s.{col} END),0)) * 100 END AS pct_gap"
]
joins = [
"JOIN dept_emp de ON d.dept_no = de.dept_no",
"JOIN employee e ON de.emp_no = e.emp_no",
"JOIN salary s ON e.emp_no = s.emp_no"
]
where = ["de.to_date IS NULL", "s.to_date IS NULL"]
sql = _build_sql(select_cols=select_cols, base_table="department d", joins=joins, where=where, group_by=["d.dept_name"], order_by="gap DESC", limit=100)
# Validate before using as hint
try:
val = enhanced_validate_sql(sql)
if val.get('valid'):
print(f"[MICRO-HINT] Built gender pay gap SQL using column '{col}': {sql}")
return sql
else:
print(f"[MICRO-HINT] Gender pay gap SQL failed validation: {val.get('issues')}")
return None
except Exception as e:
print(f"[MICRO-HINT] Error validating gender pay gap SQL: {e}")
return None
v = gen_gender_pay_gap()
if v:
print('[MICRO-HINT] Using micro-generated gender pay gap SQL as LLM hint only.')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{v}"
# 7c) Trend over time / over the years (temporal aggregation)
# NOTE: No hardcoded SQL hints here — the semantic clarifier at line ~3511 already rewrites
# the question with explicit EXTRACT(YEAR FROM ...) and GROUP BY instructions, which is
# sufficient for the LLM. Hardcoded hints were removed to avoid schema coupling.
# The validator fix (strip EXTRACT from table-scan) ensures valid EXTRACT queries pass.
if re.search(r"trend over the years|changed over time|over time|over the years|by year|per year", question.lower()):
print('[MICRO-HINT] Temporal trend query detected — semantic clarifier will inject EXTRACT(YEAR) instruction.')
# 8) maximum and minimum salary overall or for a specific department (user question may use max/min or maximum/minimum)
ql = question.lower()
if re.search(r'(max(imum)?\s+and\s+min(imum)?|highest\s+and\s+lowest).*salary', ql):
def gen_global_max_min():
if 'salary' not in available_tables:
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
sql = build_sql([
f"MAX(s.{col}) AS max_salary",
f"MIN(s.{col}) AS min_salary"
], base_table='salary s', where=["s.to_date IS NULL"], limit=None)
try:
val = enhanced_validate_sql(sql)
if val.get('valid'):
print('[MICRO-HINT] Built global max/min salary SQL (used as LLM hint only)')
return sql
else:
print(f"[MICRO-HINT] Global max/min salary SQL failed validation: {val.get('issues')}")
return None
except Exception as e:
print(f"[MICRO-HINT] Error validating global max/min salary SQL: {e}")
return None
# Department-specific variant if a department name appears
dept_name = None
# Collect known dept names dynamically if possible
try:
if 'department' in available_tables:
dept_rows = []
# Attempt simple query to fetch names (guarded; optional)
# Avoid asynchronous complexity here; just rely on common names fallback if fails
dept_rows = ["Development","Sales","Marketing","Finance","Human Resources","Research","Production"]
except Exception:
pass
# Heuristic extraction: look for word ending with 'ment' etc or known names
for candidate in ["development","sales","marketing","finance","human resources","research","production"]:
if candidate in ql:
dept_name = candidate.title()
if dept_name.lower() == 'Human Resources':
dept_name = 'Human Resources'
break
def gen_dept_max_min(dept):
required = ['department','dept_emp','salary']
if not all(t in available_tables for t in required):
return None
try:
cols = [c['name'] for c in inspector.get_columns(table_name='salary')]
col = 'salary' if 'salary' in cols else (cols[0] if cols else 'salary')
except Exception:
col = 'salary'
select_cols = ["d.dept_name", f"MAX(s.{col}) AS max_salary", f"MIN(s.{col}) AS min_salary"]
joins = ["JOIN dept_emp de ON d.dept_no = de.dept_no", "JOIN salary s ON de.emp_no = s.emp_no"]
where = ["de.to_date IS NULL", "s.to_date IS NULL"]
if dept:
safe_dept = dept.replace("'", "''")
where.append(f"d.dept_name = '{safe_dept}'")
sql = build_sql(select_cols=select_cols, base_table='department d', joins=joins, where=where, group_by=["d.dept_name"], limit=1 if dept else None)
try:
val = enhanced_validate_sql(sql)
if val.get('valid'):
print('[MICRO-HINT] Built dept max/min salary SQL (used as LLM hint only)')
return sql
else:
print(f"[MICRO-HINT] Dept max/min salary SQL failed validation: {val.get('issues')}")
return None
except Exception as e:
print(f"[MICRO-HINT] Error validating dept max/min salary SQL: {e}")
return None
sql_mm = gen_dept_max_min(dept_name) if dept_name else gen_global_max_min()
if sql_mm:
print('[MICRO-HINT] Using micro-generated max/min salary SQL as LLM hint only.')
question = f"{question}\n\n# HINT: The following SQL may help, but do not copy blindly.\n{sql_mm}"
except Exception:
# On any failure, fall back to normal LLM generation
pass
max_retries = 3 # Increased to 3 retries with progressive timeout increases
# Semantic clarification for ambiguous terms
# Transform ambiguous natural language into clearer, more specific terms
clarified_question = question
# "salary range" should be interpreted as "maximum and minimum salary"
if re.search(r'\bsalary range\b', question, re.IGNORECASE):
clarified_question = re.sub(r'\bsalary range\b', 'maximum and minimum salary', clarified_question, flags=re.IGNORECASE)
print(f"[SEMANTIC_CLARIFICATION] 'salary range' → 'maximum and minimum salary'")
# "pay range" should also be interpreted as "maximum and minimum salary"
if re.search(r'\bpay range\b', question, re.IGNORECASE):
clarified_question = re.sub(r'\bpay range\b', 'maximum and minimum salary', clarified_question, flags=re.IGNORECASE)
print(f"[SEMANTIC_CLARIFICATION] 'pay range' → 'maximum and minimum salary'")
# "compensation range" should be interpreted as "maximum and minimum salary"
if re.search(r'\bcompensation range\b', question, re.IGNORECASE):
clarified_question = re.sub(r'\bcompensation range\b', 'maximum and minimum salary', clarified_question, flags=re.IGNORECASE)
print(f"[SEMANTIC_CLARIFICATION] 'compensation range' -> 'maximum and minimum salary'")
# "trend over the years" / "hiring trend" / "trend by year" / "changed over time"
# should be interpreted as an annual aggregate query, NOT individual rows.
if re.search(r'\b(trend|trends|change|changed|changing)\b.{0,40}\b(year|years|annual|annually|time|history)\b', question, re.IGNORECASE) or \
re.search(r'\b(year|years|annual|time|history)\b.{0,40}\b(trend|trends|change|changed|changing)\b', question, re.IGNORECASE) or \
re.search(r'\bover time\b', question, re.IGNORECASE):
# Detect the subject (hiring vs salary)
if re.search(r'\bsalar(y|ies)\b', question, re.IGNORECASE):
clarified_question = (
f"Show the average salary per year, "
f"grouped by year using EXTRACT(YEAR FROM from_date), ordered by year. "
f"Original question: {clarified_question}"
)
elif re.search(r'\bhir(e|ed|ing)\b', question, re.IGNORECASE):
clarified_question = (
f"Show the count of employees hired per year, "
f"grouped by year using EXTRACT(YEAR FROM hire_date), ordered by year. "
f"Original question: {clarified_question}"
)
else:
clarified_question = (
f"Show the count of records per year, "
f"grouped by year using EXTRACT(YEAR FROM from_date), ordered by year. "
f"Original question: {clarified_question}"
)
print(f"[SEMANTIC_CLARIFICATION] 'trend over time' -> annual aggregate (GROUP BY year)")
# Use the clarified question for the rest of the processing
if clarified_question != question:
print(f"[SEMANTIC_CLARIFICATION] Original: {question}")
print(f"[SEMANTIC_CLARIFICATION] Clarified: {clarified_question}")
question = clarified_question
# DISABLED: Eager micro-generator (now runs lazily inside Level 2)
# This code previously ran BEFORE Level 1, wasting ~5s when Level 1 succeeded.
# Now micro-gen is called on-demand inside level_2_llm_with_fixer() via generate_micro_gen_hint()
micro_hint = None # Keep variable for backward compatibility, but always None
# # Special micro-generator for salary range in department questions
# # This should trigger early to provide the correct MIN/MAX aggregation
# if re.search(r'\b(maximum and minimum|max.*min|min.*max|salary range)\b.*\b(development|dept|department)\b', question.lower()):
# print("[MICRO-GEN] Detected salary range in department question, generating specific SQL")
# try:
# # Extract department name
# dept_name = "Development" # Default
# if "development" in question.lower():
# dept_name = "Development"
# elif "sales" in question.lower():
# dept_name = "Sales"
# elif "marketing" in question.lower():
# dept_name = "Marketing"
# elif "finance" in question.lower():
# dept_name = "Finance"
# elif "human resources" in question.lower():
# dept_name = "Human Resources"
# elif "research" in question.lower():
# dept_name = "Research"
# elif "production" in question.lower():
# dept_name = "Production"
#
# # Generate the correct SQL for salary range by department
# range_sql = (
# f"SELECT MAX(s.salary) AS max_salary, MIN(s.salary) AS min_salary "
# f"FROM salary s "
# f"JOIN employee e ON s.emp_no = e.emp_no "
# f"JOIN dept_emp de ON e.emp_no = de.emp_no "
# f"JOIN department d ON de.dept_no = d.dept_no "
# f"WHERE d.dept_name = '{dept_name}' "
# f"AND s.to_date IS NULL "
# f"AND de.to_date IS NULL;"
# )
# print(f"[MICRO-GEN] Generated salary range SQL for {dept_name}: {range_sql}")
# micro_hint = range_sql
# except Exception as e:
# print(f"[MICRO-GEN] Failed to generate salary range SQL: {e}")
# Build a salary hint prefix if needed (keeps lines tidy and avoids complex concatenation in the dict)
salary_prefix = (salary_rank_hint + "\n") if salary_rank_hint else ""
# If a micro-generated SQL is available, add it as a strong hint to the LLM prompt
micro_hint_text = f"\n🎯 CRITICAL TEMPLATE - FOLLOW EXACTLY:\n{micro_hint}\n\nThis is the EXACT SQL pattern you MUST use. Do NOT generate a different query structure. Adapt ONLY the column names if needed but keep the same aggregation logic.\n" if micro_hint else ""
# Special instructions for salary range questions
salary_range_instruction = ""
if "maximum and minimum" in question.lower() or "salary range" in question.lower():
salary_range_instruction = "\nIMPORTANT: For salary range questions, use MAX() and MIN() aggregation functions, not individual salary listings. Filter by the specific department mentioned.\n"
prompt0 = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{salary_prefix}You are an SQL expert. Convert the natural language question into a SINGLE valid PostgreSQL query.\n"
f"Question: {question}\n"
f"Available tables: {', '.join(relevant_tables)}\n"
f"Table schemas (columns):\n{schema_text}\n"
f"Hints: {query_hints if query_hints else 'Use only existing tables/columns.'}\n"
f"{salary_range_instruction}"
f"{micro_hint_text}"
"Rules: Do NOT invent column names. Prefer existing columns from provided schemas. End with LIMIT 100; and a semicolon. Return ONLY raw SQL."
"\n\nIMPORTANT: If a template SQL is provided above, follow its EXACT structure and logic. Do not generate a different query pattern."
)
prompt1 = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{salary_prefix}Question: {question}\n"
f"Tables: {', '.join(relevant_tables)} (columns listed above may have been seen)\n"
f"{micro_hint_text}"
"Return ONLY SQL (PostgreSQL) ending with LIMIT 100; Avoid inventing columns."
"\n\nCRITICAL: If a template SQL is shown above with 🎯, copy its structure exactly."
)
prompt2 = (
f"{_dynamic_schema_block(schema_text)}\n\n"
f"{micro_hint_text}"
f"SELECT -- refinement attempt\n-- Question: {question}\n-- Use ONLY actual columns. LIMIT 100 at end.\n"
"-- CRITICAL: Follow the 🎯 template structure above exactly if provided."
)
# If plan present, first attempt will use plan-specific prompt; otherwise fallback to tiered prompts
prompts = {}
if plan_dict:
prompts[0] = _build_sql_prompt_from_plan(plan_dict)
# Provide a refined shorter second attempt without full schema (adaptive shrinking)
minimal_schema_text = "\n".join(_collect_schema_snippets(relevant_tables, per_table_col_cap=6))
prompts[1] = prompts[0] + f"\n-- Minimal schema view:\n{minimal_schema_text}\n"
prompts[2] = prompt2 # fallback generic refinement
else:
prompts = {0: prompt0, 1: prompt1, 2: prompt2}
# ===============================================================================
# 3-LEVEL PROGRESSIVE ENHANCEMENT (replaces retry loop)
# ===============================================================================
# Try Level 1 → Level 2 → Level 3 in strict sequence
# Each level gets ONE attempt. No retries, no loops, no backtracking.
# ===============================================================================
base_llm = Settings.llm
ensure_llm_ready() # Make sure LLM is initialized
# Helper function to apply semantic adjusters (extracted from original retry loop)
def apply_semantic_adjusters(sql: str, q: str, norm_title) -> str:
"""Apply semantic post-fixes to generated SQL."""
sql = adjust_title_query(sql, q, norm_title)
sql = adjust_manager_query(sql, q)
sql = adjust_department_query(sql, q)
sql = adjust_avg_salary_department_query(sql, q)
# DISABLED: This was overriding LLM's correct SQL with department filters
# sql = adjust_employee_count_by_department_query(sql, q)
return sql
print("="*80)
print("[3-LEVEL ARCHITECTURE] Starting progressive SQL generation")
print("="*80)
# Level 1: Pure LLM (no assistance)
sql_query = level_1_pure_llm(
question=question,
schema_text=schema_text,
relevant_tables=relevant_tables,
base_llm=base_llm,
timeout=FIRST_ATTEMPT_TIMEOUT
)
if sql_query:
sql_query = apply_semantic_adjusters(sql_query, question, normalized_title)
print(f"[3-LEVEL] ✓ SUCCESS at Level 1 (Pure LLM)")
print(f"[GEN] Final SQL: {sql_query}")
return sql_query
print("[3-LEVEL] Level 1 failed, escalating to Level 2...")
# Level 2: LLM + Micro-gen hints + SQL Fixer (generates hint lazily)
sql_query = level_2_llm_with_fixer(
question=question,
schema_text=schema_text,
relevant_tables=relevant_tables,
base_llm=base_llm,
timeout=SECOND_ATTEMPT_TIMEOUT
)
if sql_query:
sql_query = apply_semantic_adjusters(sql_query, question, normalized_title)
print(f"[3-LEVEL] ✓ SUCCESS at Level 2 (LLM + Micro-gen + Fixer)")
print(f"[GEN] Final SQL: {sql_query}")
return sql_query
print("[3-LEVEL] Level 2 failed, escalating to Level 3 (final attempt)...")
# Level 3: LLM + MCP Hints + SQL Fixer (final escalation)
sql_query = level_3_mcp_hints_llm_fixer(
question=question,
schema_text=schema_text,
relevant_tables=relevant_tables,
base_llm=base_llm,
timeout=THIRD_ATTEMPT_TIMEOUT
)
if sql_query:
sql_query = apply_semantic_adjusters(sql_query, question, normalized_title)
print(f"[3-LEVEL] ✓ SUCCESS at Level 3 (LLM + MCP Hints + Fixer)")
print(f"[GEN] Final SQL: {sql_query}")
return sql_query
# All 3 levels failed
print("[3-LEVEL] ✗ All 3 levels failed to generate valid SQL")
# Deterministic last resort: the MCP fast-SQL hint produced during Level 3
# is schema-true and question-matched. If all LLM calls failed because of
# provider outage / exhausted credits, return it instead of erroring out.
if LAST_MCP_HINT_SQL:
try:
if is_sql_valid(LAST_MCP_HINT_SQL, question):
print("[3-LEVEL] ✓ FALLBACK: using deterministic MCP fast-SQL hint as final answer")
print(f"[GEN] Final SQL: {LAST_MCP_HINT_SQL[:200]}")
return LAST_MCP_HINT_SQL
except Exception as fb_err:
print(f"[3-LEVEL] MCP fast-SQL fallback skipped: {fb_err}")
raise ValueError("Unable to generate SQL query after 3-level attempts; try rephrasing.")
# salvage path (unreachable due to raise above, kept for reference)
def fix_query(query: str, reason: str) -> str:
"""Fix issues with a SQL query."""
# Add LIMIT if missing
if "limit" not in query.lower():
if query.strip().endswith(';'):
query = query[:-1] + " LIMIT 100;"
else:
query += " LIMIT 100"
return query
def build_sql(select_cols: List[str], base_table: str, joins: Optional[List[str]] = None,
where: Optional[List[str]] = None, group_by: Optional[List[str]] = None,
order_by: Optional[str] = None, limit: Optional[int] = None) -> str:
"""Module-level SQL builder to assemble SQL strings from components.
Use this instead of embedding literal SQL strings.
"""
parts: List[str] = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
if joins:
for j in joins:
parts.append(j)
if where:
parts.append("WHERE " + " AND ".join(where))
if group_by:
parts.append("GROUP BY " + ", ".join(group_by))
if order_by:
parts.append("ORDER BY " + order_by)
if limit:
parts.append(f"LIMIT {limit}")
sql = " ".join(parts)
if not sql.strip().endswith(';'):
sql = sql + ';'
return sql
## NOTE: Hardcoded fallback SQL function removed to honor no-hardcoded-SQL policy.
## All SQL is generated by LLM or micro-generators as hints only.
def fix_common_sql_errors(query: str) -> str:
"""Fix common SQL errors that are seen in LLM-generated queries."""
# Debug: print SQL before fixing
try:
print("[DEBUG][SQL_FIXER][BEFORE]", query)
except Exception:
pass
# Safety check: If the input doesn't look like SQL at all, return it unchanged
# This prevents the fixer from corrupting non-SQL text responses
if not query or not isinstance(query, str):
return query
query_lower = query.lower().strip()
# Check if it contains SQL keywords
sql_keywords = ['select', 'from', 'where', 'join', 'group by', 'order by', 'having', 'with']
has_sql_keywords = any(keyword in query_lower for keyword in sql_keywords)
# If it doesn't contain SQL keywords and looks like prose, return unchanged
if not has_sql_keywords and len(query.split()) > 10:
print("[DEBUG][SQL_FIXER] Input doesn't appear to be SQL, skipping fixes")
return query
import re # Ensure re is imported at the top
# Quick sanitization: remove accidental literal tokens written by LLMs such as the word
# 'semicolon' (models sometimes append the word 'semicolon' instead of a real ';').
# Also collapse duplicate semicolons and trim whitespace before semicolons.
try:
# Replace patterns like ';semicolon;' 'semicolon;' '; semicolon' or lone 'semicolon' with a single ';'
query = re.sub(r';?\s*semicolon\s*;?', ';', query, flags=re.IGNORECASE)
# Collapse repeated semicolons into a single one
query = re.sub(r';{2,}', ';', query)
# Remove spaces before semicolon
query = re.sub(r'\s+;', ';', query)
query = query.strip()
except Exception:
# If anything goes wrong here, continue without failing the sanitizer
pass
# Strip LLM explanation/commentary that appears AFTER the SQL semicolon
# Pattern: "; <blank line(s)> <explanation text>", e.g. ";\n\nThis query works as follows:"
# Also handles prose like ";\n\nNote:", ";\n\nThe above query:", etc.
try:
semi_then_blank = re.search(r';\s*\n\s*\n', query)
if semi_then_blank:
query = query[:semi_then_blank.start() + 1]
print("[DEBUG][SQL_FIXER][STRIP_EXPLANATION] Stripped LLM explanation text after semicolon")
except Exception:
pass
# Step 1: Fix table names
for singular, plural in [
("department", "departments"),
("employee", "employees"),
("salary", "salaries"),
("title", "titles"),
("dept_emp", "dept_emps"),
("dept_manager", "dept_managers")
]:
if singular in available_tables and plural in query:
query = query.replace(plural, singular)
# Step 2: Fix misnamed columns
for wrong, correct in [
("department_id", "dept_no"),
("employee_id", "emp_no"),
("manager_id", "emp_no"),
("emp_id", "emp_no"),
("dept_id", "dept_no"),
("id", "emp_no"),
("s.amount", "s.salary"), # Fix hallucinated salary column name
("salary.amount", "salary.salary"), # Fix without alias too
("s.pay", "s.salary"), # Another common hallucination
("s.wage", "s.salary"), # Another common hallucination
("e.name", "e.first_name"), # Fix employee name hallucination
("d.name", "d.dept_name"), # Fix department name hallucination (also handled elsewhere)
("t.name", "t.title") # Fix title name hallucination
]:
if wrong in query:
query = query.replace(wrong, correct)
# Step 2b: Fix dept_no used with a department NAME value instead of a dept code
# e.g. "WHERE dept_no = 'Sales'" → "WHERE dept_no IN (SELECT dept_no FROM department WHERE dept_name = 'Sales')"
# Department codes look like 'd001'...'d009'. If the value is a word, it's a name not a code.
try:
dept_no_name_pattern = re.search(
r"\bdept_no\s*=\s*'([A-Za-z][A-Za-z\s&]*)'",
query, re.IGNORECASE
)
if dept_no_name_pattern:
bad_value = dept_no_name_pattern.group(1)
# Only fix if it looks like a dept name (not already a code like 'd007')
if not re.match(r'^d\d+$', bad_value.strip(), re.IGNORECASE):
correct_subquery = f"dept_no IN (SELECT dept_no FROM department WHERE dept_name = '{bad_value}')"
query = re.sub(
r"\bdept_no\s*=\s*'" + re.escape(bad_value) + r"'",
correct_subquery,
query, flags=re.IGNORECASE
)
print(f"[DEBUG][SQL_FIXER][DEPT_NO_NAME_FIX] Replaced dept_no = '{bad_value}' with subquery lookup")
except Exception:
pass
# Step 3: JOIN syntax repairs (pure regex replacements, no external state)
join_fix_patterns = [
(r'JOIN\s+([a-z][a-z0-9_]*)\s+ON\s+JOIN\.([a-z][a-z0-9_]*)', r'JOIN \1 ON \1.\2'),
(r'JOIN\s+([a-z][a-z0-9_]*)\s+ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*JOIN\.([a-z][a-z0-9_]*)', r'JOIN \1 ON \2.\3 = \1.\4'),
(r'JOIN\.([a-z][a-z0-9_]*)', r'd.\1')
]
for pattern, replacement in join_fix_patterns:
try:
query = re.sub(pattern, replacement, query, flags=re.IGNORECASE)
except Exception:
pass
# Step 4: alias normalization - fix the broken pattern that creates "s AS s"
print("[DEBUG][SQL_FIXER][ALIAS_NORM][BEFORE]", query)
# First, fix any existing "table alias AS alias" patterns to just "table alias"
# This fixes patterns like "FROM salary s AS s" -> "FROM salary s"
query = re.sub(r'\b(\w+)\s+(\w+)\s+AS\s+\2\b', r'\1 \2', query, flags=re.IGNORECASE)
# Then normalize "FROM table AS alias" to "FROM table alias"
for table, alias in [("department","d"),("employee","e"),("salary","s"),("dept_emp","de"),("dept_manager","dm"),("titles","t")]:
query = re.sub(rf'\bFROM\s+{table}\s+AS\s+{alias}\b', f'FROM {table} {alias}', query, flags=re.IGNORECASE)
query = re.sub(rf'\bJOIN\s+{table}\s+AS\s+{alias}\b', f'JOIN {table} {alias}', query, flags=re.IGNORECASE)
if f"{table} {alias}" in query and f"{table}." in query:
print(f"[DEBUG][SQL_FIXER][ALIAS_NORM][REPLACE] {table}. -> {alias}.")
query = query.replace(f"{table}.", f"{alias}.")
if f"{table} AS {alias}" in query and f"{table}." in query:
print(f"[DEBUG][SQL_FIXER][ALIAS_NORM][REPLACE] {table}. -> {alias}.")
query = query.replace(f"{table}.", f"{alias}.")
print("[DEBUG][SQL_FIXER][ALIAS_NORM][AFTER]", query)
# (Removed self-join fixer logic; reverted to previous state)
# Step 5: to_date misuse corrections
if "employee" in query and "e.to_date" in query:
if "salary s" in query or "s.salary" in query:
query = query.replace("e.to_date = '9999-01-01'", "s.to_date = '9999-01-01'")
elif "dept_emp de" in query or "de.dept_no" in query:
query = query.replace("e.to_date = '9999-01-01'", "de.to_date = '9999-01-01'")
elif "dept_manager dm" in query or "dm.dept_no" in query:
query = query.replace("e.to_date = '9999-01-01'", "dm.to_date = '9999-01-01'")
if "department" in query.lower():
# Robustly remove to_date from department table as it doesn't exist there
# Handles d.to_date, department.to_date, and various whitespace/casing
for dept_ref in ["d", "department"]:
# Pattern: AND/WHERE [alias.]to_date = '9999-01-01'
# 1. Handle as part of a larger WHERE clause (with AND)
query = re.sub(rf"\bAND\s+{dept_ref}\.to_date\s*=\s*'9999-01-01'", "", query, flags=re.IGNORECASE)
query = re.sub(rf"\b{dept_ref}\.to_date\s*=\s*'9999-01-01'\s+AND\b", "", query, flags=re.IGNORECASE)
# 2. Handle as the only clause in WHERE
query = re.sub(rf"\bWHERE\s+{dept_ref}\.to_date\s*=\s*'9999-01-01'", "", query, flags=re.IGNORECASE)
# If we removed the only WHERE clause, we might have leftover spacing issues
query = re.sub(r"\bWHERE\s*LIMIT\b", "LIMIT", query, flags=re.IGNORECASE)
query = re.sub(r"\bWHERE\s*GROUP\b", "GROUP", query, flags=re.IGNORECASE)
query = re.sub(r"\bWHERE\s*ORDER\b", "ORDER", query, flags=re.IGNORECASE)
query = re.sub(r"\bWHERE\s*;", ";", query, flags=re.IGNORECASE)
# Early detection: pure salary extremes aggregate (avoid over-sanitizing)
_q_lower = query.lower().strip()
simple_extremes = False
if _q_lower.startswith('select') and 'min(' in _q_lower and 'max(' in _q_lower and ' from salary' in _q_lower and ' join ' not in _q_lower:
simple_extremes = True
# Step 6: ensure a conservative LIMIT to protect UI if missing (skip for pure aggregate extremes)
try:
has_limit = re.search(r"\blimit\s+\d+\b", query, flags=re.IGNORECASE) is not None
except Exception:
has_limit = " limit " in query.lower()
FORCE_LIMIT = os.environ.get("FORCE_LIMIT_APPEND", "0") in ("1","true","True")
if (FORCE_LIMIT or (not simple_extremes)) and "select" in query.lower() and not has_limit:
if query.strip().endswith(';'):
query = query[:-1] + " LIMIT 100;"
else:
if not query.strip().endswith(';'):
query = query.rstrip(';')
query += " LIMIT 100;"
# Collapse duplicate LIMITs that may already exist due to earlier generations
try:
query = re.sub(r"(LIMIT\s+\d+)(?:\s+LIMIT\s+\d+)+", r"\1", query, flags=re.IGNORECASE)
except Exception:
pass
# Inject missing alias t if needed
if re.search(r"from\s+title\b", query, re.IGNORECASE) and re.search(r"t\.(?:emp_no|title|to_date)", query, re.IGNORECASE) and not re.search(r"from\s+title\s+(?:as\s+)?t\b", query, re.IGNORECASE):
query = re.sub(r"from\s+title\b", "FROM title t", query, flags=re.IGNORECASE)
if not query.strip().endswith(';'):
query += ";"
# Secondary duplicate LIMIT cleanup in case edits added a new one before semicolon
try:
query = re.sub(r"(LIMIT\s+\d+)(?:\s+LIMIT\s+\d+)+", r"\1", query, flags=re.IGNORECASE)
except Exception:
pass
print("[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][BEFORE]", query)
if "dde." in query:
print("[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][REPLACE] dde. -> de.")
query = query.replace("dde.", "de.")
for original, typo in [("de.","dee."),("de.","dp."),("dm.","ddm."),("dm.","dmm."),("e.","ee."),("e.","emp."),("d.","dd."),("d.","dep."),("s.","ss."),("s.","sal.")]:
if typo in query:
print(f"[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][REPLACE] {typo} -> {original}")
query = query.replace(typo, original)
for wrong, right in [("department_id","dept_no"),("employee_id","emp_no"),("manager_id","emp_no")]:
if wrong in query:
print(f"[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][REPLACE] {wrong} -> {right}")
query = query.replace(wrong, right)
print("[DEBUG][SQL_FIXER][ARTIFACT_CLEANUP][AFTER]", query)
# Step 7: JOIN/ON artifact cleanup
if " ON." in query:
query = re.sub(r'([a-z][a-z0-9_]*)\s+ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*ON\.([a-z][a-z0-9_]*)', r'\1 ON \2.\3 = \1.\4', query, flags=re.IGNORECASE)
if " ON." in query:
for alias in ["dm","de","e","d","s","t"]:
if f" {alias} ON" in query and f" ON.{alias}" not in query:
query = query.replace(" ON.", f" {alias}.")
if "JOIN." in query:
if "JOIN dept_manager dm ON JOIN.dept_no = dm.dept_no" in query:
query = query.replace("JOIN dept_manager dm ON JOIN.dept_no = dm.dept_no", "JOIN dept_manager dm ON d.dept_no = dm.dept_no")
if "department d\nJOIN\n dept_manager dm ON JOIN.dept_no = dm.dept_no" in query:
query = query.replace("department d\nJOIN\n dept_manager dm ON JOIN.dept_no = dm.dept_no", "department d\nJOIN\n dept_manager dm ON d.dept_no = dm.dept_no")
if "department d\nJOIN\n dept_manager dm ON JOIN." in query:
query = query.replace("department d\nJOIN\n dept_manager dm ON JOIN.", "department d\nJOIN\n dept_manager dm ON d.")
query = re.sub(r'([a-z][a-z0-9_]*)\s+ON\s+JOIN\.([a-z][a-z0-9_]*)\s*=\s*([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)', r'\1 ON \1.\2 = \3.\4', query, flags=re.IGNORECASE)
query = re.sub(r'([a-z][a-z0-9_]*)\s+ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*JOIN\.([a-z][a-z0-9_]*)', r'\1 ON \2.\3 = \1.\4', query, flags=re.IGNORECASE)
if "JOIN." in query:
tables_with_aliases = re.findall(r'([a-z][a-z0-9_]*)\s+([a-z][a-z0-9_]*)(?=\s+ON\s+JOIN\.)', query, flags=re.IGNORECASE)
if tables_with_aliases:
last_alias = tables_with_aliases[-1][1]
query = query.replace("JOIN.", f"{last_alias}.")
else:
for alias in ["dm","de","e","d","s","t"]:
if f"JOIN {alias}" in query:
query = query.replace("JOIN.", f"{alias}.")
if "JOIN." in query:
query = query.replace("JOIN.", "d.")
# REMOVED: Duplicate to_date correction logic (already handled in Step 5 above)
print("[DEBUG][SQL_FIXER][AFTER_TODATE_CORRECTION]", query)
# Add to_date conditions where appropriate based on the schema knowledge
# For any salary-related query that's missing the to_date condition
if "salary" in query.lower() and "s." in query and "s.to_date" not in query:
# If we're joining with employee table
if "employee e" in query or "e.emp_no" in query:
# Replace any incorrect e.to_date reference
if "e.to_date" in query:
query = query.replace("e.to_date = '9999-01-01'", "s.to_date = '9999-01-01'")
# Add missing to_date condition based on query structure
elif "WHERE" in query.upper():
query = re.sub(r'\bWHERE\b', "WHERE s.to_date = '9999-01-01' AND ", query, count=1, flags=re.IGNORECASE)
elif "LIMIT" in query.upper():
query = re.sub(r'\bLIMIT\b', "WHERE s.to_date = '9999-01-01' LIMIT", query, count=1, flags=re.IGNORECASE)
else:
query += " WHERE s.to_date = '9999-01-01'"
# Similarly for dept_emp table with 'de' alias
if "dept_emp" in query.lower() and "de." in query and "de.to_date" not in query:
if "WHERE" in query.upper():
query = re.sub(r'\bWHERE\b', "WHERE de.to_date = '9999-01-01' AND ", query, count=1, flags=re.IGNORECASE)
elif "LIMIT" in query.upper():
query = re.sub(r'\bLIMIT\b', "WHERE de.to_date = '9999-01-01' LIMIT", query, count=1, flags=re.IGNORECASE)
else:
query += " WHERE de.to_date = '9999-01-01'"
print("[DEBUG][SQL_FIXER][AFTER_DEPTEMP_TODATE]", query)
# Similarly for dept_manager table with 'dm' alias
if "dept_manager" in query.lower() and "dm." in query and "dm.to_date" not in query:
if "WHERE" in query.upper():
query = re.sub(r'\bWHERE\b', "WHERE dm.to_date = '9999-01-01' AND ", query, count=1, flags=re.IGNORECASE)
elif "LIMIT" in query.upper():
query = re.sub(r'\bLIMIT\b', "WHERE dm.to_date = '9999-01-01' LIMIT", query, count=1, flags=re.IGNORECASE)
else:
query += " WHERE dm.to_date = '9999-01-01'"
# Deduplicate repeated to_date conditions (e.g. AND s.to_date = '9999-01-01' AND s.to_date = '9999-01-01')
for alias in ['s', 'de', 'dm', 'e', 'd']:
dup_pattern = rf"(AND\s+{alias}\.to_date\s*=\s*'9999-01-01'\s*)(?:AND\s+{alias}\.to_date\s*=\s*'9999-01-01'\s*)+"
query = re.sub(dup_pattern, rf"AND {alias}.to_date = '9999-01-01' ", query, flags=re.IGNORECASE)
# Check for and fix unclosed quotes in date conditions
if "to_date=" in query:
# Look for unclosed date quotes with LIMIT inside the string
if "'9999-01-01 LIMIT" in query:
query = query.replace("'9999-01-01 LIMIT", "'9999-01-01' LIMIT")
# General check for other variations
for date_format in ["'9999-01-01", "'9999-01-01 AND", "'9999-01-01 WHERE", "'9999-01-01 GROUP", "'9999-01-01 ORDER"]:
if date_format in query and date_format + "'" not in query:
query = query.replace(date_format, date_format + "'")
# If there are multiple to_date conditions, ensure they all have closing quotes
if "to_date='9999-01-01 AND" in query:
query = query.replace("to_date='9999-01-01 AND", "to_date='9999-01-01' AND")
# Fix inconsistent table aliases (e.g., "dept_emp" used after "dept_emp de" was defined)
if "dept_emp de" in query and "dept_emp." in query:
query = query.replace("dept_emp.", "de.")
if "employee e" in query and "employee." in query:
query = query.replace("employee.", "e.")
if "department d" in query and "department." in query:
query = query.replace("department.", "d.")
if "salary s" in query and "salary." in query:
query = query.replace("salary.", "s.")
if "dept_manager dm" in query and "dept_manager." in query:
query = query.replace("dept_manager.", "dm.")
if "titles t" in query and "titles." in query:
query = query.replace("titles.", "t.")
# Self-join salary alias fix: only if detected, otherwise leave correct joins untouched
import re
salary_self_join_pattern = r'JOIN\s+salary\s+s\s+ON\s+s\.emp_no\s*=\s*s\.emp_no'
deptemp_self_join_pattern = r'JOIN\s+dept_emp\s+(?:AS\s+)?de\s+ON\s+de\.emp_no\s*=\s*de\.emp_no'
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_CHECK] Input query: {query[:200]}...')
# Only apply fix if self-join is present
if re.search(salary_self_join_pattern, query, re.IGNORECASE):
print('[DEBUG][SQL_FIXER][SELF_JOIN_SALARY] Found salary self-join pattern')
emp_alias_match = re.search(r'FROM\s+employee\s+(\w+)|JOIN\s+employee\s+(\w+)', query, re.IGNORECASE)
emp_alias = (emp_alias_match.group(1) or emp_alias_match.group(2)) if emp_alias_match else None
if emp_alias:
query = re.sub(salary_self_join_pattern, f'JOIN salary s ON s.emp_no = {emp_alias}.emp_no', query, flags=re.IGNORECASE)
print('[DEBUG][SQL_FIXER][SELF_JOIN_SALARY] Fixed self-join: replaced with join to employee alias')
else:
print('[DEBUG][SQL_FIXER][SELF_JOIN_SALARY] No salary self-join pattern found, skipping fix')
# Only fix if the join is exactly de.emp_no = de.emp_no and s.emp_no = de.emp_no is not already present
if re.search(deptemp_self_join_pattern, query, re.IGNORECASE):
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Found dept_emp self-join pattern')
# Check if a correct join already exists
correct_join_exists = re.search(r'JOIN\s+dept_emp\s+(?:AS\s+)?de\s+ON\s+s\.emp_no\s*=\s*de\.emp_no', query, re.IGNORECASE)
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Correct join exists: {bool(correct_join_exists)}')
if not correct_join_exists:
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] No correct join found, applying fix')
sal_alias_match = re.search(r'FROM\s+salary\s+(\w+)|JOIN\s+salary\s+(\w+)', query, re.IGNORECASE)
sal_alias = (sal_alias_match.group(1) or sal_alias_match.group(2)) if sal_alias_match else None
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Found salary alias: {sal_alias}')
if sal_alias:
old_query = query
query = re.sub(deptemp_self_join_pattern, f'JOIN dept_emp de ON {sal_alias}.emp_no = de.emp_no', query, flags=re.IGNORECASE)
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Fixed self-join: replaced with join to salary alias')
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] BEFORE: {old_query}')
print(f'[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] AFTER: {query}')
else:
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] Correct join already present, no fix applied')
else:
print('[DEBUG][SQL_FIXER][SELF_JOIN_DEPTEMP] No self-join pattern found, skipping fix')
print(f'[DEBUG][SQL_FIXER][POST_SELF_JOIN] {query[:100]}...')
# If no self-join, do not touch correct joins
# Fix incorrect MIN/MAX alias syntax errors
if "MIN(" in query or "MAX(" in query:
# Fix syntax error with column = aggregate instead of AS alias
if "=" in query and ("MIN" in query or "MAX" in query):
import re
# Pattern: column_name = MIN(...)
query = re.sub(r'([a-zA-Z0-9_\.]+)\s*=\s*MIN\(([^)]+)\)', r'MIN(\2) AS \1_min', query)
query = re.sub(r'([a-zA-Z0-9_\.]+)\s*=\s*MAX\(([^)]+)\)', r'MAX(\2) AS \1_max', query)
# Pattern: table.column_name = MIN(...)
query = re.sub(r'([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*=\s*MIN\(([^)]+)\)', r'MIN(\3) AS \2_min', query)
query = re.sub(r'([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*=\s*MAX\(([^)]+)\)', r'MAX(\3) AS \2_max', query)
# Fix incorrect MIN/MAX alias syntax (column = aggregate_function instead of alias = aggregate_function)
if "MIN(" in query or "MAX(" in query:
# Look for pattern: table.column = MIN(...)
import re
query = re.sub(r'([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*=\s*(MIN|MAX)\(', r'\3(\1.\2) AS \2_\3', query)
# Also catch: column = MIN(...) without table prefix
query = re.sub(r'([a-zA-Z0-9_]+)\s*=\s*(MIN|MAX)\(', r'\2(\1) AS \1_\2', query)
# Fix GROUP BY queries that have COUNT in ORDER BY but not in SELECT
if "GROUP BY" in query.upper() and "ORDER BY" in query.upper() and "COUNT" in query.upper():
import re
# Check if COUNT is in ORDER BY but not in SELECT
if "ORDER BY COUNT" in query.upper() and "COUNT" not in query.upper().split("FROM")[0]:
# Extract the COUNT expression from ORDER BY
count_match = re.search(r'ORDER BY\s+COUNT\s*\(\s*([^)]+)\s*\)', query, re.IGNORECASE)
if count_match:
count_expr = count_match.group(1)
# Add the count to the SELECT clause
query = re.sub(
r'SELECT\s+(.+?)\s+FROM',
rf'SELECT \1, COUNT({count_expr}) AS employee_count FROM',
query,
flags=re.IGNORECASE
)
# Update the ORDER BY to use the alias
query = re.sub(
r'ORDER BY\s+COUNT\s*\([^)]+\)',
r'ORDER BY employee_count',
query,
flags=re.IGNORECASE
)
# Fix missing GROUP BY for aggregate queries (PostgreSQL strict mode)
if any(agg in query.upper() for agg in ['AVG(', 'SUM(', 'COUNT(', 'MAX(', 'MIN(']):
select_match = re.search(r'SELECT\s+(.*?)\s+FROM', query, re.IGNORECASE | re.DOTALL)
if select_match:
select_clause = select_match.group(1)
# Remove aggregate functions to find non-aggregated columns
remaining_select = re.sub(r'(COUNT|SUM|AVG|MAX|MIN)\s*\([^)]*\)', '', select_clause, flags=re.IGNORECASE)
remaining_select = re.sub(r'\s*AS\s+[a-z][a-z0-9_]*', '', remaining_select, flags=re.IGNORECASE)
# Find alias.column patterns first (e.g. d.dept_name, e.first_name)
dot_columns = re.findall(r'([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)', remaining_select, re.IGNORECASE)
# Also find bare column names (e.g. title, dept_name) for queries without alias prefix
_GBY_KW = {'where','group','order','limit','having','join','on','and','or','not',
'in','as','by','select','from','set','distinct','case','when','then',
'else','end','null','true','false','is','like'}
bare_columns = [t for t in re.findall(r'\b([a-z][a-z0-9_]*)\b', remaining_select, re.IGNORECASE)
if t.lower() not in _GBY_KW and not t.isdigit()]
columns = dot_columns if dot_columns else bare_columns
if columns and 'GROUP BY' not in query.upper():
# Deduplicate preserving order
seen = []
for c in columns:
if c.upper() not in [x.upper() for x in seen]:
seen.append(c)
group_cols = ', '.join(seen)
# Find insertion point: before ORDER BY, LIMIT, or semicolon
insertion_point = len(query)
for clause in [r'\bORDER\s+BY\b', r'\bLIMIT\b']:
m = re.search(clause, query, re.IGNORECASE)
if m and m.start() < insertion_point:
insertion_point = m.start()
semi = re.search(r'\s*;', query)
if semi and semi.start() < insertion_point:
insertion_point = semi.start()
query = query[:insertion_point] + f' GROUP BY {group_cols} ' + query[insertion_point:]
print(f"[DEBUG][SQL_FIXER][GROUP_BY_ADDED] Added GROUP BY {group_cols}")
# Fix incorrect date formatting in conditions
date_formats = [
(r"'(\d{4}/\d{2}/\d{2})'", lambda m: f"'{m.group(1).replace('/', '-')}'"), # YYYY/MM/DD to YYYY-MM-DD
(r"'(\d{2}/\d{2}/\d{4})'", lambda m: f"'{m.group(1)[6:]}-{m.group(1)[0:2]}-{m.group(1)[3:5]}'"), # MM/DD/YYYY to YYYY-MM-DD
]
for pattern, replacement in date_formats:
query = re.sub(pattern, lambda m: replacement(m), query)
# Extract all table alias definitions early to avoid UnboundLocalError
# Pattern matches table aliases only in FROM and JOIN clauses
alias_pattern = r'(?:FROM|JOIN)\s+([a-z][a-z0-9_]*)\s+(?:AS\s+)?([a-z][a-z0-9_]*)'
aliases = {}
for match in re.finditer(alias_pattern, query, re.IGNORECASE):
table_name, alias = match.groups()
if alias.lower() not in ['where', 'group', 'order', 'limit', 'on', 'join', 'from']:
aliases[table_name.lower()] = alias.lower()
# Extract all table alias definitions early to avoid UnboundLocalError
# Pattern matches table aliases only in FROM and JOIN clauses
alias_pattern = r'(?:FROM|JOIN)\s+([a-z][a-z0-9_]*)\s+(?:AS\s+)?([a-z][a-z0-9_]*)'
aliases = {}
for match in re.finditer(alias_pattern, query, re.IGNORECASE):
table_name, alias = match.groups()
if alias.lower() not in ['where', 'group', 'order', 'limit', 'on', 'join', 'from']:
aliases[table_name.lower()] = alias.lower()
# Fix wrong table aliases in JOIN conditions
if "JOIN" in query.upper():
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX_START] {query[:150]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Found aliases: {aliases}')
# Now check for wrong JOIN conditions
for table, alias in aliases.items():
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Processing table={table}, alias={alias}')
# Pattern for incorrect join conditions like: ON table.column = other_alias.column
incorrect_join = rf'ON\s+{table}\.([a-z][a-z0-9_]*)\s*=\s*([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)'
old_query = query
query = re.sub(incorrect_join, f'ON {alias}.\\1 = \\2.\\3', query, flags=re.IGNORECASE)
if old_query != query:
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Applied incorrect_join fix: {table} -> {alias}')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] BEFORE: {old_query[old_query.find("JOIN"):old_query.find("JOIN")+80]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] AFTER: {query[query.find("JOIN"):query.find("JOIN")+80]}...')
# Pattern for the reverse direction
incorrect_join_reverse = rf'ON\s+([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)\s*=\s*{table}\.([a-z][a-z0-9_]*)'
old_query2 = query
query = re.sub(incorrect_join_reverse, f'ON \\1.\\2 = {alias}.\\3', query, flags=re.IGNORECASE)
if old_query2 != query:
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] Applied incorrect_join_reverse fix: {table} -> {alias}')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] BEFORE: {old_query2[old_query2.find("JOIN"):old_query2.find("JOIN")+80]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX] AFTER: {query[query.find("JOIN"):query.find("JOIN")+80]}...')
print(f'[DEBUG][SQL_FIXER][ALIAS_FIX_END] {query[:150]}...')
# Fix common table name/alias confusion in JOIN clauses (like "dept_manager.dept_no" instead of "dm.dept_no")
if "JOIN" in query and any(full_table in query for full_table in ["department.", "dept_manager.", "dept_emp.", "employee.", "salary.", "titles."]):
# Replace full table names with their aliases in JOIN conditions
# Use found aliases from aliases dict, falling back to defaults if not found
mapping = {
"department": aliases.get("department", "d"),
"dept_manager": aliases.get("dept_manager", "dm"),
"dept_emp": aliases.get("dept_emp", "de"),
"employee": aliases.get("employee", "e"),
"salary": aliases.get("salary", "s"),
"titles": aliases.get("titles", "t")
}
for table, alias in mapping.items():
if f"{table}." in query:
# Don't replace in the FROM or JOIN clauses where the alias is defined
parts = re.split(r'(FROM\s+|JOIN\s+)', query, flags=re.IGNORECASE)
result = []
for i, part in enumerate(parts):
if i > 0 and (parts[i-1].upper() == "FROM " or parts[i-1].upper() == "JOIN "):
# Check if this part contains the definition of an alias for this table
# e.g. "employee AS T1" or "employee T1"
alias_def = re.match(rf'^{table}\s+(?:AS\s+)?(\w+)', part, re.IGNORECASE)
if alias_def:
# This is where the alias is defined, don't replace here
result.append(part)
else:
result.append(part.replace(f"{table}.", f"{alias}."))
else:
result.append(part.replace(f"{table}.", f"{alias}."))
query = ''.join(result)
# Optional: allow disabling aggressive artifact heuristics (some regressions observed)
if os.environ.get("DISABLE_SANITIZER_ARTIFACT_HEURISTICS", "0") in ("1","true","True"):
# Skip the more invasive JOIN./ON. artifact rewrites
pass
else:
# Final cleanup - ensure no remaining JOIN. syntax errors with smarter mapping
if "JOIN." in query or " ON." in query:
try:
# Build alias preference ordering
alias_candidates = []
if re.search(r'\bdept_emp\s+de\b', query, re.IGNORECASE):
alias_candidates.append('de')
if re.search(r'\bdept_manager\s+dm\b', query, re.IGNORECASE):
alias_candidates.append('dm')
if re.search(r'\bemployee\s+e\b', query, re.IGNORECASE):
alias_candidates.append('e')
if re.search(r'\bdepartment\s+d\b', query, re.IGNORECASE):
alias_candidates.append('d')
if re.search(r'\bsalary\s+s\b', query, re.IGNORECASE):
alias_candidates.append('s')
# Fallback default alias if nothing else
if not alias_candidates:
alias_candidates = ['d','e','s','de','dm']
def _map_join_artifact(m):
col = m.group(1)
return f"{alias_candidates[0]}.{col}"
# Replace join.col or ON.col artifacts
query = re.sub(r'\bjoin\.([a-z][a-z0-9_]*)', lambda m: _map_join_artifact(m), query, flags=re.IGNORECASE)
query = re.sub(r'\bON\.([a-z][a-z0-9_]*)', lambda m: _map_join_artifact(m), query, flags=re.IGNORECASE)
# Fix pathological ON alias self-compare: ON join.emp_no = join.emp_no -> try map to distinct aliases
query = re.sub(r'ON\s+join\.([a-z0-9_]+)\s*=\s*join\.([a-z0-9_]+)', lambda m: f"ON {alias_candidates[0]}.{m.group(1)} = {alias_candidates[1] if len(alias_candidates)>1 else alias_candidates[0]}.{m.group(2)}", query, flags=re.IGNORECASE)
except Exception:
pass
# Smart alias injection: only if table is present WITHOUT ANY ALIAS
# Fix for the "de AS T2" bug: ensure we don't inject 'de' if 'T2' or any other alias is already there
for table, default_alias in [("salary", "s"), ("dept_emp", "de"), ("employee", "e"), ("department", "d")]:
if table in query.lower() and table.lower() not in aliases:
# If table is mentioned but not in our alias map, it might be unaliased
# Use a regex that checks if the table name is followed by something that looks like an alias
# We only want to inject if it's "FROM table" or "JOIN table" followed by a keyword or punctuation
pattern = rf'(FROM|JOIN)\s+{table}\b(?!\s+(?:AS\s+)?\w+)'
query = re.sub(pattern, rf'\1 {table} {default_alias}', query, flags=re.IGNORECASE)
# Fix the specific T1.salary / employee.salary hallucination
# If 'salary' is in SELECT but NOT in FROM/JOIN, we MUST join it.
if re.search(r'SELECT\b.*?\.salary\b', query, re.IGNORECASE | re.DOTALL) and 'salary' not in query.lower():
# Find the alias used for salary, e.g. T1.salary -> T1
sal_alias_match = re.search(r'([a-zA-Z0-9_]+)\.salary', query, re.IGNORECASE)
if sal_alias_match:
bad_alias = sal_alias_match.group(1)
# If this alias is actually mapped to 'employee', we need to fix it
if aliases.get('employee') == bad_alias or bad_alias.lower() == 'e':
# Find a good place to inject the join
if 'FROM employee' in query:
query = query.replace('FROM employee', f'FROM employee {bad_alias} JOIN salary s ON {bad_alias}.emp_no = s.emp_no')
elif f'FROM employee {bad_alias}' in query:
query = query.replace(f'FROM employee {bad_alias}', f'FROM employee {bad_alias} JOIN salary s ON {bad_alias}.emp_no = s.emp_no')
query = query.replace(f'{bad_alias}.salary', 's.salary')
print(f"[DEBUG][SQL_FIXER][SALARY_HALLUCINATION] Fixed {bad_alias}.salary by joining salary table")
# Fix the specific d.dept_name / department.dept_name hallucination
# If 'd.' or 'dept_name' is used but 'department' is NOT in FROM/JOIN, we MUST join it.
if re.search(r'\bd\.dept_name\b', query, re.IGNORECASE) and 'department' not in query.lower():
# If we already have a join to dept_emp, we can join department d
if 'dept_emp' in query.lower():
de_alias = aliases.get('dept_emp', 'de')
if f'JOIN department d' not in query:
query = query.replace(f'LIMIT', f'JOIN department d ON {de_alias}.dept_no = d.dept_no LIMIT')
print(f"[DEBUG][SQL_FIXER][DEPT_HALLUCINATION] Fixed d.dept_name by joining department table")
else:
# If no dept_emp, we need a cross join or similar?
# Better to just add a standard join sequence if it's a global query
pass
# Only repair salary.emp_no = dept_emp.emp_no if NOT inside an ON clause (avoid rewriting valid joins)
# This is a very conservative fix: only replace if the pattern is NOT inside an ON ... = ... clause
# Do NOT replace all salary.emp_no or dept_emp.emp_no globally!
# Instead, only fix the specific case of a direct, unaliased join condition outside ON clauses
# (This disables the destructive rewrite for all other cases)
# Simple extremes pattern: SELECT MIN(salary), MAX(salary) FROM salary -> ensure alias usage
if re.search(r'SELECT\s+MIN\(salary\)\s*,\s*MAX\(salary\)\s+FROM\s+salary(?!\s+(?:AS\s+)?s\b)', query, re.IGNORECASE):
query = re.sub(r'FROM\s+salary(?!\s+(?:AS\s+)?s\b)', 'FROM salary s', query, flags=re.IGNORECASE)
query = re.sub(r'MIN\(salary\)', 'MIN(s.salary)', query, flags=re.IGNORECASE)
query = re.sub(r'MAX\(salary\)', 'MAX(s.salary)', query, flags=re.IGNORECASE)
# Fix leftover join.emp_no occurrences after mapping (only if not part of a valid ON clause)
# This is now a no-op unless a clear artifact remains, so we keep it for legacy cases only
if re.search(r'\bjoin\.emp_no\b', query, re.IGNORECASE):
query = re.sub(r'\bjoin\.emp_no\b', 's.emp_no', query, flags=re.IGNORECASE)
# Department naming normalization & column correction
# Normalize capitalized Department references
query = re.sub(r'FROM\s+Department\b', 'FROM department', query, flags=re.IGNORECASE)
query = re.sub(r'JOIN\s+Department\b', 'JOIN department', query, flags=re.IGNORECASE)
# Standardize alias to d (if uppercase D used)
query = re.sub(r'from\s+department\s+D\b', 'FROM department d', query, flags=re.IGNORECASE)
query = re.sub(r'join\s+department\s+D\b', 'JOIN department d', query, flags=re.IGNORECASE)
# Replace d.name (nonexistent) with d.dept_name
query = re.sub(r'\b[dD]\.name\b', 'd.dept_name', query)
# Fix hallucinated column department.department_name
query = re.sub(r'department\.department_name', 'department.dept_name', query, flags=re.IGNORECASE)
query = re.sub(r'd\.department_name', 'd.dept_name', query, flags=re.IGNORECASE)
# Repair broken pattern 'JOIN dept ON d.dept_no = ON.dept_no'
if re.search(r'JOIN\s+dept\s+ON\s+d\.dept_no\s*=\s*ON\.dept_no', query, flags=re.IGNORECASE):
query = re.sub(r'JOIN\s+dept\s+ON\s+d\.dept_no\s*=\s*ON\.dept_no', '', query, flags=re.IGNORECASE)
# Replace stray ' ON.dept_no' with proper alias if department alias d exists
if ' ON.dept_no' in query and re.search(r'FROM\s+department\s+(?:AS\s+)?d\b', query, flags=re.IGNORECASE):
query = query.replace(' ON.dept_no', ' d.dept_no')
# General mapping: convert stray ON.<col> (e.g. ON.emp_no, ON.dept_no) to a likely alias.<col>
# This handles cases where the LLM accidentally emitted 'ON.col' instead of 'alias.col'
try:
def _map_on_col(m):
col = m.group(1).lower()
# Prefer department alias 'd' for dept_no
if col == 'dept_no' and re.search(r'FROM\s+department\s+(?:AS\s+)?d\b', query, flags=re.IGNORECASE):
return 'd.dept_no'
# Prefer dept_emp alias 'de' for dept_emp-related columns
if col in ('dept_no', 'emp_no') and re.search(r'\bdept_emp\s+(?:as\s+)?de\b', query, flags=re.IGNORECASE):
return f'de.{col}'
# dept_manager alias
if col in ('dept_no', 'emp_no') and re.search(r'\bdept_manager\s+(?:as\s+)?dm\b', query, flags=re.IGNORECASE):
return f'dm.{col}'
# salary/employee preferences for emp_no
if col == 'emp_no' and re.search(r'\bsalary\s+(?:as\s+)?s\b', query, flags=re.IGNORECASE):
return 's.emp_no'
if col == 'emp_no' and re.search(r'\bemployee\s+(?:as\s+)?e\b', query, flags=re.IGNORECASE):
return 'e.emp_no'
# If an explicit alias for the column exists anywhere, prefer that alias
for alias in ['d', 'de', 'dm', 'e', 's', 't']:
if re.search(rf'\b{alias}\.', query, flags=re.IGNORECASE) or re.search(rf'\b{alias}\b', query, flags=re.IGNORECASE):
return f"{alias}.{col}"
# Fallback to department alias if present, otherwise 'd'
if re.search(r'FROM\s+department', query, flags=re.IGNORECASE):
return f'd.{col}'
return f'd.{col}'
query = re.sub(r'\bON\.([a-z][a-z0-9_]*)\b', lambda m: _map_on_col(m), query, flags=re.IGNORECASE)
except Exception:
pass
# Fix common LLM alias typos like 'dept_e' or 'dept_emp' misuse and stray 'ON.dept_no'
# e.g. "JOIN department d ON dept_e.dept_no = ON.dept_no" -> "JOIN department d ON de.dept_no = d.dept_no"
try:
if re.search(r'join\s+department\s+d\s+on\s+[a-zA-Z0-9_]+\.dept_no\s*=\s*on\.dept_no', query, flags=re.IGNORECASE):
# Normalize left alias to 'de' (dept_emp) and right side to 'd.dept_no'
query = re.sub(r'join\s+department\s+d\s+on\s+[a-zA-Z0-9_]+\.dept_no\s*=\s*on\.dept_no',
'JOIN department d ON de.dept_no = d.dept_no',
query, flags=re.IGNORECASE)
# Replace 'dept_e.' (a common truncated alias) with 'de.'
if re.search(r'\bdept_e\.', query, flags=re.IGNORECASE):
query = re.sub(r'\bdept_e\.', 'de.', query, flags=re.IGNORECASE)
# Replace stray 'ON.dept_no' with 'd.dept_no' if department alias d is present
if re.search(r'\bON\.dept_no\b', query, flags=re.IGNORECASE) and re.search(r'from\s+department\s+(?:as\s+)?d\b', query, flags=re.IGNORECASE):
query = re.sub(r'\bON\.dept_no\b', 'd.dept_no', query, flags=re.IGNORECASE)
# Normalize 'dept_emp' full name followed by dot to 'de.' when alias de exists
if re.search(r'\bdept_emp\.', query, flags=re.IGNORECASE) and re.search(r'\bdept_emp\s+de\b', query, flags=re.IGNORECASE):
query = re.sub(r'\bdept_emp\.', 'de.', query, flags=re.IGNORECASE)
except Exception:
pass
# Final debug print to show the output after all fixer logic
try:
print("[DEBUG][SQL_FIXER][AFTER]", query)
except Exception:
pass
# Fix common mistake: stray semicolon before WHERE (e.g. "; WHERE ...")
# This often breaks SQL (syntax error at or near "WHERE"). Remove the stray semicolon.
if re.search(r';\s*WHERE', query, flags=re.IGNORECASE):
query = re.sub(r';\s*WHERE', ' WHERE', query, flags=re.IGNORECASE)
# Fix mistaken alias references where LLM used 'd' but salary table is aliased as 's'.
# Example: "salary s JOIN dept_emp de ON d.emp_no = de.emp_no" -> use s.emp_no
try:
if re.search(r'\bsalary\s+s\b', query, flags=re.IGNORECASE) and re.search(r'\bd\.emp_no\b', query, flags=re.IGNORECASE):
query = re.sub(r'\bd\.emp_no\b', 's.emp_no', query, flags=re.IGNORECASE)
except Exception:
pass
# Safe auto-insert: if the SQL references alias `e.` but there's no FROM/JOIN for employee
# inject a conservative join using dept_emp alias `de` when present. This addresses errors
# like "missing FROM-clause entry for table \"e\"" for queries such as employee counts per dept.
try:
has_e_alias_usage = re.search(r'\be\.', query, flags=re.IGNORECASE)
has_employee_join = re.search(r'\bjoin\s+employee\b|\bfrom\s+employee\b|\bemployee\s+e\b', query, flags=re.IGNORECASE)
has_de_alias = re.search(r'\b(dept_emp\s+de|\bde\.)', query, flags=re.IGNORECASE)
if has_e_alias_usage and not has_employee_join and has_de_alias:
# Insert join before WHERE/GROUP/ORDER/LIMIT/HAVING if present, otherwise append before semicolon/end
insert_point = None
m = re.search(r'\b(WHERE|GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b', query, flags=re.IGNORECASE)
join_clause = ' JOIN employee e ON de.emp_no = e.emp_no'
if m:
idx = m.start()
query = query[:idx] + join_clause + ' ' + query[idx:]
else:
# append safely before final semicolon if present
if query.strip().endswith(';'):
query = query.rstrip()[:-1] + join_clause + ';'
else:
query = query + join_clause
# Normalize spacing
query = re.sub(r'\s{2,}', ' ', query).strip()
except Exception:
pass
# Missing table join detection and auto-fix
# Fix missing department table join when d.dept_name is referenced
try:
# Fix for e.salary error: LLM trying to get salary from employee table
has_employee_salary = re.search(r'\be\.salary\b', query, re.IGNORECASE)
if has_employee_salary:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] e.salary referenced - need salary table join")
# Add salary table join
if not re.search(r'salary\s+s\b', query, re.IGNORECASE):
# Add salary join after employee table
employee_join_pattern = r'(FROM\s+employee\s+e)'
if re.search(employee_join_pattern, query, re.IGNORECASE):
query = re.sub(employee_join_pattern, r'\1\nJOIN salary s ON e.emp_no = s.emp_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added salary join")
# Fix the column references: e.salary -> s.salary
query = re.sub(r'\be\.salary\b', 's.salary', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Fixed column references from e.salary to s.salary")
# Add salary table to WHERE clause for current records
if 's.to_date' not in query.lower():
where_match = re.search(r'(\bWHERE\b)', query, re.IGNORECASE)
if where_match:
query = re.sub(r'(\bWHERE\b)', r'\1 s.to_date = \'9999-01-01\' AND', query, flags=re.IGNORECASE)
else:
# Add WHERE clause before GROUP BY
group_match = re.search(r'(\bGROUP BY\b)', query, re.IGNORECASE)
if group_match:
query = re.sub(r'(\bGROUP BY\b)', r'WHERE s.to_date = \'9999-01-01\'\n\1', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added salary to_date filter")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
# Fix for missing e.gender in SELECT: LLM puts e.gender in GROUP BY but forgets to SELECT it
# This produces 2-column results instead of 3-column (dept, gender, count)
# Use SELECT-anchored search to avoid matching schema text or LLM prompt preamble
_ql = query.lower()
_select_pos = _ql.rfind('select') # rfind: use LAST 'select' (closest to actual SQL)
_group_by_pos = _ql.rfind('group by') # rfind: use LAST 'group by'
_gender_in_groupby = False
_gender_in_select = True # default safe: don't inject
if _select_pos > -1 and _group_by_pos > -1 and _group_by_pos > _select_pos:
_group_by_snippet = query[_group_by_pos:]
_gender_in_groupby = 'e.gender' in _group_by_snippet.lower()
# Find FROM between SELECT and GROUP BY using ' from ' (spaces avoid matching 'from_date')
_from_pos = _ql.find(' from ', _select_pos)
if _from_pos > -1:
_select_snippet = query[_select_pos:_from_pos]
_gender_in_select = 'e.gender' in _select_snippet.lower()
if _gender_in_groupby and not _gender_in_select:
print("[DEBUG][SQL_FIXER][GENDER_SELECT] e.gender in GROUP BY but missing from SELECT — injecting")
# Find the first comma AFTER the SELECT keyword (safe: skips any schema text before SELECT)
_actual_select_pos = _ql.rfind('select')
first_comma = query.find(',', _actual_select_pos)
if first_comma > -1:
query = query[:first_comma + 1] + '\ne.gender,' + query[first_comma + 1:]
print(f"[DEBUG][SQL_FIXER][GENDER_SELECT_AFTER] {query[_actual_select_pos:_actual_select_pos+250]}")
# Fix for e.dept_name error: LLM incorrectly puts dept_name on employee table
# dept_name lives on the department table, requires dept_emp + department JOINs
has_e_dept_name = re.search(r'\be\.dept_name\b', query, re.IGNORECASE)
if has_e_dept_name:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] e.dept_name referenced — dept_name is not on employee table, adding dept_emp + department JOINs")
emp_alias = re.search(r'FROM\s+employee\s+(\w+)', query, re.IGNORECASE)
e_alias = emp_alias.group(1) if emp_alias else 'e'
# Add dept_emp join if missing
if not re.search(r'dept_emp\s+de\b', query, re.IGNORECASE):
emp_table_pattern = rf'(FROM\s+employee\s+{e_alias})'
if re.search(emp_table_pattern, query, re.IGNORECASE):
query = re.sub(emp_table_pattern, rf'\1\nJOIN dept_emp de ON {e_alias}.emp_no = de.emp_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added dept_emp join")
# Add department join if missing
if not re.search(r'JOIN\s+department\b', query, re.IGNORECASE):
dept_emp_pattern = r'(JOIN\s+dept_emp\s+de\s+ON\s+[^\n]+)'
if re.search(dept_emp_pattern, query, re.IGNORECASE):
query = re.sub(dept_emp_pattern, r'\1\nJOIN department d ON de.dept_no = d.dept_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department join")
# Fix to_date filter on dept_emp
if 'de.to_date' not in query.lower():
where_match = re.search(r'(\bWHERE\b)', query, re.IGNORECASE)
if where_match:
query = re.sub(r'\bWHERE\b', lambda m: "WHERE de.to_date = '9999-01-01' AND", query, flags=re.IGNORECASE, count=1)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added de.to_date filter")
# Replace e.dept_name → d.dept_name everywhere
query = re.sub(r'\be\.dept_name\b', 'd.dept_name', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Fixed e.dept_name → d.dept_name")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
# Fix: title table has no dept_no column — LLM hallucinates direct title→department join
# Correct path: title → dept_emp (via emp_no) → department (via dept_no)
if re.search(r'\bt\.dept_no\b', query, re.IGNORECASE):
print("[DEBUG][SQL_FIXER][TITLE_DEPT_NO] t.dept_no doesn't exist — fixing title→dept_emp→department join")
# Replace: JOIN department X ON t.dept_no = X.dept_no
# With: JOIN dept_emp de ON t.emp_no = de.emp_no \n JOIN department X ON de.dept_no = X.dept_no
fixed = re.sub(
r'JOIN\s+department\s+(?:AS\s+)?(\w+)\s+ON\s+t\.dept_no\s*=\s*\1\.dept_no',
r'JOIN dept_emp de ON t.emp_no = de.emp_no\nJOIN department \1 ON de.dept_no = \1.dept_no',
query, flags=re.IGNORECASE
)
if fixed != query:
query = fixed
# Add de.to_date filter if missing
if 'de.to_date' not in query.lower():
query = re.sub(r'\bWHERE\b', "WHERE de.to_date = '9999-01-01' AND", query, flags=re.IGNORECASE, count=1)
print(f"[DEBUG][SQL_FIXER][TITLE_DEPT_NO_AFTER] {query[:300]}")
has_department_join = re.search(r'\bjoin\s+department\b|\bfrom\s+department\b', query, re.IGNORECASE | re.DOTALL)
# Updated pattern to handle multiline formatting and whitespace - simplified
has_dept_emp_join = re.search(r'dept_emp\s+de\b', query, re.IGNORECASE | re.DOTALL)
# Fix for incorrect alias usage: T2.dept_no/T2.dept_name when T2 is salary table
has_salary_alias_dept_cols = re.search(r'\bT2\.(dept_no|dept_name)\b', query, re.IGNORECASE)
has_salary_table_as_t2 = re.search(r'\bsalary\s+AS\s+T2\b', query, re.IGNORECASE)
if has_salary_alias_dept_cols and has_salary_table_as_t2:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] T2.dept_no/dept_name referenced but T2 is salary table - need dept_emp and department joins")
# This is a fundamental schema error - the LLM is trying to get dept info from salary table
# We need to add dept_emp and department joins and fix the column references
# Check if we already have dept_emp join
if not re.search(r'dept_emp', query, re.IGNORECASE):
# Add dept_emp join after employee table
employee_join_pattern = r'(FROM\s+employee\s+AS\s+T1)'
if re.search(employee_join_pattern, query, re.IGNORECASE):
query = re.sub(employee_join_pattern, r'\1\nJOIN dept_emp de ON T1.emp_no = de.emp_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added dept_emp join")
# Check if we need department join
if not re.search(r'department', query, re.IGNORECASE):
# Add department join after dept_emp
query = re.sub(r'(JOIN dept_emp de ON T1\.emp_no = de\.emp_no)', r'\1\nJOIN department d ON de.dept_no = d.dept_no', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department join")
# Fix the column references: T2.dept_no -> d.dept_no, T2.dept_name -> d.dept_name
query = re.sub(r'\bT2\.dept_no\b', 'd.dept_no', query, flags=re.IGNORECASE)
query = re.sub(r'\bT2\.dept_name\b', 'd.dept_name', query, flags=re.IGNORECASE)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Fixed column references from T2 to d")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
elif has_d_dept_name and not has_department_join and has_dept_emp_join:
print("[DEBUG][SQL_FIXER][MISSING_JOIN] d.dept_name referenced but no department table joined")
# Insert department join after the first JOIN clause (simplified approach)
first_join_match = re.search(r'(JOIN\s+\w+\s+\w+\s+ON\s+[^\n]+)', query, re.IGNORECASE)
if first_join_match:
join_clause = first_join_match.group(1)
new_join = join_clause + '\nJOIN department d ON de.dept_no = d.dept_no'
query = query.replace(join_clause, new_join)
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department table join after first JOIN")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
else:
# Fallback: insert department join before WHERE clause
where_match = re.search(r'(\bWHERE\b)', query, re.IGNORECASE)
if where_match:
insert_pos = where_match.start()
new_join = '\nJOIN department d ON de.dept_no = d.dept_no\n'
query = query[:insert_pos] + new_join + query[insert_pos:]
print("[DEBUG][SQL_FIXER][MISSING_JOIN] Added department table join (fallback method)")
print(f"[DEBUG][SQL_FIXER][MISSING_JOIN_AFTER] {query}")
except Exception:
pass
# Final normalization: remove duplicate LIMITs again, trim extra semicolons & spaces
try:
query = re.sub(r"(LIMIT\s+\d+)(?:\s+LIMIT\s+\d+)+", r"\1", query, flags=re.IGNORECASE)
query = re.sub(r";{2,}$", ";", query.strip())
query = re.sub(r"\s+;", ";", query)
query = re.sub(r"[ \t]{2,}", " ", query)
except Exception:
pass
# Final: if we identified a simple extremes pattern but sanitizer introduced alias mismatch, normalize
if simple_extremes:
# Ensure aliasing consistency
if re.search(r'FROM\s+salary\s+s', query, re.IGNORECASE):
# Convert MIN(salary) to MIN(s.salary) if missing
query = re.sub(r'MIN\(salary\)', 'MIN(s.salary)', query, flags=re.IGNORECASE)
query = re.sub(r'MAX\(salary\)', 'MAX(s.salary)', query, flags=re.IGNORECASE)
# Remove trailing LIMIT if present (not needed for two-row aggregate)
if re.search(r'LIMIT\s+100;?$', query, re.IGNORECASE):
query = re.sub(r'LIMIT\s+100;?$', ';', query, flags=re.IGNORECASE)
# Fix malformed subquery aliases like "FROM salary s s2" -> "FROM salary s2"
# Common LLM error: generates "FROM table alias1 alias2" instead of proper aliases
try:
# Pattern: FROM table_name alias1 alias2 (followed by space/newline, not JOIN)
# Must have exactly 3 words after FROM and be followed by whitespace or end
malformed_alias_pattern = r'\bFROM\s+(\w+)\s+(\w+)\s+(\w+)(?=\s|$)'
matches = re.findall(malformed_alias_pattern, query, flags=re.IGNORECASE | re.MULTILINE)
if matches:
# Only fix if we have exactly the malformed pattern "FROM table alias1 alias2"
# and not something like "FROM table alias JOIN"
for match in matches:
table_name, alias1, alias2 = match
# Check if this is actually malformed (neither alias1 nor alias2 should be SQL keywords)
KEYWORDS = ['SELECT', 'FROM', 'JOIN', 'WHERE', 'GROUP', 'ORDER', 'LIMIT', 'HAVING', 'ON', 'AND', 'OR', 'BY', 'SET', 'IN', 'AS', 'DISTINCT']
if alias1.upper() not in KEYWORDS and alias2.upper() not in KEYWORDS:
# Additional safeguard: Don't fix if the old_pattern appears in a subquery context
# Look for patterns like "SELECT...FROM table WHERE" which indicate a subquery
old_pattern = f"FROM {table_name} {alias1} {alias2}"
# Check if this pattern appears near SELECT and WHERE keywords (subquery indicator)
if old_pattern in query:
# Find the position of this pattern
pos = query.find(old_pattern)
# Look before and after for SELECT/WHERE to detect subqueries
before_context = query[max(0, pos-100):pos].upper()
after_context = query[pos:pos+100].upper()
# Skip if this looks like a subquery (has SELECT before and WHERE after)
if 'SELECT' in before_context and 'WHERE' in after_context:
print(f"[DEBUG][SQL_FIXER][MALFORMED_ALIAS] Skipping subquery pattern: {old_pattern}")
continue
# Safe to fix
new_pattern = f"FROM {table_name} {alias2}"
query = query.replace(old_pattern, new_pattern)
print(f"[DEBUG][SQL_FIXER][MALFORMED_ALIAS] Fixed malformed table alias: {old_pattern} -> {new_pattern}")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][MALFORMED_ALIAS] Error: {e}")
pass
# Fix alias reference in SELECT clause (PostgreSQL doesn't allow referencing column aliases in same SELECT)
# Common pattern: (avg_male_salary - avg_female_salary) AS salary_gap
# Fix: Replace with the actual expressions
# TEMPORARILY DISABLED - causing SQL corruption
try:
# Skip alias reference fix for now to avoid SQL corruption
pass
# alias_ref_pattern = r'\(\s*avg_male_salary\s*-\s*avg_female_salary\s*\)'
# if re.search(alias_ref_pattern, query, flags=re.IGNORECASE):
# # Find the SELECT clause and extract alias definitions
# select_part = re.search(r'SELECT\s+(.*?)\s+FROM', query, flags=re.IGNORECASE | re.DOTALL)
# if select_part:
# select_content = select_part.group(1)
# # Look for avg_male_salary and avg_female_salary definitions with proper word boundaries
# male_salary_match = re.search(r'(AVG\s*\(\s*CASE\s+WHEN\s+e\.gender\s*=\s*[\'"]M[\'"].*?END\s*\))\s+AS\s+avg_male_salary', select_content, flags=re.IGNORECASE | re.DOTALL)
# female_salary_match = re.search(r'(AVG\s*\(\s*CASE\s+WHEN\s+e\.gender\s*=\s*[\'"]F[\'"].*?END\s*\))\s+AS\s+avg_female_salary', select_content, flags=re.IGNORECASE | re.DOTALL)
#
# if male_salary_match and female_salary_match:
# male_expr = male_salary_match.group(1).strip()
# female_expr = female_salary_match.group(1).strip()
# # Replace the alias references with actual expressions, being more careful about boundaries
# replacement = f'({male_expr} - {female_expr})'
# query = re.sub(r'\(\s*avg_male_salary\s*-\s*avg_female_salary\s*\)', replacement, query, flags=re.IGNORECASE)
# print(f"[DEBUG][SQL_FIXER][ALIAS_REF_FIX] Replaced alias references with expressions")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][ALIAS_REF_FIX] Error: {e}")
pass
# Fix missing employee alias in JOIN
try:
if re.search(r'FROM\s+employee\s+JOIN', query, flags=re.IGNORECASE):
query = re.sub(r'FROM\s+employee\s+JOIN', 'FROM employee e JOIN', query, flags=re.IGNORECASE)
print(f"[DEBUG][SQL_FIXER][MISSING_ALIAS] Added missing employee alias")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][MISSING_ALIAS] Error: {e}")
pass
# Fix invalid ds.to_date references
try:
if 'ds.to_date' in query:
query = query.replace('ds.to_date', 's.to_date')
print(f"[DEBUG][SQL_FIXER][INVALID_COLUMN] Fixed ds.to_date to s.to_date")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][INVALID_COLUMN] Error: {e}")
pass
# Fix unnecessary GROUP BY that matches COUNT DISTINCT field
# Pattern: SELECT COUNT(DISTINCT alias.column) ... GROUP BY alias.column
# This is always wrong - returns one row per value instead of total count
try:
# Find COUNT(DISTINCT alias.column) in SELECT
count_distinct_pattern = r'COUNT\s*\(\s*DISTINCT\s+(\w+)\.(\w+)\s*\)'
count_match = re.search(count_distinct_pattern, query, re.IGNORECASE)
if count_match:
alias = count_match.group(1)
column = count_match.group(2)
# Check if there's a GROUP BY with the same alias.column
group_by_pattern = rf'GROUP\s+BY\s+{re.escape(alias)}\.{re.escape(column)}\b'
if re.search(group_by_pattern, query, re.IGNORECASE):
# Remove the GROUP BY clause entirely
query = re.sub(group_by_pattern, '', query, flags=re.IGNORECASE)
# Clean up any trailing commas or extra whitespace
query = re.sub(r'\s+', ' ', query).strip()
print(f"[DEBUG][SQL_FIXER][GROUP_BY_FIX] Removed unnecessary GROUP BY {alias}.{column}")
except Exception as e:
print(f"[DEBUG][SQL_FIXER][GROUP_BY_FIX] Error: {e}")
pass
query = query.strip()
# Debug: print SQL after fixing
try:
print("[DEBUG][SQL_FIXER][AFTER]", query)
except Exception:
pass
return query
# ---------------- Hybrid micro-generation verification helper -----------------
def verify_micro_sql(candidate_sql: Optional[str], question: str, tag: str) -> Optional[str]:
"""Verify & (optionally) adjust a micro-generated candidate via MCP/LLM.
Returns validated SQL or None to signal fallback to full LLM generation.
"""
if not candidate_sql or not isinstance(candidate_sql, str):
return None
if os.environ.get("SKIP_MICRO_VERIFICATION", "0") in ("1","true","True"):
return candidate_sql
try:
base_val = validate_sql_query(candidate_sql)
eh_val = enhanced_validate_sql(candidate_sql)
if base_val.get('valid') and eh_val.get('valid'):
# Already valid, skip fixer
return candidate_sql
prompt = (
f"{_dynamic_schema_block('')}\n\n"
"You are an SQL verifier. A candidate SQL statement was programmatically assembled for the question below. "
"If it's already valid, you may return it unchanged. If SMALL fixes (aliases, correct column name existing in schema, missing LIMIT) are needed, correct them. "
"DO NOT invent new tables or columns. Always end with a semicolon; add 'LIMIT 100' if a plain SELECT without limiting and not an aggregate returning <= 1 row.\n"
f"Tag: {tag}\n"
f"Question: {question}\n"
"Candidate SQL:\n" + candidate_sql + "\n\nReturn ONLY the final SQL (no explanation)."
)
verified_text = None
# Try MCP first
try:
from .mcp_client import mcp_complete
verified_text = mcp_complete(prompt, timeout=MICRO_VERIFY_MCP_TIMEOUT)
except Exception:
try:
llm_local = Settings.llm
resp = llm_local.complete(prompt=prompt, timeout=MICRO_VERIFY_LLM_TIMEOUT)
verified_text = getattr(resp, 'text', str(resp))
except Exception as e2:
print(f"[MICRO-VERIFY][WARN] Verification LLM failed: {e2}; using original candidate if valid.")
if base_val.get('valid'):
return candidate_sql
return None
if not verified_text:
return candidate_sql if base_val.get('valid') else None
# Extract SQL
if '```' in verified_text:
parts = re.split(r"```(?:sql)?", verified_text, flags=re.IGNORECASE)
verified_text = '\n'.join(p for p in parts if 'select' in p.lower() or 'with' in p.lower())
if verified_text and verified_text.lower().startswith('sql'):
verified_text = verified_text[3:].strip()
# Only fix if not valid
v1 = validate_sql_query(verified_text)
v2 = enhanced_validate_sql(verified_text)
if v1.get('valid') and v2.get('valid'):
print(f"[MICRO-VERIFY] Accepted verified SQL for tag={tag}")
return verified_text
else:
print(f"[MICRO-VERIFY][WARN] Verified SQL invalid (base={v1}, issues={v2.get('issues')}); applying fixer.")
# Guard: only pass actual SQL to the fixer, not the full LLM prompt
_vt_stripped = verified_text.strip().lstrip('\n') if verified_text else ''
_vt_lower = _vt_stripped.lower()
if not (_vt_lower.startswith('select') or _vt_lower.startswith('with ') or _vt_lower.startswith('insert') or _vt_lower.startswith('update')):
# LLM returned the full prompt instead of SQL — extract the last SELECT...; block
_sql_match = re.search(r'(SELECT\s.+?;)\s*$', _vt_stripped, re.IGNORECASE | re.DOTALL)
if _sql_match:
_vt_stripped = _sql_match.group(1).strip()
print(f"[MICRO-VERIFY][WARN] Extracted SQL from prompt echo: {_vt_stripped[:120]}")
elif base_val.get('valid'):
print("[MICRO-VERIFY][WARN] Could not extract SQL; falling back to original candidate")
return candidate_sql
else:
return None
fixed = fix_common_sql_errors(_vt_stripped)
v1f = validate_sql_query(fixed)
v2f = enhanced_validate_sql(fixed)
if v1f.get('valid') and v2f.get('valid'):
print(f"[MICRO-VERIFY] Fixed SQL accepted for tag={tag}")
return fixed
# fallback to original if it was valid
if base_val.get('valid') and eh_val.get('valid'):
return candidate_sql
return None
except Exception as e:
print(f"[MICRO-VERIFY][ERROR] Unexpected error: {e}; falling back to full generation")
return None
def schema_aware_column_mapper(query: str) -> str:
"""Map alias.column references to the correct alias based on the inspected schema.
If an alias references a column not present in its table, search other tables
present in the FROM/JOIN clauses for that column and rewrite the alias to
the correct one. This fixes cases like `de.dept_name` -> `d.dept_name`.
"""
try:
# Build alias -> table mapping from FROM and JOIN clauses
aliases: Dict[str, str] = {}
for m in re.finditer(r"\bFROM\s+([a-zA-Z_][\w]*)\s+(?:AS\s+)?([a-zA-Z_][\w]*)", query, flags=re.IGNORECASE):
table, alias = m.group(1).lower(), m.group(2).lower()
aliases[alias] = table
for m in re.finditer(r"\bJOIN\s+([a-zA-Z_][\w]*)\s+(?:AS\s+)?([a-zA-Z_][\w]*)", query, flags=re.IGNORECASE):
table, alias = m.group(1).lower(), m.group(2).lower()
aliases[alias] = table
# Cache columns for each alias (table)
alias_cols: Dict[str, List[str]] = {}
for alias, table in aliases.items():
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name=table)]
except Exception:
cols = []
alias_cols[alias] = cols
# Replacement: for each occurrence alias.col, if col not in alias table, try to find other alias that has it
def _replace_alias_col(m: re.Match) -> str:
a = m.group(1)
col = m.group(2)
a_l = a.lower()
col_l = col.lower()
# If alias known and column exists there, keep
if a_l in alias_cols and col_l in alias_cols[a_l]:
return f"{a}.{col}"
# Search other aliases
for other_alias, cols in alias_cols.items():
if col_l in cols:
return f"{other_alias}.{col}"
# No alternative found; return original
return f"{a}.{col}"
new_query = re.sub(r"\b([a-zA-Z_][\w]*)\.([a-zA-Z_][\w]*)\b", _replace_alias_col, query)
return new_query
except Exception as e:
print(f"schema_aware_column_mapper failed: {e}")
return query
# --- Manager query micro-generation helpers ---
def generate_manager_list_sql(question: str) -> Optional[str]:
"""Micro LLM generation for manager listing without static template."""
try:
mini_llm = initialize_llm(progressive_reduction=1)
prompt = (
"Generate ONLY a PostgreSQL SQL query listing current department managers.\n"
"Use tables: department d, dept_manager dm, employee e.\n"
"Joins: d.dept_no = dm.dept_no AND e.emp_no = dm.emp_no.\n"
"Current managers: dm.to_date='9999-01-01'.\n"
"Select: d.dept_name AS department_name, e.first_name, e.last_name.\n"
"Order by d.dept_name. End with LIMIT 100; Include semicolon.\n"
f"Question: {question}\nReturn ONLY SQL." )
resp = TimeoutManager.run_with_timeout(
func=mini_llm.complete,
timeout=10.0,
prompt=prompt
)
sql_txt = resp.text.strip()
if '```' in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
needed = ['from department', 'join dept_manager', 'join employee', 'dept_name', 'limit']
if all(n in sql_txt.lower() for n in needed) and sql_txt.lower().startswith('select'):
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
print(f"Manager micro-generation failed: {e}")
return None
def adjust_manager_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if 'manager' not in ql:
return sql_query
missing_dm = not re.search(r'join\s+dept_manager', sql_query, re.IGNORECASE)
missing_e = not re.search(r'join\s+employee', sql_query, re.IGNORECASE)
if missing_dm or missing_e:
regen = generate_manager_list_sql(original_question)
if regen:
return regen
# If only dept name selected, regenerate for full manager listing
sel = sql_query.split('FROM')[0].lower() if 'from' in sql_query.lower() else ''
if 'dept_name' in sel and 'first_name' not in sel and 'last_name' not in sel:
regen = generate_manager_list_sql(original_question)
if regen:
return regen
return sql_query
# --- Department listing micro-generation & adjuster ---
def generate_department_list_sql(question: str) -> Optional[str]:
"""Micro LLM generation for department name listing (pure LLM path)."""
try:
mini_llm = initialize_llm(progressive_reduction=1)
prompt = (
"Generate ONLY a concise PostgreSQL SQL query that lists all department names.\n"
"Use table: department d. Select: d.dept_name AS department_name.\n"
"Order alphabetically by department_name. End with LIMIT 100; Include semicolon.\n"
f"Question: {question}\nReturn ONLY SQL." )
resp = TimeoutManager.run_with_timeout(
func=mini_llm.complete,
timeout=6.0,
prompt=prompt
)
sql_txt = resp.text.strip()
if '```' in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
needed = ['select', 'from department', 'dept_name', 'limit']
if all(n in sql_txt.lower() for n in needed) and sql_txt.lower().startswith('select'):
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
print(f"Department micro-generation failed: {e}")
return None
def adjust_department_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if not any(k in ql for k in ['department name', 'department names', 'list departments', 'departments list']):
return sql_query
if re.search(r'department\.department_name', sql_query, re.IGNORECASE) or re.search(r'JOIN\s+dept\s+ON', sql_query, re.IGNORECASE):
regen = generate_department_list_sql(original_question)
if regen:
return regen
if ' ON.' in sql_query and 'dept_name' in sql_query.lower():
regen = generate_department_list_sql(original_question)
if regen:
return regen
return sql_query
# --- Average salary per department micro-generation & adjuster ---
def generate_avg_salary_by_department_sql(question: str) -> Optional[str]:
"""Micro LLM generation for average salary per department (pure LLM)."""
try:
mini_llm = initialize_llm(progressive_reduction=1)
prompt = (
"Generate ONLY a PostgreSQL SQL query returning average current salary per department.\n"
"Use tables/aliases: salary s, employee e, dept_emp de, department d.\n"
"Joins: s.emp_no = e.emp_no; e.emp_no = de.emp_no; de.dept_no = d.dept_no.\n"
"Current rows: s.to_date='9999-01-01' AND de.to_date='9999-01-01'.\n"
"Select: d.dept_name AS department_name, AVG(s.salary) AS average_salary.\n"
"Group by d.dept_name. Order by average_salary DESC. End with LIMIT 100; Include semicolon.\n"
f"Question: {question}\nReturn ONLY SQL." )
resp = TimeoutManager.run_with_timeout(func=mini_llm.complete, timeout=8.0, prompt=prompt)
sql_txt = resp.text.strip()
if '```' in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
required = ['avg', 'from salary', 'join', 'dept_name', 'group by', 'limit']
if all(r in sql_txt.lower() for r in required) and sql_txt.lower().startswith('select'):
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
print(f"Avg salary micro-generation failed: {e}")
return None
def adjust_avg_salary_department_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if 'average salary' not in ql or 'department' not in ql:
return sql_query
# If question asks for an additional grouping dimension (title, job title, gender, etc.)
# AND the SQL already joins that table, do not regenerate — the LLM handled the
# multi-dimension correctly and regenerating would drop that dimension.
_extra_dims = ['title', 'job title', 'gender', 'hire_date', 'position']
if any(dim in ql for dim in _extra_dims):
if re.search(r'\b(from|join)\s+title\b', sql_query, re.IGNORECASE):
print(f"[ADJUSTER][AVG_SALARY] Skipping regen — SQL already has title join for multi-dimension query")
return sql_query
# Conditions that signal regeneration
invalid_bare_department = re.search(r'\bselect\s+department\b', sql_query, re.IGNORECASE)
missing_dept_join = 'join department' not in sql_query.lower() and ' department d' not in sql_query.lower()
missing_avg = 'avg(' not in sql_query.lower()
missing_filters = 'to_date' not in sql_query.lower()
if invalid_bare_department or missing_dept_join or missing_avg or missing_filters:
regen = generate_avg_salary_by_department_sql(original_question)
if regen:
return regen
return sql_query
# --- Title query semantic adjuster (post-generation heuristic, no hardcoded full query) ---
def adjust_title_query(sql_query: str, original_question: str, normalized_title: Optional[str]) -> str:
"""Heuristically adjust title/engineer count queries to valid schema usage.
Rules (non-destructive, only if clearly a title count intent):
- Ensure alias t for title table
- Replace misuse of 'department =' with correct t.title comparison
- Ensure COUNT(DISTINCT t.emp_no) and current record filter t.to_date='9999-01-01'
"""
ql = original_question.lower()
if not any(k in ql for k in ["engineer", "title", "developer", "analyst", "staff"]):
return sql_query
# Guard: if user is asking for count of DISTINCT title types (not employees with a title),
# the correct SQL is COUNT(DISTINCT title) with no emp_no or engineer filter — do not corrupt it.
is_distinct_title_count = (
any(k in ql for k in ["how many", "count", "number of", "different", "distinct", "types of"]) and
any(k in ql for k in ["title", "titles", "job title", "job titles", "positions", "roles"]) and
not any(k in ql for k in ["engineer", "engineers", "analyst", "developer", "staff"])
)
if is_distinct_title_count:
print(f"[ADJUSTER][TITLE] Skipping adjust_title_query — question is asking for count of distinct title types, not employee count")
return sql_query
intent_is_count = any(k in ql for k in ["how many", "count"]) and any(k in ql for k in ["engineer", "engineers"])
# If LLM failed to reference title table at all for an engineer count intent, synthesize minimal correct pattern.
# This is a heuristic repair (constructed dynamically) – not a stored fallback template list.
if 'from title' not in sql_query.lower() and intent_is_count:
target = normalized_title
if not target:
for pat, canon in [
(r"senior engineers?", "Senior Engineer"),
(r"staff engineers?", "Staff Engineer"),
(r"assistant engineers?", "Assistant Engineer"),
(r"engineers?", "Engineer"),
]:
if re.search(pat, ql):
target = canon
break
target = target or "Engineer"
try:
generated = generate_title_count_sql(original_question, target)
if generated:
return generated
except Exception as gen_err:
print(f"Title micro-generation failed: {gen_err}")
# Fall through to incremental adjustments below without introducing a static template
pass
# Continue adjustments for queries that already reference title
target = normalized_title
# Attempt to infer target from question if not provided
if not target:
for pat, canon in [
(r"senior engineers?", "Senior Engineer"),
(r"staff engineers?", "Staff Engineer"),
(r"assistant engineers?", "Assistant Engineer"),
(r"engineers?", "Engineer"),
]:
if re.search(pat, ql):
target = canon
break
# Minimal non-template adjustments only (structural hygiene, no full pattern injection)
# 1. Ensure alias t if title table already present and alias missing (pure structural, not full query template)
if re.search(r"from\s+title\b", sql_query, re.IGNORECASE) and not re.search(r"from\s+title\s+(?:as\s+)?t\b", sql_query, re.IGNORECASE):
sql_query = re.sub(r"from\s+title\b", "FROM title t", sql_query, flags=re.IGNORECASE)
# 2. Replace misuse of department = 'X' (a semantic correction, not a canned query)
if re.search(r"department\s*=\s*'([^']+)'", sql_query, re.IGNORECASE):
sql_query = re.sub(r"department\s*=\s*'([^']+)'", lambda m: f"t.title = '{(target or m.group(1)).rstrip('s')}'", sql_query, flags=re.IGNORECASE)
# 3. If we see COUNT(*) with only title table referenced, prefer regenerating via micro LLM instead of hard substituting a fragment
if re.search(r"select\s+count\(\*\)", sql_query, re.IGNORECASE) and re.search(r"from\s+title", sql_query, re.IGNORECASE):
regen = None
try:
regen = generate_title_count_sql(original_question, target or 'Engineer')
except Exception:
regen = None
if regen:
return regen
# 4. If essential current-row filter missing, attempt micro-generation instead of injecting static predicate
# Only trigger current-filter regen if this is TRULY an employee-with-title count query
# (not a generic 'count distinct titles' query which doesn't need to_date)
is_employee_title_count = intent_is_count or (target is not None)
needs_current_filter = (
re.search(r"from\s+title", sql_query, re.IGNORECASE) and
't.to_date' not in sql_query.lower() and
is_employee_title_count
)
if needs_current_filter:
regen = None
try:
regen = generate_title_count_sql(original_question, target or 'Engineer')
except Exception:
regen = None
if regen:
return regen
# 5. Do NOT inject LIMIT / semicolon here; global sanitizers handle generic formatting consistently
return sql_query
# Lightweight micro-generation (still LLM based) to avoid static hardcoded pattern for title count
def generate_title_count_sql(question: str, canonical_title: str) -> Optional[str]:
"""Use a minimal LLM prompt to produce the title count SQL instead of embedding a static query.
Returns cleaned SQL or None on failure. Keeps pure LLM path philosophy."""
# Deterministic schema-driven builder first (no LLM)
try:
if 'title' in available_tables:
try:
cols = [c['name'].lower() for c in inspector.get_columns(table_name='title')]
except Exception:
cols = []
# Required columns for deterministic build
if 'emp_no' in cols and 'title' in cols and 'to_date' in cols:
title_literal = canonical_title.strip()
# Assemble SQL programmatically via build_sql helper (avoid literal concatenation)
select_cols = ["COUNT(DISTINCT t.emp_no) AS title_count"]
base_table = "title t"
where = [f"LOWER(t.title) = '{title_literal.lower()}'", "t.to_date = '9999-01-01'"]
sql = build_sql(select_cols=select_cols, base_table=base_table, where=where, limit=100)
return sql
except Exception as e:
print(f"Deterministic title count builder error: {e}")
# Fallback: small LLM-based micro-generator (existing behavior)
try:
mini_llm = initialize_llm(progressive_reduction=2)
memory_manager.register_llm_instance(mini_llm)
prompt = (
"Generate ONLY a concise PostgreSQL SQL query counting distinct employees with a specific title.\n"
"Constraints:\n"
"- Use title table only with alias t\n"
"- Current rows: t.to_date='9999-01-01'\n"
f"- Exact title match: '{canonical_title}' (use singular)\n"
"- COUNT(DISTINCT t.emp_no) AS TitleCount\n"
"- End with LIMIT 100;\n"
"Question: " + question + "\n"
"Return ONLY SQL; no prose."
)
resp = TimeoutManager.run_with_timeout(
func=mini_llm.complete,
timeout=8.0,
prompt=prompt
)
memory_manager.unregister_llm_instance(mini_llm)
sql_txt = resp.text.strip()
if "```" in sql_txt:
parts = re.split(r"```(?:sql)?", sql_txt, flags=re.IGNORECASE)
sql_txt = ''.join(p for p in parts if 'select' in p.lower()).strip()
sql_txt = fix_common_sql_errors(sql_txt)
# Enforce essential fragments; if missing treat as failure so caller may fallback to adjustments
required = ["count", "from title", "t.to_date", "t.title", "limit"]
if all(r in sql_txt.lower() for r in required):
if not sql_txt.lower().startswith("select"):
return None
if not sql_txt.strip().endswith(';'):
sql_txt += ';'
return sql_txt
return None
except Exception as e:
try:
memory_manager.unregister_llm_instance(mini_llm)
except Exception:
pass
print(f"Title count micro-generation failed: {e}")
return None
# --- Employee count by department micro-generation & adjuster ---
def generate_employee_count_by_department_sql(question: str) -> Optional[str]:
"""Deterministic, schema-driven SQL builder for employees-per-department.
This avoids relying on a small LLM call which has been producing
malformed WHERE/alias combinations under low-memory/degraded-model
conditions. The builder inspects available tables/columns and returns
a safe SQL string or None if required tables/columns are missing.
"""
try:
ql = question.lower()
superlative = any(w in ql for w in ["most", "largest", "highest"])
least = any(w in ql for w in ["fewest", "least"]) # prefer ASC
order_asc = least
# Required tables
if 'department' not in available_tables or 'dept_emp' not in available_tables:
return None
# Discover canonical column names
try:
dept_cols = [c['name'].lower() for c in inspector.get_columns(table_name='department')]
except Exception:
dept_cols = []
try:
de_cols = [c['name'].lower() for c in inspector.get_columns(table_name='dept_emp')]
except Exception:
de_cols = []
# Choose dept name column (dept_name preferred)
dept_name_col = 'dept_name' if 'dept_name' in dept_cols else (dept_cols[0] if dept_cols else 'dept_name')
# dept_no column name
dept_no_col = 'dept_no' if 'dept_no' in dept_cols or 'dept_no' in de_cols else (dept_cols[0] if dept_cols else 'dept_no')
# emp_no column
emp_no_col = 'emp_no' if 'emp_no' in de_cols else (de_cols[0] if de_cols else 'emp_no')
# Build SQL programmatically
order_dir = 'ASC' if order_asc else 'DESC'
limit_clause = 'LIMIT 1' if superlative or least else 'LIMIT 100'
select_cols = [f"d.{dept_name_col} AS department_name", f"COUNT(DISTINCT de.{emp_no_col}) AS employee_count"]
base_table = f"department d"
joins = [f"JOIN dept_emp de ON d.{dept_no_col} = de.{dept_no_col}"]
where_clauses = []
# Prefer standard current-row sentinel if available
if 'to_date' in de_cols:
where_clauses.append("de.to_date = '9999-01-01'")
parts = []
parts.append("SELECT " + ", ".join(select_cols))
parts.append("FROM " + base_table)
parts.extend(joins)
if where_clauses:
parts.append("WHERE " + " AND ".join(where_clauses))
parts.append("GROUP BY d." + dept_name_col)
parts.append(f"ORDER BY employee_count {order_dir}")
parts.append(limit_clause + ';')
sql = " ".join(parts)
return sql
except Exception as e:
print(f"Employee count builder failed: {e}")
return None
def adjust_employee_count_by_department_query(sql_query: str, original_question: str) -> str:
ql = original_question.lower()
if not ('department' in ql and any(k in ql for k in ['count', 'how many', 'number of', 'most', 'largest', 'highest', 'fewest', 'least']) and any(k in ql for k in ['employee', 'employees'])):
return sql_query
# If missing essential join or aggregation/grouping, regenerate
missing_join = 'join dept_emp' not in sql_query.lower()
missing_group = 'group by' not in sql_query.lower()
missing_count = 'count(' not in sql_query.lower()
if missing_join or missing_group or missing_count:
regen = generate_employee_count_by_department_sql(original_question)
if regen:
return regen
# Superlative queries should generally have LIMIT 1
if any(w in ql for w in ['most','largest','highest','fewest','least']) and 'limit 1' not in sql_query.lower():
regen = generate_employee_count_by_department_sql(original_question)
if regen:
return regen
return sql_query
async def present_results(question: str, sql_query: str) -> str:
"""Execute the query and present results with intelligent formatting and insights."""
print("[PRESENTER] Starting intelligent result presentation...")
try:
# Execute the query with diagnostic verification
print("[PRESENTER] Executing query...")
results = await execute_query(sql_query)
# Perform result verification for suspicious patterns
if results and isinstance(results, list) and len(results) <= 5:
try:
flat_vals = []
for r in results:
for v in r.values():
if isinstance(v, (int, float)):
flat_vals.append(v)
if flat_vals and all(v == flat_vals[0] for v in flat_vals):
if flat_vals[0] in (1103, 1000, 999):
print(f"[PRESENTER] Detected uniform result pattern {flat_vals} - verifying accuracy...")
lowered_q = question.lower()
is_simple_total = re.search(r"total number of employees|total employees|count of employees|how many employees", lowered_q) and 'department' not in lowered_q and 'each' not in lowered_q
simple_sql_shape = re.match(r"^\s*select\s+count\s*\(\s*\*\s*\)\s+as\s+total_employees\s+from\s+employee\s*;?\s*$", sql_query, flags=re.IGNORECASE)
if is_simple_total or simple_sql_shape:
try:
print("[PRESENTER] Cross-verifying employee count...")
verification_results = []
verify_queries = [
build_sql(select_cols=["COUNT(*) AS total_emp_check"], base_table="employee", limit=None),
build_sql(select_cols=["COUNT(DISTINCT emp_no) AS total_emp_distinct"], base_table="employee", limit=None)
]
for vq in verify_queries:
try:
vr = await execute_query(vq)
verification_results.append((vq, vr))
except Exception as verr:
verification_results.append((vq, str(verr)))
counts = []
for _, vr in verification_results:
if isinstance(vr, list) and vr and isinstance(vr[0], dict):
for val in vr[0].values():
if isinstance(val, (int, float)):
counts.append(val)
if len(counts) >= 2 and counts[0] != counts[1]:
print(f"[PRESENTER] Count verification mismatch detected: {counts}")
else:
print(f"[PRESENTER] Count verification successful: {counts[0] if counts else 'N/A'}")
except Exception as ver_err:
print(f"[PRESENTER] Verification failed: {ver_err}")
except Exception:
pass
# Apply intelligent formatting and context
print("[PRESENTER] Applying intelligent formatting...")
formatted_response = format_results(results, question)
# Chart-aware presentation (Phase 3): attach a chart spec when the
# result rows visualize well. The marker is stripped by the worker;
# CLI/terminal runs just see the plain text answer.
try:
from .chart_contract import detect_chart_spec, embed_chart_marker
chart_spec = detect_chart_spec(results, question)
if chart_spec:
formatted_response = embed_chart_marker(formatted_response, chart_spec)
print(f"[PRESENTER] Chart attached: type={chart_spec['type']} "
f"points={len(chart_spec['x'])} series={len(chart_spec['series'])}")
except Exception as chart_err:
print(f"[PRESENTER] chart detection skipped: {chart_err}")
# Add presentation insights
if results:
result_count = len(results)
print(f"[PRESENTER] Presenting {result_count} result(s) with contextual insights")
else:
print("[PRESENTER] No results found - providing explanatory response")
print("[PRESENTER] Result presentation completed successfully")
return formatted_response
except Exception as e:
error_msg = f"[PRESENTER] Error during result presentation: {str(e)}"
print(error_msg)
return f"I encountered an error while presenting the results: {str(e)}"
async def agentic_query_process(question: str) -> str:
"""Process a query through an agentic workflow."""
print(f"[DEBUG][INCOMPLETE_FUNCTION] Called with question: '{question}'")
# Generate SQL query from LLM
sql_query = query_craft(question)
# Validate the query before fixing
validation = enhanced_validate_sql(sql_query)
if validation["valid"]:
print("[DEBUG][AGENTIC] Query is valid, skipping fixer.")
fixed_query = sql_query
else:
print(f"[DEBUG][AGENTIC] Query has issues: {validation['issues']}. Applying fixer.")
fixed_query = fix_common_sql_errors(sql_query)
# Continue with the rest of the workflow using fixed_query
# (The rest of the function remains unchanged)
async def get_doc_text(doc_id: str) -> Optional[str]:
"""Return stored OCR text for a given doc_id if present in chat_store/docs."""
try:
docs_dir = Path('chat_store') / 'docs'
fpath = docs_dir / f"{doc_id}.txt"
if fpath.exists():
return fpath.read_text(encoding='utf-8')
except Exception:
pass
return None
async def agentic_query_process(question: str, doc_id: Optional[str] = None) -> str:
"""Process a query through an agentic workflow. Optionally include OCR text from doc_id."""
print(f"[ENTRY_POINT_DEBUG] agentic_query_process called with question: '{question}'")
print(f"[ENTRY_POINT_DEBUG] doc_id: {doc_id}")
# Initialize database if not already done
global async_session, available_tables
if not _DB_CONNECTED or not available_tables:
print("--- Database Initialization ---")
db_metadata = await initialize_database(uri=DB_CONNECTION_URL)
if not db_metadata["tables"]:
return (
"No database is connected yet. Open the **Database** panel on the right, "
"then connect one via connection URL or upload a .sqlite/.csv file — "
"or set DB_CONNECTION_URL in Agent/.env and restart the worker."
)
print(f"Database initialized with tables: {available_tables}")
# If a doc_id is provided, fetch its OCR text and append to the question for context
if doc_id:
try:
doc_text = await get_doc_text(doc_id)
if doc_text:
try:
from os import getenv as _oget
snippet_limit = int(_oget("OCR_SNIPPET_LIMIT", "2000"))
except Exception:
snippet_limit = 2000
if snippet_limit <= 0:
snippet_limit = 2000
snippet = doc_text[:snippet_limit]
question = (
question
+ f"\n\n[Document {doc_id} excerpt start]\n{snippet}\n[Document excerpt end]"
)
except Exception as e:
print(f"Warning: failed to include doc_text for {doc_id}: {e}")
# First clean up the question to handle common typos
print(f"[TYPO_DEBUG] Question before typo correction: '{question}'")
corrected_question = question
common_typos = {
"deparment": "department",
"deparments": "departments",
"avarage": "average",
# Removed "employe": "employee" and "employes": "employees" to prevent over-correction
}
for typo, correction in common_typos.items():
corrected_question = corrected_question.replace(typo, correction)
if corrected_question != question:
print(f"Corrected query: '{corrected_question}' (was: '{question}')")
question = corrected_question
# Initial memory cleanup before processing
print(f"\n--- Initial Memory Cleanup for question: {question} ---")
gc.collect()
stats_before = memory_manager.get_memory_stats()
memory_manager.force_collect_garbage()
stats_after = memory_manager.get_memory_stats()
print(f"Memory before cleanup: {stats_before['available_mb']:.2f}MB")
print(f"Memory after cleanup: {stats_after['available_mb']:.2f}MB")
print(f"Memory reclaimed: {stats_after['available_mb'] - stats_before['available_mb']:.2f}MB")
# Check if there's enough memory to safely process the request
if stats_after['available_mb'] < memory_manager.critical_mb:
print(f"System is critically low on memory: {stats_after['available_mb']:.2f}MB available")
print("LLM-based approach not viable due to critical memory constraints")
return "I'm sorry, but your system is critically low on available memory. Please close some applications and try again, or try a simpler query."
print("\n=== MEMORY AGENT ===")
# Handle re-execute requests by cleaning the question first
original_question = question
clean_question = question
for keyword in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"]:
clean_question = clean_question.replace(keyword, "").strip()
# Check if this is a re-execute request
is_reexecute = any(k in original_question.lower() for k in ["re-execute", "reexecute", "regenerate", "try again", "force", "bypass memory"])
if is_reexecute and clean_question and len(clean_question.strip()) > 0:
print(f"User requested to re-execute. Using question: '{clean_question}'")
question = clean_question # Use the cleaned question for processing
print("Bypassing memory check for re-execute request.")
else:
# Normal memory check
memory_result = memory_check(question)
if memory_result.startswith("FOUND_IN_MEMORY:"):
print("Found previous answer in memory")
return memory_result.replace("FOUND_IN_MEMORY: ", "")
elif memory_result.startswith("FOUND_IN_MEMORY_WITH_ERROR:"):
print("Found previous answer with error. Suggesting to retry.")
error_message = memory_result.replace("FOUND_IN_MEMORY_WITH_ERROR: ", "")
return f"{error_message}\n\nIt looks like there was an error with this query. You can try asking again with 're-execute' to regenerate the query."
elif memory_result.startswith("RE_EXECUTE_ORIGINAL:"):
print("User requested to re-execute. Retrieving original question.")
# Only use the fallback question if user didn't specify a question after "re-execute"
if clean_question and len(clean_question.strip()) > 0:
print(f"User provided specific question for re-execute: '{clean_question}' - using that instead of fallback")
question = clean_question
else:
print("User didn't specify question for re-execute, using fallback from memory")
question = memory_result.replace("RE_EXECUTE_ORIGINAL: ", "")
print(f"Final question for re-execution: {question}")
elif "Bypassing memory check" in memory_result:
print("User requested to re-execute. Bypassing memory check.")
# Continue with query generation
else:
print("No previous answer found in memory")
# Memory check again before heavy processing
print("\n--- Pre-Generation Memory Check ---")
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < memory_manager.threshold_mb:
print(f"⚠️ Memory low before query generation: {stats['available_mb']:.2f}MB available")
memory_manager.force_collect_garbage()
stats = memory_manager.get_memory_stats()
if stats['available_mb'] < memory_manager.critical_mb:
return "I'm sorry, but your system is currently low on available memory. Please close some applications and try again, or try a simpler query."
print("\n=== QUERY CRAFT AGENT ===")
try:
gc.collect()
try:
sql_query = query_craft(question)
except ValueError as gen_fail:
ql = question.lower()
if ACTIVE_SCHEMA_PROFILE.is_employee_db and ('manager' in ql and ('male' in ql or 'female' in ql or 'gender' in ql)):
print("[FALLBACK] Building manager gender count query programmatically after LLM failure")
select_cols = ["e.gender", "COUNT(*) AS manager_count"]
joins = ["JOIN dept_manager dm ON dm.emp_no = e.emp_no"]
sql_query = build_sql(select_cols=select_cols, base_table="employee e", joins=joins, group_by=["e.gender"], order_by="manager_count DESC", limit=None)
else:
raise
try:
global LAST_GENERATED_SQL
LAST_GENERATED_SQL = sql_query
except Exception:
pass
try:
print("<<<SQL_START>>>")
print(sql_query.strip())
print("<<<SQL_END>>>")
except Exception:
print(f"Generated SQL query: {sql_query}")
# Invariant enforcement (classification + constraint-driven regeneration).
# NOTE: invariants are tuned for the built-in employee demo schema. For
# user-uploaded schemas only generic structural validation runs.
if ENABLE_INVARIANTS and ACTIVE_SCHEMA_PROFILE.is_employee_db:
# Quick validation check BEFORE running expensive invariants.
# If the SQL already passes validation, invariants will only produce false positives
# and waste 90-160s. Use a flag to gate the entire enforce_invariants block.
_skip_invariants = False
try:
_pre_inv_valid = enhanced_validate_sql(sql_query)
_pre_inv_base = validate_sql_query(sql_query)
if _pre_inv_valid.get('valid') and _pre_inv_base.get('valid'):
print("[INVARIANTS] SQL passed pre-check — skipping enforce_invariants (already valid)")
print("<<<SQL_REVISED_START>>>")
print(sql_query.strip())
print("<<<SQL_REVISED_END>>>")
LAST_GENERATED_SQL = sql_query
_skip_invariants = True
except Exception:
pass # If pre-check fails, fall through to normal invariants
if not _skip_invariants:
try:
try:
cat = classify_question(question)
except Exception:
cat = None
_sql_normalized = re.sub(r'\s+', ' ', sql_query).lower()
_has_dept_emp = 'dept_emp' in _sql_normalized
_has_gender = 'gender' in _sql_normalized or 'e.gender' in _sql_normalized
if cat == 'EMP_COUNT_BY_DEPT' and not _has_dept_emp and not _has_gender:
rescue = generate_employee_count_by_department_sql(question)
if rescue:
print("[RESCUE] Replaced incomplete employee count query with micro-generated version before invariants")
sql_query = rescue
classification_question = re.sub(r'^re-execute\s+', '', question, flags=re.IGNORECASE)
inv_result = enforce_invariants(classification_question, sql_query, Settings.llm, run_llm_with_timeout)
if inv_result.get('regenerations'):
for regen in inv_result['regenerations']:
print(f"[INVARIANTS] Regen {regen['attempt']} fixed missing {regen['missing_before']}")
if 'remaining_missing' in inv_result and inv_result['remaining_missing']:
print(f"[INVARIANTS] Remaining unmet invariants: {inv_result['remaining_missing']}")
try:
micro_map = {
'count_depts': generate_department_list_sql,
'emp_count_by_dept': generate_employee_count_by_department_sql,
'count_employees_by_department': generate_employee_count_by_department_sql,
'EMP_COUNT_BY_DEPT': generate_employee_count_by_department_sql,
}
for miss in inv_result['remaining_missing']:
key = miss.lower()
gen_fn = micro_map.get(key)
if gen_fn:
try:
print(f"[INVARIANTS] Attempting micro-generator for unmet invariant '{miss}' using {gen_fn.__name__}")
micro_sql = gen_fn(question)
if micro_sql:
print(f"[INVARIANTS] Micro-generator produced SQL for '{miss}': {micro_sql}")
sql_query = micro_sql
break
except Exception as micro_err:
print(f"[INVARIANTS] Micro-generator {gen_fn.__name__} failed: {micro_err}")
except Exception as micro_map_err:
print(f"[INVARIANTS] Micro-generator mapping failed: {micro_map_err}")
if not sql_query or (isinstance(sql_query, str) and sql_query.strip() == ''):
sql_query = inv_result.get('final_sql', sql_query)
else:
try:
print("<<<SQL_REVISED_START>>>")
print(sql_query.strip())
print("<<<SQL_REVISED_END>>>")
LAST_GENERATED_SQL = sql_query
except Exception:
print(f"[INVARIANTS] Final SQL after enforcement/micro-gen: {sql_query}")
if not sql_query:
sql_query = inv_result.get('final_sql', sql_query)
except Exception as inv_err:
print(f"[INVARIANTS] Enforcement skipped due to error: {inv_err}")
memory_manager.force_collect_garbage()
# --- MAIN PATCH: Only fix if invalid, and always use the validated/fixed query ---
validation = enhanced_validate_sql(sql_query)
if validation["valid"]:
fixed_query = sql_query
print("[DEBUG][AGENTIC] Query is valid, skipping fixer.")
else:
print(f"[DEBUG][AGENTIC] Query has issues: {validation['issues']}. Applying fixer.")
fixed_query = fix_common_sql_errors(sql_query)
# All subsequent processing uses fixed_query only
sql_query = fixed_query
# --- Unconditional semantic fixes (run even when fixer was skipped) ---
# Fix 1: dept_no = 'DeptName' → dept_no IN (SELECT dept_no FROM department WHERE dept_name = 'DeptName')
# This pattern is syntactically valid but semantically wrong (dept_no is a code, not a name)
try:
_dn_match = re.search(r"\bdept_no\s*=\s*'([A-Za-z][A-Za-z\s&]*)'", sql_query, re.IGNORECASE)
if _dn_match and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_dn_val = _dn_match.group(1)
if not re.match(r'^d\d+$', _dn_val.strip(), re.IGNORECASE):
_dn_fix = f"dept_no IN (SELECT dept_no FROM department WHERE dept_name = '{_dn_val}')"
sql_query = re.sub(
r"\bdept_no\s*=\s*'" + re.escape(_dn_val) + r"'",
_dn_fix, sql_query, flags=re.IGNORECASE
)
print(f"[POST-FIX] dept_no = '{_dn_val}' replaced with subquery lookup")
except Exception:
pass
# Fix 2: department.dept_name = 'X' used in WHERE but department table not in FROM/JOIN
# → add the missing dept_emp + department joins
try:
_has_dept_filter = re.search(r"\bdepartment\.dept_name\s*=\s*'([^']+)'", sql_query, re.IGNORECASE)
_has_dept_join = re.search(r'\bjoin\s+department\b|\bfrom\s+department\b', sql_query, re.IGNORECASE)
_has_dept_emp_join = re.search(r'\bjoin\s+dept_emp\b|\bfrom\s+dept_emp\b', sql_query, re.IGNORECASE)
if _has_dept_filter and not _has_dept_join and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_dept_filter_val = _has_dept_filter.group(1)
# Find the first FROM table to determine the join anchor
_from_match = re.search(r'\bFROM\s+(\w+)', sql_query, re.IGNORECASE)
_anchor = _from_match.group(1) if _from_match else 'salary'
# Build the missing joins - anchor through employee if present
_has_emp = re.search(r'\bjoin\s+employee\b|\bfrom\s+employee\b', sql_query, re.IGNORECASE)
if _has_emp and not _has_dept_emp_join:
_join_to_add = (
"JOIN dept_emp de ON e.emp_no = de.emp_no "
"JOIN department d ON de.dept_no = d.dept_no"
)
elif not _has_dept_emp_join:
_join_to_add = (
f"JOIN dept_emp de ON {_anchor}.emp_no = de.emp_no "
"JOIN department d ON de.dept_no = d.dept_no"
)
else:
_join_to_add = "JOIN department d ON de.dept_no = d.dept_no"
# Insert join before WHERE
sql_query = re.sub(r'\bWHERE\b', _join_to_add + ' WHERE', sql_query, count=1, flags=re.IGNORECASE)
# Replace department.dept_name with d.dept_name (alias form)
sql_query = re.sub(r'\bdepartment\.dept_name\b', 'd.dept_name', sql_query, flags=re.IGNORECASE)
print(f"[POST-FIX] Added missing department JOIN for filter dept_name = '{_dept_filter_val}'")
except Exception:
pass
# Fix 3: "Which X has the highest/most/lowest/fewest..." → singular superlative → LIMIT 1
# These questions expect a single top result, not a ranked list of all rows.
try:
_singular_superlative = re.search(
r'\bwhich\b.{1,40}\b(highest|most|lowest|fewest|best|worst|largest|smallest|greatest)\b',
question.lower()
)
if _singular_superlative:
# Only change LIMIT 100 (the default) — never touch intentional LIMIT N values
if re.search(r'\bLIMIT\s+100\b', sql_query, re.IGNORECASE):
sql_query = re.sub(r'\bLIMIT\s+100\b', 'LIMIT 1', sql_query, flags=re.IGNORECASE)
print("[POST-FIX] Changed LIMIT 100 -> LIMIT 1 for singular superlative query")
except Exception:
pass
# Fix 4: department alias used with columns it doesn't have (emp_no, from_date, to_date)
# These columns belong to dept_manager. When both 'd' (department) and 'dm' (dept_manager)
# are present, redirect d.emp_no -> dm.emp_no, d.from_date -> dm.from_date, d.to_date -> dm.to_date
# and fix wrong JOIN conditions like "ON d.emp_no = e.emp_no" -> "ON dm.emp_no = e.emp_no"
try:
_dept_alias = re.search(r'\bFROM\s+department\s+(\w+)\b', sql_query, re.IGNORECASE)
_dm_alias = re.search(r'\b(?:FROM|JOIN)\s+dept_manager\s+(\w+)\b', sql_query, re.IGNORECASE)
if _dept_alias and _dm_alias and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_da = _dept_alias.group(1) # e.g. 'd'
_dma = _dm_alias.group(1) # e.g. 'dm'
_cols_department_lacks = ['emp_no', 'from_date', 'to_date']
_fixed = False
for _col in _cols_department_lacks:
_bad = rf'\b{re.escape(_da)}\.{_col}\b'
if re.search(_bad, sql_query, re.IGNORECASE):
sql_query = re.sub(_bad, f'{_dma}.{_col}', sql_query, flags=re.IGNORECASE)
_fixed = True
if _fixed:
print(f"[POST-FIX] Redirected {_da}.{{emp_no|from_date|to_date}} -> {_dma}.* (department lacks these columns)")
except Exception:
pass
# Fix 5: The employee table does not have a 'to_date' column.
# If employee is the ONLY table (no JOINs) but the query has "to_date = '9999-01-01'", remove it.
# This happens because the system prompt universally instructs filtering current rows with to_date.
try:
if (re.search(r'\bFROM\s+employee\b', sql_query, re.IGNORECASE)
and not re.search(r'\bJOIN\b', sql_query, re.IGNORECASE)
and ACTIVE_SCHEMA_PROFILE.is_employee_db):
if re.search(r'\bto_date\s*=\s*\'9999-01-01\'', sql_query, re.IGNORECASE):
# Remove if it's the first/only condition
sql_query = re.sub(r'\bWHERE\s+(?:e\.)?to_date\s*=\s*\'9999-01-01\'\s*(?:AND)?\s*', 'WHERE ', sql_query, flags=re.IGNORECASE)
# Remove if it's a subsequent condition
sql_query = re.sub(r'\bAND\s+(?:e\.)?to_date\s*=\s*\'9999-01-01\'\s*', '', sql_query, flags=re.IGNORECASE)
# Cleanup trailing empty WHERE clauses
sql_query = re.sub(r'\bWHERE\s+(GROUP\s+BY|ORDER\s+BY|LIMIT|;|$)', r'\1', sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r'\bWHERE\s*$', '', sql_query, flags=re.IGNORECASE)
print("[POST-FIX] Removed invalid 'to_date' filter (employee table has no to_date column)")
except Exception:
pass
# Fix 6: hire_date only exists in the employee table. If it is prefixed by an alias belonging to another table,
# redirect it to the employee table's alias (or 'employee' if no alias).
try:
if re.search(r'\bhire_date\b', sql_query, re.IGNORECASE) and ACTIVE_SCHEMA_PROFILE.is_employee_db:
_emp_alias_match = re.search(r'\b(?:FROM|JOIN)\s+employee\s+(?:AS\s+)?(\w+)\b', sql_query, re.IGNORECASE)
_emp_alias = _emp_alias_match.group(1) if _emp_alias_match else 'employee'
def _fix_hire_date_alias(match):
_prefix = match.group(1)
if _prefix.lower() not in [_emp_alias.lower(), 'employee']:
return f"{_emp_alias}.hire_date"
return match.group(0)
_sql_query_new = re.sub(r'\b(\w+)\.hire_date\b', _fix_hire_date_alias, sql_query, flags=re.IGNORECASE)
if _sql_query_new != sql_query:
print(f"[POST-FIX] Redirected wrong alias for hire_date to {_emp_alias}.hire_date")
sql_query = _sql_query_new
except Exception:
pass
# Continue with the rest of the workflow as before, but always use sql_query (now validated/fixed)
# Pre-execution semantic/alias guard for title queries
if ACTIVE_SCHEMA_PROFILE.is_employee_db and re.search(r"from\s+title\b", sql_query, re.IGNORECASE):
if re.search(r"t\.(?:emp_no|title|to_date)", sql_query, re.IGNORECASE) and not re.search(r"from\s+title\s+(?:as\s+)?t\b", sql_query, re.IGNORECASE):
print("Injecting missing alias 't' for title before execution")
sql_query = re.sub(r"from\s+title\b", "FROM title t", sql_query, flags=re.IGNORECASE)
if re.search(r"where\s+.*department\s*=", sql_query, re.IGNORECASE) and not re.search(r"join\s+department", sql_query, re.IGNORECASE):
print("Adjusting misuse of department in title-only query before execution")
sql_query = adjust_title_query(sql_query, question, None)
# Sanitize: move misplaced WHERE clauses that appear after GROUP BY/ORDER BY back to before them
try:
upper_sql = sql_query.upper()
where_m = re.search(r'\bWHERE\b', upper_sql)
if where_m:
where_idx = where_m.start()
remainder = upper_sql[where_idx + 5:]
next_clause_m = re.search(r'\b(GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b', remainder)
if next_clause_m:
next_rel = where_idx + 5 + next_clause_m.start()
where_clause = sql_query[where_idx:next_rel]
sql_query = sql_query[:where_idx] + sql_query[next_rel:]
grp_m = re.search(r'\bGROUP\s+BY\b', sql_query, re.IGNORECASE)
ord_m = re.search(r'\bORDER\s+BY\b', sql_query, re.IGNORECASE)
lim_m = re.search(r'\bLIMIT\b', sql_query, re.IGNORECASE)
if grp_m:
insert_at = grp_m.start()
elif ord_m:
insert_at = ord_m.start()
elif lim_m:
insert_at = lim_m.start()
else:
insert_at = len(sql_query)
sql_query = sql_query[:insert_at] + where_clause + ' ' + sql_query[insert_at:]
print("[AUTO-FIX] Repositioned WHERE clause before GROUP/ORDER/LIMIT.")
except Exception:
pass
try:
# DISABLED: Schema mapper has a bug where it replaces table aliases with SQL keywords
# See GitHub issue: regex captures "ON" and "JOIN" as aliases, causing syntax errors
# The 3-level architecture generates correct SQL, so this post-processing is unnecessary
skip_schema_mapper = True # FORCE DISABLED to prevent corruption
if not skip_schema_mapper:
normalized_sql = schema_aware_column_mapper(sql_query)
if normalized_sql.strip() != sql_query.strip():
print("[SCHEMA-MAPPER] Rewrote SQL to match inspected schema.")
print("Before:", sql_query)
print("After:", normalized_sql)
sql_query = normalized_sql
else:
print("[SCHEMA-MAPPER] Disabled to prevent corruption of valid SQL")
except Exception as _map_err:
print(f"Schema mapper error (continuing with original SQL): {_map_err}")
print(f"Executing query: {sql_query}")
if re.search(r"count\s*\(", sql_query, re.IGNORECASE):
print("[DIAGNOSTIC] Detected COUNT query. Full SQL (sanitized):")
print(sql_query)
# Fix stray semicolons before LIMIT — LLM sometimes puts ';' after ORDER BY clause
# Pattern 1: explicit ;\s*LIMIT (original)
if re.search(r";\s*LIMIT\s+\d+", sql_query, re.IGNORECASE):
sql_query = re.sub(r";\s*(LIMIT\s+\d+)", r" \1", sql_query, flags=re.IGNORECASE)
print("[AUTO-FIX] Removed stray semicolon before LIMIT")
# Pattern 2: semicolon after ORDER BY / GROUP BY / HAVING clause, before LIMIT on next line
sql_query = re.sub(r"(ORDER\s+BY\s+[^;]+);(\s*LIMIT)", r"\1\2", sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r"(GROUP\s+BY\s+[^;]+);(\s*LIMIT)", r"\1\2", sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r";\s*;+$", ";", sql_query)
try:
if re.search(r'\b(count|sum|avg|min|max)\s*\(', sql_query, flags=re.IGNORECASE) and 'group by' not in sql_query.lower():
sql_query = re.sub(r"\s+LIMIT\s+\d+\s*;?\s*$", ";" if sql_query.strip().endswith(';') else '', sql_query, flags=re.IGNORECASE)
sql_query = re.sub(r"\s+LIMIT\s+\d+\b", '', sql_query, flags=re.IGNORECASE)
except Exception:
pass
if re.search(r"\bdde\.", sql_query):
print("[AUTO-FIX] Correcting alias typo 'dde.' to 'de.'")
sql_query = re.sub(r"\bdde\.", "de.", sql_query)
if re.search(r"\bdee\.", sql_query):
print("[AUTO-FIX] Correcting alias typo 'dee.' to 'de.'")
sql_query = re.sub(r"\bdee\.", "de.", sql_query)
if (ACTIVE_SCHEMA_PROFILE.is_employee_db
and re.search(r"avg\s*\(\s*s\.salary", sql_query, re.IGNORECASE)
and 'group by' in sql_query.lower()):
# Only regenerate if SQL has actual issues — skip if already valid
_regen_pre_check = enhanced_validate_sql(sql_query)
if not _regen_pre_check.get('valid'):
print("[REGEN] Regenerating AVG salary per department query for correct structure via LLM.")
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, CATEGORY_AVG_SALARY
cat = classify_question(question)
if cat == CATEGORY_AVG_SALARY:
invs = build_invariants(cat, question)
missing = evaluate_invariants(sql_query, invs)
if missing:
regen_sql = regenerate_with_constraints(question, sql_query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
sql_query = regen_sql
else:
print("[REGEN] AVG salary REGEN skipped — SQL already valid")
if (ACTIVE_SCHEMA_PROFILE.is_employee_db
and re.match(r"\s*select\s+count\s*\(\s*\*\s*\)\s+from\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+limit", sql_query, flags=re.IGNORECASE)
and 'group by' not in sql_query.lower()):
print("[REGEN] Regenerating simple total count query for correct structure via LLM.")
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, CATEGORY_SIMPLE_COUNT_EMP
cat = classify_question(question)
if cat == CATEGORY_SIMPLE_COUNT_EMP:
invs = build_invariants(cat, question)
missing = evaluate_invariants(sql_query, invs)
if missing:
regen_sql = regenerate_with_constraints(question, sql_query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
sql_query = regen_sql
if (ACTIVE_SCHEMA_PROFILE.is_employee_db
and re.search(r"group\s+by\s+.*dept_name", sql_query, re.IGNORECASE)
and re.search(r"count\s*\(\s*\*\s*\)", sql_query, re.IGNORECASE)
and 'employee_count' not in sql_query.lower()):
try:
select_part, rest = sql_query.split('FROM', 1)
select_part_fixed = re.sub(r"count\s*\(\s*\*\s*\)\s*(?!as)", "COUNT(*) AS employee_count ", select_part, count=1, flags=re.IGNORECASE)
sql_query = select_part_fixed + 'FROM' + rest
except Exception:
pass
if ('dept_name' in sql_query.lower() and 'count(' in sql_query.lower() and 'dept_emp' not in sql_query.lower() and re.search(r'from\s+department', sql_query, re.IGNORECASE)):
try:
from .invariants import build_invariants, evaluate_invariants, regenerate_with_constraints, CATEGORY_EMP_COUNT_BY_DEPT
cat = classify_question(question)
if cat == CATEGORY_EMP_COUNT_BY_DEPT:
invs = build_invariants(cat, question)
missing = evaluate_invariants(sql_query, invs)
if missing:
print('[REGEN] Initiating LLM regeneration to add missing dept_emp join and related invariants')
regen_sql = regenerate_with_constraints(question, sql_query, missing, Settings.llm, run_llm_with_timeout, attempt=0)
if regen_sql:
sql_query = regen_sql
except Exception as regen_err:
print(f'[REGEN] Skipped regeneration due to error: {regen_err}')
if re.search(r"having\s+count\s*\(\s*\*\s*\)\s*=\s*\d+", sql_query, re.IGNORECASE) and 'join dept_emp' not in sql_query.lower():
repair = generate_employee_count_by_department_sql(question)
if repair:
print("[REPAIR] Replaced malformed HAVING constant count query with micro-generated employee count query")
sql_query = repair
hallucinated_tables = []
# Strip out EXTRACT(... FROM ...) and similar functions before scanning for tables
_sql_for_tables = re.sub(r'\bEXTRACT\s*\([^)]+\)', '', sql_query, flags=re.IGNORECASE)
for tbl in re.findall(r'\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b', _sql_for_tables, re.IGNORECASE):
if tbl and tbl.lower() not in [x.lower() for x in available_tables]:
hallucinated_tables.append(tbl)
if hallucinated_tables:
print(f"[REGEN] Hallucinated tables detected: {hallucinated_tables}. Regenerating SQL without them.")
feedback = f"Do NOT use these tables: {', '.join(hallucinated_tables)}. Use only these: {', '.join(available_tables)}. For current rows, use WHERE ...to_date = '9999-01-01'."
regen_sql = query_craft(question + " " + feedback)
sql_query = regen_sql
print("\n=== RESULT PRESENTER AGENT ===")
print("Executing query and preparing intelligent presentation...")
# Execute query using the present_results function
final_result = await present_results(question, sql_query)
# Add diagnostic verification for result quality
if final_result and isinstance(final_result, str):
print(f"[PRESENTER] Generated response of {len(final_result)} characters")
if len(final_result) > 100:
print("[PRESENTER] Response includes contextual insights and formatting")
else:
print("[PRESENTER] Concise response provided")
print("[PRESENTER] Result presentation completed successfully")
except Exception as exec_error:
error_msg = str(exec_error)
if 'syntax error' in error_msg.lower():
corrected = sql_query
corrected = re.sub(r';\s*LIMIT', ' LIMIT', corrected, flags=re.IGNORECASE)
if re.search(r'HAVING\s+COUNT\s*\(\s*\*\s*\)\s*=\s*\d+', corrected, flags=re.IGNORECASE) and not re.search(r'SELECT\s+.*COUNT\s*\(', corrected, flags=re.IGNORECASE):
try:
if re.search(r'from\s+department', corrected, re.IGNORECASE):
corrected = re.sub(r'^SELECT\s+dept_name', 'SELECT dept_name, COUNT(*) AS employee_count', corrected, flags=re.IGNORECASE)
corrected = re.sub(r'HAVING\s+COUNT\s*\(\s*\*\s*\)\s*=\s*\d+\s*', '', corrected, flags=re.IGNORECASE)
if 'GROUP BY' not in corrected.upper():
corrected = re.sub(r'FROM', 'FROM', corrected)
if not re.search(r'GROUP\s+BY', corrected, re.IGNORECASE):
corrected = re.sub(r'LIMIT', 'GROUP BY dept_name LIMIT', corrected, flags=re.IGNORECASE)
if 'employee_count' not in corrected.lower():
corrected = re.sub(r'COUNT\s*\(\s*\*\s*\)', 'COUNT(*) AS employee_count', corrected, count=1, flags=re.IGNORECASE)
except Exception:
pass
if corrected != sql_query:
print(f"[AUTO-FIX] Applying syntax corrections: {corrected}")
try:
results = await execute_query(corrected)
memory_manager.force_collect_garbage()
final_result = format_results(results, question)
update_history(question, final_result)
return final_result
except Exception as post_fix_err:
print(f"[AUTO-FIX] Corrected query still failed: {post_fix_err}")
if not 'tried_regen_after_error' in locals():
tried_regen_after_error = False
if not tried_regen_after_error and 'does not exist' in error_msg.lower() and 'column' in error_msg.lower():
tried_regen_after_error = True
missing_cols = []
for m in re.findall(r'column "([a-zA-Z0-9_\.]+)" does not exist', error_msg, flags=re.IGNORECASE):
missing_cols.append(m)
feedback = f"Previously generated SQL referenced non-existent columns: {', '.join(missing_cols)}. Remove or replace them using ONLY columns from the provided schemas."
print(f"[REGEN_AFTER_ERROR] Feedback: {feedback}")
if "relation" in error_msg.lower() and "does not exist" in error_msg.lower():
match = re.search(r'relation "([^"]+)" does not exist', error_msg.lower())
if match:
bad_table = match.group(1)
if bad_table.endswith('s') and bad_table[:-1] in available_tables:
fixed_query = sql_query.replace(bad_table, bad_table[:-1])
print(f"Fixing pluralization issue: {bad_table} -> {bad_table[:-1]}")
print(f"Retrying with fixed query: {fixed_query}")
try:
results = await execute_query(fixed_query)
final_result = format_results(results, question)
except Exception as retry_error:
print(f"Retry failed with error: {str(retry_error)}")
final_result = f"I encountered an error while querying the database: {str(retry_error)}\n\nPlease try rephrasing your question or ask about a different aspect of the data."
else:
available_tables_str = ", ".join(available_tables)
final_result = f"I encountered an error with the database query. The table '{bad_table}' doesn't exist in the database. Available tables are: {available_tables_str}."
else:
final_result = f"I encountered an error while querying the database: {error_msg}"
else:
if "syntax error" in error_msg.lower():
final_result = f"I encountered a syntax error in the SQL query. Please try rephrasing your question: {error_msg}"
else:
final_result = f"I encountered an error while querying the database: {error_msg}"
# Final system cleanup and reporting (separate from Result Presenter Agent)
print("\n--- Post-Query System Cleanup ---")
if hasattr(memory_manager, 'perform_full_cleanup'):
memory_manager.perform_full_cleanup()
else:
memory_manager.force_collect_garbage()
stats = memory_manager.get_memory_stats()
print(f"Memory after processing: {stats['available_mb']:.2f}MB available")
update_history(question, final_result)
return final_result
async def main():
global llm
# Initialize memory management
print("\n--- Memory Management Initialization ---")
# Force garbage collection and memory monitoring
memory_manager.force_collect_garbage()
# Check memory before starting
print("\n--- System Resource Check ---")
stats = memory_manager.get_memory_stats()
print(f"Memory status: {stats['available_mb']:.2f}MB available of {stats['total_mb']:.2f}MB total")
if stats['available_mb'] < 1000:
print("⚠️ WARNING: System memory is low at startup. Application may experience issues.")
print("Please close other applications to free up memory before proceeding.")
print("At least 1GB of free memory is recommended for reliable operation.")
# Continue anyway, but with a warning
# Initialize database
print("\n--- Database Initialization ---")
db_metadata = await initialize_database(uri=DB_CONNECTION_URL)
if not db_metadata["tables"]:
print("Database initialization failed: No tables found")
return
print(f"Database initialized with tables: {available_tables}")
# Print welcome message
print("\nWelcome to the SQL Assistant! Type 'quit' to exit.")
print("You can ask questions about the employee database, and I'll help you find the answers.")
print("Example questions:")
print("- What is the average salary for employees?")
print("- How many employees work in each department?")
print("- Who are the current department managers?")
print("\nWhat would you like to know?")
# Main loop
consecutive_errors = 0
max_consecutive_errors = 3
while True:
try:
# Blocking input removed for non-interactive/cloud stability
# For local terminal use, uncomment the line below:
# user_question = input("\nYour question: ").strip()
print("\n[INFO] Non-interactive mode: exiting main loop.")
break
# Handle empty input
if not user_question:
print("Please enter a question or type 'quit' to exit.")
continue
# Reset consecutive errors on successful input
consecutive_errors = 0
# Run garbage collection before each query
gc.collect()
except Exception as e:
print(f"Error in main loop: {e}")
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
print("Multiple consecutive errors detected. Performing memory cleanup...")
gc.collect()
consecutive_errors = 0
# Skip this iteration on input/read error
continue
# Check if there's enough memory to safely process the request
if not check_memory_safety():
stats_now = memory_manager.get_memory_stats()
# If memory is critically low, refuse to proceed
if stats_now['available_mb'] < memory_manager.critical_mb:
print("System is critically low on memory. Please close other applications and try again.")
print("Cannot proceed due to critically low memory. Please close some applications and try again.")
continue
# Otherwise, operate in a degraded, aggressive memory mode instead of outright refusing
print(f"⚠️ Low memory warning: Only {stats_now['available_mb']:.2f}MB available, need at least {memory_manager.threshold_mb:.2f}MB")
print("Proceeding in degraded mode with aggressive resource reduction. Responses may be less detailed or slower.")
# Try to create a reduced LLM and continue; if that fails, then abort
try:
gc.collect()
reduced = initialize_llm(progressive_reduction=2)
if reduced is not None:
llm = reduced
Settings.llm = reduced
print("Degraded LLM initialized and set as active model.")
else:
print("Failed to initialize degraded LLM instance. Aborting request.")
continue
except Exception as e:
print(f"Failed to initialize degraded LLM due to error: {e}")
print("Cannot proceed due to low memory. Please close other applications and try again.")
continue
# Process the query through the agentic workflow
print(f"\n--- Processing Query: {user_question} ---")
try:
# Run the agentic workflow
result = await agentic_query_process(user_question)
# Display the result
print(f"\nAnswer: {result}")
# Run garbage collection after successful query
gc.collect()
except Exception as e:
# Handle potential memory or timeout errors
if "model requires more system memory" in str(e) or "memory" in str(e).lower() or "timed out" in str(e).lower() or "timeout" in str(e).lower():
print(f"\n⚠️ Model issue detected: {str(e)}")
# Try to free up memory
gc.collect()
# (No template fallback path.)
print(f"Trying with smaller model ({FALLBACK_MODEL_NAME})...")
# Create a new LLM with the smaller model and more aggressive memory settings
try:
fallback_llm = Ollama(
model=FALLBACK_MODEL_NAME,
base_url=OLLAMA_BASE_URL,
temperature=0.1,
request_timeout=30.0, # Shorter timeout for fallback model
additional_kwargs={
"num_ctx": 1024, # Ultra-minimal context window
"num_batch": 1, # Minimal batch size
"num_gpu": 0, # Force CPU mode
"f16_kv": True, # Use half-precision
"mirostat": 0, # Disable mirostat sampling
"num_thread": 1, # Minimal threads
"seed": 42, # Consistent seed
"top_k": 20,
"top_p": 0.9,
"num_predict": 256
}
)
# Temporarily replace the LLM
original_llm = llm
llm = fallback_llm
Settings.llm = fallback_llm
# Try again with the smaller model
try:
result = await agentic_query_process(user_question)
print(f"\nAnswer (using fallback model): {result}")
except Exception as inner_fallback_error:
print(f"Fallback model also failed: {str(inner_fallback_error)}")
print("All query generation methods failed. Please try rephrasing your question.")
except Exception as fallback_error:
print(f"Error with fallback model: {str(fallback_error)}")
print("All query generation methods failed. Please try rephrasing your question.")
# If we've had too many consecutive errors, trigger a manual garbage collection
if consecutive_errors >= max_consecutive_errors:
print("Multiple consecutive errors detected. Performing memory cleanup...")
gc.collect()
consecutive_errors = 0
if __name__ == "__main__":
asyncio.run(main())