SAP-ERP-AI-Agent / src /data /generate_text2sql_dataset.py
daisysooyeon's picture
deploy: SAP ERP AI Agent (HF Spaces docker)
50efdc6
Raw
History Blame Contribute Delete
28.1 kB
"""
src/data/generate_text2sql_dataset.py
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Text-to-SQL ํ‰๊ฐ€์šฉ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ƒ์„ฑ๊ธฐ
ํ๋ฆ„:
1. data/sap_erp.db (SQLite)์—์„œ ๋‹ค์–‘ํ•œ ์‹œ๋‚˜๋ฆฌ์˜ค ์ผ€์ด์Šค๋ฅผ ์ง์ ‘ ์ƒ˜ํ”Œ๋ง
- WBSTA = A / B / C ์ผ€์ด์Šค
- MARD JOIN ๊ฐ€๋Šฅํ•œ ์ผ€์ด์Šค (available_stock ์žˆ์Œ)
- NULL ์ผ€์ด์Šค (no-join row)
- ์กด์žฌํ•˜์ง€ ์•Š๋Š” order_id (negative case)
2. ์ƒ˜ํ”Œ๋งํ•œ ์‹ค์ œ DB ๊ฐ’์œผ๋กœ expected_values ์ž๋™ ๊ณ„์‚ฐ
3. ๊ธฐ์กด text2sql_test_cases.json ํฌ๋งท์œผ๋กœ ์ €์žฅ
์‹คํ–‰:
python -m src.data.generate_text2sql_dataset
python -m src.data.generate_text2sql_dataset --n-samples 30 --output data/eval/text2sql_test_cases_gen.json
"""
from __future__ import annotations
import argparse
import json
import logging
import random
import sqlite3
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
load_dotenv()
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
_ROOT_FOR_LOG = Path(__file__).resolve().parent.parent.parent
_LOG_DIR = _ROOT_FOR_LOG / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(_LOG_DIR / "data_generation.log", encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
force=True,
)
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_DB = str(_ROOT / "data" / "sap_erp.db")
DEFAULT_OUTPUT = str(_ROOT / "data" / "eval" / "text2sql_test_cases_gen.json")
REQUIRED_COLUMNS = ["material_name", "quantity", "delivery_status", "delivery_date", "available_stock"]
# negative case์— ์‚ฌ์šฉํ•  ๊ฐ€์งœ order_id
NEGATIVE_ORDER_ID = "9999999999"
# ---------------------------------------------------------------------------
# ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ
# ---------------------------------------------------------------------------
@dataclass
class TestCase:
id: str
description: str
order_id: str
item_no: int
expected_columns: list[str]
expected_values: dict[str, Any] | None
golden_sql: str # ์ •๋‹ต(๋ ˆํผ๋Ÿฐ์Šค) SQL โ€” ํ”„๋กœ๋•์…˜ ๋„๊ตฌ์˜ _hardcoded_query ๊ฒฐ๊ณผ
notes: str
# ---------------------------------------------------------------------------
# DB ์ƒ˜ํ”Œ๋ง ์ฟผ๋ฆฌ ๋ชจ์Œ
# ---------------------------------------------------------------------------
# ๊ฐ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ (description_template, notes, sql) ํŠœํ”Œ๋กœ ์ •์˜
# SQL์€ (VBELN, POSNR, quantity, delivery_status, delivery_date, available_stock) ๋ฐ˜ํ™˜
_SAMPLING_QUERIES: list[tuple[str, str, str]] = [
# โ”€โ”€ ์ผ€์ด์Šค 1: WBSTA = A (๋ฏธ์ฒ˜๋ฆฌ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order with WBSTA=A (goods movement not yet processed)",
"WBSTA=A means goods movement not yet started",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
WHERE vbup.WBSTA = 'A'
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 2: WBSTA = B (๋ถ€๋ถ„ ์ฒ˜๋ฆฌ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order with WBSTA=B (partially processed)",
"WBSTA=B means goods movement partially processed",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
WHERE vbup.WBSTA = 'B'
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 3: WBSTA = C (์™„์ „ ์ฒ˜๋ฆฌ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order with WBSTA=C (fully processed)",
"WBSTA=C means goods movement fully completed",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
WHERE vbup.WBSTA = 'C'
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 4: MARD JOIN ๊ฐ€๋Šฅ (available_stock ์žˆ์Œ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order with available stock via MARD JOIN",
"MARD.LABST (unrestricted stock) should be present",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
mard.LABST AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
LEFT JOIN MARD mard ON vbap.MATNR = mard.MATNR
WHERE mard.LABST IS NOT NULL AND mard.LABST > 0
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 5: delivery_date๋งŒ ์žˆ๊ณ  stock ์—†์Œ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order with delivery date only (no stock join)",
"MARD join yields NULL โ€” delivery_date must be from VBEP",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
LEFT JOIN MARD mard ON vbap.MATNR = mard.MATNR
WHERE vbep.EDATU IS NOT NULL AND mard.LABST IS NULL
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 6: ๋น„๊ธฐ๋ณธ POSNR (POSNR != 10) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order item with non-default POSNR (item number != 10)",
"Tests that POSNR is handled correctly for non-standard line items",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
mard.LABST AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
LEFT JOIN MARD mard ON vbap.MATNR = mard.MATNR
WHERE vbap.POSNR != 10
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 7: ์ผ๋ฐ˜ ๊ธฐ๋ณธ ์ผ€์ด์Šค (ํŠน์ˆ˜ ์กฐ๊ฑด ์—†์Œ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Standard order โ€” baseline with all joins present",
"General case: material name, delivery status, and delivery date all populated",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
WHERE makt.MAKTX IS NOT NULL
AND vbup.WBSTA IS NOT NULL
AND vbep.EDATU IS NOT NULL
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 8: ๋‚ฉ๊ธฐ ์ดˆ๊ณผ ๋ฏธ์™„๋ฃŒ ์ฃผ๋ฌธ (overdue) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Overdue order โ€” delivery date in the past, goods movement incomplete",
"EDATU < today and WBSTA in (A, B) โ€” tests time-sensitive filtering",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
WHERE vbep.EDATU < strftime('%Y%m%d', 'now')
AND vbup.WBSTA IN ('A', 'B')
AND vbep.EDATU IS NOT NULL
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 9: ๋ฏธ๋ž˜ ๋‚ฉ๊ธฐ ์˜ˆ์ • ์ฃผ๋ฌธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Upcoming delivery โ€” delivery date in the future, not yet processed",
"EDATU >= today and WBSTA = A โ€” tests future-date query handling",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
WHERE vbep.EDATU >= strftime('%Y%m%d', 'now')
AND vbup.WBSTA = 'A'
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 10: ์žฌ๊ณ ๊ฐ€ ์ฃผ๋ฌธ๋Ÿ‰ ์ด์ƒ์ธ ๊ฒฝ์šฐ (stock surplus) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order where available stock exceeds ordered quantity",
"MARD.LABST > VBAP.KWMENG โ€” tests stock-vs-quantity comparison logic",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
mard.LABST AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
LEFT JOIN MARD mard ON vbap.MATNR = mard.MATNR
WHERE mard.LABST > vbap.KWMENG
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 11: ๋‹ค์ค‘ ๋ผ์ธ ์•„์ดํ…œ ์ฃผ๋ฌธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Multi-line order โ€” sales order containing more than one item",
"Tests item disambiguation when VBELN has multiple POSNRs",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
NULL AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
WHERE vbap.VBELN IN (
SELECT VBELN FROM VBAP GROUP BY VBELN HAVING COUNT(DISTINCT POSNR) > 1
)
ORDER BY RANDOM()
LIMIT {limit}
""",
),
# โ”€โ”€ ์ผ€์ด์Šค 12: ์žฌ๊ณ  ์—†์Œ (LABST = 0 ๋˜๋Š” JOIN ๋ถˆ๊ฐ€) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(
"Order with zero or no available stock",
"MARD.LABST = 0 or NULL โ€” tests zero-stock edge case handling",
"""
SELECT
vbap.VBELN AS order_id,
vbap.POSNR AS item_no,
makt.MAKTX AS material_name,
vbap.KWMENG AS quantity,
vbup.WBSTA AS delivery_status,
vbep.EDATU AS delivery_date,
COALESCE(mard.LABST, 0.0) AS available_stock
FROM VBAP vbap
LEFT JOIN MAKT makt ON vbap.MATNR = makt.MATNR AND makt.SPRAS = 'E'
LEFT JOIN VBUP vbup ON CAST(vbap.VBELN AS INTEGER) = CAST(vbup.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbup.POSNR AS INTEGER)
LEFT JOIN VBEP vbep ON CAST(vbap.VBELN AS INTEGER) = CAST(vbep.VBELN AS INTEGER) AND CAST(vbap.POSNR AS INTEGER) = CAST(vbep.POSNR AS INTEGER)
LEFT JOIN MARD mard ON vbap.MATNR = mard.MATNR
WHERE (mard.LABST IS NULL OR mard.LABST = 0)
AND vbap.KWMENG > 0
AND makt.MAKTX IS NOT NULL
ORDER BY RANDOM()
LIMIT {limit}
""",
),
]
# ---------------------------------------------------------------------------
# DB ์—ฐ๊ฒฐ ํ—ฌํผ
# ---------------------------------------------------------------------------
def _query(db_path: str, sql: str, conn: sqlite3.Connection | None = None) -> list[dict]:
"""SQL ์‹คํ–‰ โ†’ dict ๋ฆฌ์ŠคํŠธ. conn์ด ์ฃผ์–ด์ง€๋ฉด ๊ทธ ์—ฐ๊ฒฐ์„ ์žฌ์‚ฌ์šฉ(๋‹ซ์ง€ ์•Š์Œ),
์—†์œผ๋ฉด ์ผํšŒ์„ฑ ์—ฐ๊ฒฐ์„ ์—ด๊ณ  ๋‹ซ๋Š”๋‹ค(ํ•˜์œ„ํ˜ธํ™˜). ๋ฐฐ์น˜ ์ƒ˜ํ”Œ๋ง์—์„  conn์„ ์žฌ์‚ฌ์šฉํ•ด
์—ฐ๊ฒฐ ์˜คํ”ˆ ๋น„์šฉ + SQLite ํŽ˜์ด์ง€ ์บ์‹œ ์ฝœ๋“œ์Šคํƒ€ํŠธ๋ฅผ ํ”ผํ•œ๋‹ค."""
own = conn is None
if own:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(sql).fetchall()
return [dict(r) for r in rows]
finally:
if own:
conn.close()
def _edatu_to_iso(edatu: str | None) -> str | None:
"""YYYYMMDD โ†’ YYYY-MM-DD ๋ณ€ํ™˜ (์ด๋ฏธ ISO๋ฉด ๊ทธ๋Œ€๋กœ)"""
if edatu and len(edatu) == 8:
return f"{edatu[:4]}-{edatu[4:6]}-{edatu[6:]}"
return edatu
# ์ƒ˜ํ”Œ๋งยท์บ๋…ธ๋‹ˆ์ปฌ ์ฟผ๋ฆฌ๊ฐ€ ์“ฐ๋Š” CAST(...) ์กฐ์ธ ํ‚ค์— ๋Œ€ํ•œ ํ‘œํ˜„์‹ ์ธ๋ฑ์Šค.
# ์ด๊ฒŒ ์—†์œผ๋ฉด CAST ์–‘๋ณ€ ์กฐ์ธ์ด ์ธ๋ฑ์Šค๋ฅผ ๋ชป ํƒ€ VBAPร—VBUP ์ค‘์ฒฉ๋ฃจํ”„(์ˆ˜์‹ญ์–ต ๋น„๊ต)๋กœ ํญ๋ฐœํ•œ๋‹ค
# (์‹œ๋‚˜๋ฆฌ์˜ค ์ฟผ๋ฆฌ 1ํšŒ ~254์ดˆ ๊ด€์ธก). ์ธ๋ฑ์Šค๊ฐ€ ์žˆ์œผ๋ฉด ~0.2์ดˆ๋กœ ๋–จ์–ด์ง„๋‹ค. ๋ฉฑ๋“ฑ ์ƒ์„ฑ.
_PERF_INDEXES = [
"CREATE INDEX IF NOT EXISTS ix_vbup_norm ON VBUP(CAST(VBELN AS INTEGER), CAST(POSNR AS INTEGER))",
"CREATE INDEX IF NOT EXISTS ix_vbep_norm ON VBEP(CAST(VBELN AS INTEGER), CAST(POSNR AS INTEGER))",
"CREATE INDEX IF NOT EXISTS ix_mard_mw ON MARD(MATNR, WERKS)",
"CREATE INDEX IF NOT EXISTS ix_makt_m ON MAKT(MATNR)",
]
def _ensure_perf_indexes(conn: sqlite3.Connection) -> None:
"""์ƒ˜ํ”Œ๋ง ์„ฑ๋Šฅ์šฉ ์ธ๋ฑ์Šค๋ฅผ ๋ฉฑ๋“ฑ ์ƒ์„ฑ. ์‹คํŒจํ•ด๋„(์˜ˆ: ์ฝ๊ธฐ์ „์šฉ/๋ฝ) ํ๋ฆ„์„ ๋Š์ง€ ์•Š๋Š”๋‹ค."""
try:
for ddl in _PERF_INDEXES:
conn.execute(ddl)
conn.commit()
except sqlite3.Error as e:
logger.warning("Perf index creation skipped: %s", e)
def _canonical_query_and_row(
db_path: str, order_id: str, item_no: str | int,
conn: sqlite3.Connection | None = None,
) -> tuple[str, dict | None]:
"""expected_values + golden_sql์˜ ๋‹จ์ผ ์ถœ์ฒ˜(source of truth).
ํ”„๋กœ๋•์…˜ ํ”„๋กฌํ”„ํŠธ๊ฐ€ LLM์—๊ฒŒ ์ง€์‹œํ•˜๋Š” ์˜๋ฏธ์™€ 1:1๋กœ ์ผ์น˜ํ•˜๋Š” ๋ ˆํผ๋Ÿฐ์Šค ์ฟผ๋ฆฌ
(src.tools.text2sql._hardcoded_query)๋ฅผ ๊ทธ๋Œ€๋กœ ์‹คํ–‰ํ•ด ์ •๋‹ต ํ–‰์„ ๋งŒ๋“ ๋‹ค.
์‹œ๋‚˜๋ฆฌ์˜ค SQL์€ (order_id, item_no) ๋‹ค์–‘์„ฑ ์„ ํƒ์—๋งŒ ์“ฐ๊ณ , ๊ฐ’ยท์ •๋‹ตSQL์€ ์—ฌ๊ธฐ์„œ ์‚ฐ์ถœํ•œ๋‹ค.
์ด๋ ‡๊ฒŒ ํ•ด์•ผ ๊ณจ๋“ (๊ฐ’+SQL)๊ณผ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๊ฐ™์€ ๊ฒฐ์ •์  ์ •์˜๋ฅผ ๊ณต์œ ํ•œ๋‹ค.
conn์ด ์ฃผ์–ด์ง€๋ฉด ์žฌ์‚ฌ์šฉํ•œ๋‹ค(๋ฐฐ์น˜ ์ƒ˜ํ”Œ๋ง ์„ฑ๋Šฅ).
Returns: (golden_sql, ์ •๋‹ต ํ–‰ or None)
"""
from src.tools.text2sql import _hardcoded_query
sql = _hardcoded_query(str(order_id), str(item_no))
rows = _query(db_path, sql, conn=conn)
return sql, (rows[0] if rows else None)
# ---------------------------------------------------------------------------
# ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ๋นŒ๋”
# ---------------------------------------------------------------------------
def _build_expected_values(row: dict) -> dict | None:
"""DB ํ–‰์—์„œ expected_values dict๋ฅผ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค."""
ev: dict = {}
if row.get("material_name"):
ev["material_name"] = row["material_name"]
if row.get("quantity") is not None:
ev["quantity"] = float(row["quantity"])
if row.get("delivery_status"):
ev["delivery_status"] = row["delivery_status"]
if row.get("delivery_date"):
ev["delivery_date"] = _edatu_to_iso(row["delivery_date"])
if row.get("available_stock") is not None:
ev["available_stock"] = float(row["available_stock"])
return ev if ev else None
def _sample_cases(
db_path: str,
n_per_scenario: int,
seed: int = 42,
) -> list[TestCase]:
"""๊ฐ ์ƒ˜ํ”Œ๋ง ์‹œ๋‚˜๋ฆฌ์˜ค์—์„œ n_per_scenario๊ฐœ์”ฉ ์ผ€์ด์Šค๋ฅผ ์ˆ˜์ง‘ํ•ฉ๋‹ˆ๋‹ค."""
random.seed(seed)
cases: list[TestCase] = []
# ์—ฐ๊ฒฐ 1๊ฐœ ์žฌ์‚ฌ์šฉ(264+ ์˜คํ”ˆ ๋ฐฉ์ง€ + ํŽ˜์ด์ง€ ์บ์‹œ ์œ ์ง€) + CAST ์กฐ์ธ ์ธ๋ฑ์Šค ๋ฉฑ๋“ฑ ์ƒ์„ฑ.
# ์ธ๋ฑ์Šค๊ฐ€ ์—†์œผ๋ฉด ์‹œ๋‚˜๋ฆฌ์˜ค ์ฟผ๋ฆฌ์˜ CAST ์–‘๋ณ€ ์กฐ์ธ์ด ์ค‘์ฒฉ๋ฃจํ”„๋กœ ํญ๋ฐœํ•œ๋‹ค(1ํšŒ ~254์ดˆ).
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
_ensure_perf_indexes(conn)
try:
for desc_tmpl, notes, sql_tmpl in _SAMPLING_QUERIES:
# ๋„‰๋„‰ํ•˜๊ฒŒ ์ฟผ๋ฆฌ ํ›„ ๋žœ๋ค ์„ ํƒ
sql = sql_tmpl.format(limit=n_per_scenario * 5)
try:
rows = _query(db_path, sql, conn=conn)
except Exception as e:
logger.warning("Sampling failed (%s): %s", desc_tmpl[:40], e)
continue
sampled = random.sample(rows, min(n_per_scenario, len(rows)))
for row in sampled:
order_id = str(row.get("order_id", "")).strip()
item_no = int(row.get("item_no", 10))
# expected_valuesยทgolden_sql์€ ์‹œ๋‚˜๋ฆฌ์˜ค ํ–‰์ด ์•„๋‹ˆ๋ผ ๋ ˆํผ๋Ÿฐ์Šค(์บ๋…ธ๋‹ˆ์ปฌ) ์ฟผ๋ฆฌ์—์„œ ์‚ฐ์ถœ
golden_sql, canon = _canonical_query_and_row(db_path, order_id, item_no, conn=conn)
cases.append(TestCase(
id="", # ๋‚˜์ค‘์— ๋ถ€์—ฌ
description=desc_tmpl,
order_id=order_id,
item_no=item_no,
expected_columns=REQUIRED_COLUMNS,
expected_values=_build_expected_values(canon) if canon else None,
golden_sql=golden_sql,
notes=notes,
))
finally:
conn.close()
return cases
def _add_negative_case(cases: list[TestCase]) -> None:
"""์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ๋ฒˆํ˜ธ ์ผ€์ด์Šค(expected_values=None)๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
์ •๋‹ต SQL์€ ์œ ํšจํ•˜์ง€๋งŒ ๊ฒฐ๊ณผ ํ–‰์ด ์—†๋Š” ์ผ€์ด์Šค๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค."""
from src.tools.text2sql import _hardcoded_query
cases.append(TestCase(
id="",
description="Non-existent order โ€” should return no row (None result)",
order_id=NEGATIVE_ORDER_ID,
item_no=10,
expected_columns=REQUIRED_COLUMNS,
expected_values=None,
golden_sql=_hardcoded_query(NEGATIVE_ORDER_ID, "10"),
notes="SQL must execute without error even if no rows returned",
))
# ---------------------------------------------------------------------------
# ๋ฉ”์ธ ์ƒ์„ฑ ํ•จ์ˆ˜
# ---------------------------------------------------------------------------
def generate_text2sql_dataset(
n_samples: int = 20,
output_path: str = DEFAULT_OUTPUT,
db_path: str = DEFAULT_DB,
seed: int = 42,
append: bool = False,
) -> list[dict]:
"""
SQLite DB๋ฅผ ์ƒ˜ํ”Œ๋งํ•˜์—ฌ text2sql ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
Parameters
----------
n_samples : ์‹œ๋‚˜๋ฆฌ์˜ค๋‹น ์ƒ˜ํ”Œ ์ˆ˜ (์‹ค์ œ ์ด ์ผ€์ด์Šค ์ˆ˜ โ‰ˆ n_samples ร— ์‹œ๋‚˜๋ฆฌ์˜ค ์ˆ˜)
output_path : ์ถœ๋ ฅ JSON ๊ฒฝ๋กœ
db_path : sap_erp.db ๊ฒฝ๋กœ
seed : ๋žœ๋ค ์‹œ๋“œ (์žฌํ˜„์„ฑ)
append : ๊ธฐ์กด ํŒŒ์ผ์— ์ถ”๊ฐ€ ์—ฌ๋ถ€
"""
if not Path(db_path).exists():
raise FileNotFoundError(
f"DB file not found: {db_path}\n"
"Check the paths.erp_db configuration in configs.yaml."
)
logger.info("Starting DB sampling: %s", db_path)
cases = _sample_cases(db_path, n_samples, seed)
_add_negative_case(cases)
# ์ค‘๋ณต (order_id, item_no) ์ œ๊ฑฐ
seen: set[tuple] = set()
deduped: list[TestCase] = []
for c in cases:
key = (c.order_id, c.item_no)
if key not in seen:
seen.add(key)
deduped.append(c)
cases = deduped
# id ๋ถ€์—ฌ
start_idx = 1
if append:
existing_path = Path(output_path)
if existing_path.exists():
try:
existing = json.loads(existing_path.read_text(encoding="utf-8"))
start_idx = len(existing) + 1
except Exception:
pass
for idx, c in enumerate(cases, start=start_idx):
c.id = f"tc_{idx:03d}"
# dict ๋ณ€ํ™˜
records = [asdict(c) for c in cases]
# append ์ฒ˜๋ฆฌ
if append:
existing_path = Path(output_path)
if existing_path.exists():
try:
existing = json.loads(existing_path.read_text(encoding="utf-8"))
records = existing + records
except Exception as e:
logger.warning("Failed to read existing file: %s", e)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(records, indent=2, ensure_ascii=False), encoding="utf-8")
# Summary
print("\n" + "=" * 60)
print(" Text-to-SQL Dataset Generation Results")
print("=" * 60)
print(f" DB Path : {db_path}")
print(f" Scenarios : {len(_SAMPLING_QUERIES) + 1}")
print(f" Generated Cases : {len(cases)}")
print(f" Output File : {output_path}")
print("=" * 60)
return records
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Text-to-SQL ํ‰๊ฐ€์šฉ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค ์ƒ์„ฑ")
parser.add_argument(
"--n-samples", type=int, default=10,
help="์‹œ๋‚˜๋ฆฌ์˜ค๋‹น ์ƒ˜ํ”Œ ์ˆ˜ (๊ธฐ๋ณธ: 10, ์ด ์ผ€์ด์Šค โ‰ˆ 10 ร— ์‹œ๋‚˜๋ฆฌ์˜ค ์ˆ˜)",
)
parser.add_argument(
"--output", default=DEFAULT_OUTPUT,
help=f"์ถœ๋ ฅ JSON ๊ฒฝ๋กœ (๊ธฐ๋ณธ: {DEFAULT_OUTPUT})",
)
parser.add_argument(
"--db", default=DEFAULT_DB,
help=f"SQLite DB ๊ฒฝ๋กœ (๊ธฐ๋ณธ: {DEFAULT_DB})",
)
parser.add_argument(
"--seed", type=int, default=42,
help="๋žœ๋ค ์‹œ๋“œ (๊ธฐ๋ณธ: 42)",
)
parser.add_argument(
"--append", action="store_true",
help="๊ธฐ์กด ์ถœ๋ ฅ ํŒŒ์ผ์— ๊ฒฐ๊ณผ๋ฅผ ์ถ”๊ฐ€",
)
return parser.parse_args()
if __name__ == "__main__":
args = _parse_args()
generate_text2sql_dataset(
n_samples=args.n_samples,
output_path=args.output,
db_path=args.db,
seed=args.seed,
append=args.append,
)