Files
2026-07-13 13:35:10 +08:00

233 lines
7.9 KiB
Python

import asyncio
import logging
import os
from pathlib import Path
from typing import Optional
import pandas as pd
from dotenv import load_dotenv
from openai import AsyncOpenAI
from ragas import Dataset, experiment
from ragas.metrics.discrete import discrete_metric
from ragas.metrics.result import MetricResult
import datacompy
from .db_utils import execute_sql
from .text2sql_agent import Text2SQLAgent
# Load environment variables
load_dotenv(".env")
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger(__name__)
# Suppress HTTP request logs from OpenAI/httpx
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("openai._base_client").setLevel(logging.WARNING)
@discrete_metric(name="execution_accuracy", allowed_values=["correct", "incorrect"])
def execution_accuracy(expected_sql: str, predicted_success: bool, predicted_result):
"""Compare execution results of predicted vs expected SQL using datacompy."""
try:
# Execute expected SQL
expected_success, expected_result = execute_sql(expected_sql)
# If expected SQL fails, it's incorrect
if not expected_success:
return MetricResult(
value="incorrect",
reason=f"Expected SQL failed to execute: {expected_result}"
)
# If predicted SQL fails, it's incorrect
if not predicted_success:
return MetricResult(
value="incorrect",
reason=f"Predicted SQL failed to execute: {predicted_result}"
)
# Both queries succeeded - compare DataFrames using datacompy
if isinstance(expected_result, pd.DataFrame) and isinstance(predicted_result, pd.DataFrame):
# Handle empty DataFrames
if expected_result.empty and predicted_result.empty:
return MetricResult(
value="correct",
reason="Both queries returned empty results"
)
# If one is empty and the other isn't, they're different
if expected_result.empty != predicted_result.empty:
return MetricResult(
value="incorrect",
reason=f"Expected returned {len(expected_result)} rows, predicted returned {len(predicted_result)} rows"
)
# Guard for very large results to avoid pathological comparisons
if len(expected_result) > 10000 or len(predicted_result) > 10000:
return MetricResult(
value="incorrect",
reason=(
f"Result too large to compare (expected_rows={len(expected_result)}, "
f"predicted_rows={len(predicted_result)}, max_rows=10000)"
),
)
# Use datacompy to compare DataFrames
try:
# Reset index to ensure clean comparison
expected_clean = expected_result.reset_index(drop=True)
predicted_clean = predicted_result.reset_index(drop=True)
# Compare using datacompy with index-based comparison
comparison = datacompy.Compare(
expected_clean,
predicted_clean,
on_index=True, # Compare row-by-row by index position
abs_tol=1e-10, # Very small tolerance for floating point comparison
rel_tol=1e-10,
df1_name='expected',
df2_name='predicted'
)
if comparison.matches():
return MetricResult(
value="correct",
reason=f"DataFrames match exactly ({len(expected_result)} rows, {len(expected_result.columns)} columns)"
)
else:
return MetricResult(
value="incorrect",
reason=f"DataFrames do not match. {comparison.report()}\nExpected: \n{expected_result}\nPredicted: \n{predicted_result}"
)
except Exception as comparison_error:
# If datacompy fails, report it as incorrect
return MetricResult(
value="incorrect",
reason=f"DataFrame comparison failed with datacompy: {str(comparison_error)}"
)
else:
return MetricResult(
value="incorrect",
reason="One or both query results are not DataFrames"
)
except Exception as e:
return MetricResult(
value="incorrect",
reason=f"Execution accuracy evaluation failed: {str(e)}"
)
@experiment()
async def text2sql_experiment(
row,
model: str,
prompt_file: Optional[str],
):
"""Experiment function for text-to-SQL evaluation."""
# Create text-to-SQL agent
openai_client = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
agent = Text2SQLAgent(
client=openai_client,
model_name=model,
prompt_file=prompt_file
)
# Generate SQL from natural language query
result = await agent.query(row["Query"])
# Execute predicted SQL
try:
predicted_success, predicted_result = execute_sql(result["sql"])
except Exception as e:
predicted_success, predicted_result = False, f"SQL execution failed: {str(e)}"
# Score the response using execution accuracy
accuracy_score = await execution_accuracy.ascore(
expected_sql=row["SQL"],
predicted_success=predicted_success,
predicted_result=predicted_result,
)
return {
"query": row["Query"],
"expected_sql": row["SQL"],
"predicted_sql": result["sql"],
"level": row["Levels"],
"execution_accuracy": accuracy_score.value,
"accuracy_reason": accuracy_score.reason,
}
def load_dataset(limit: Optional[int] = None):
"""Load the text-to-SQL dataset from CSV file."""
dataset_path = Path(__file__).parent / "datasets" / "booksql_sample.csv"
# Read CSV
df = pd.read_csv(dataset_path)
# Limit dataset size if requested
if limit is not None and limit > 0:
df = df.head(limit)
# Create Ragas Dataset
dataset = Dataset(name="text2sql_booksql", backend="local/csv", root_dir=".")
for _, row in df.iterrows():
dataset.append({
"Query": row["Query"],
"SQL": row["SQL"],
"Levels": row["Levels"],
"split": row["split"],
})
return dataset
async def main():
"""Simple demo script to run text-to-SQL evaluation."""
logger.info("TEXT-TO-SQL EVALUATION DEMO")
logger.info("=" * 40)
# Configuration
model = "gpt-5-mini"
prompt_file = None
name = "demo_evaluation"
limit = 5 # Only evaluate 5 samples for demo
# Validate API key is available
if not os.environ.get("OPENAI_API_KEY"):
logger.error("❌ Error: OPENAI_API_KEY environment variable is not set")
return
# Load dataset
logger.info("Loading dataset...")
dataset = load_dataset(limit=limit)
logger.info(f"Dataset loaded with {len(dataset)} samples")
logger.info(f"Running text-to-SQL evaluation with model: {model}")
# Run the experiment
results = await text2sql_experiment.arun(
dataset,
name=name,
model=model,
prompt_file=prompt_file,
)
# Report results
logger.info(f"✅ {name}: {len(results)} cases evaluated")
# Calculate and display accuracy
accuracy_rate = sum(1 for r in results if r["execution_accuracy"] == "correct") / max(1, len(results))
logger.info(f"{name} Execution Accuracy: {accuracy_rate:.2%}")
if __name__ == "__main__":
asyncio.run(main())