SAP-ERP-AI-Agent / src /tools /text2sql.py
daisysooyeon's picture
deploy: SAP ERP AI Agent (HF Spaces docker)
50efdc6
Raw
History Blame Contribute Delete
19.7 kB
"""
src/tools/text_to_sql.py
Text-to-SQL: dd03l μŠ€ν‚€λ§ˆ μ»¨ν…μŠ€νŠΈ 기반 SQLite 검증 쿼리 생성
흐름:
1. LLM (worker_a_sql) β†’ SQL 생성
2. hardcoded fallback β†’ LLM μ‹€νŒ¨ μ‹œ
"""
import logging
import os
import re
import sqlite3
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from src.config import get_config
logger = logging.getLogger(__name__)
from pathlib import Path as _Path
DB_PATH = str(_Path(__file__).resolve().parent.parent.parent / "data" / "sap_erp.db")
# ---------------------------------------------------------------------------
# SAP ABAP λ”•μ…”λ„ˆλ¦¬ 기반 μŠ€ν‚€λ§ˆ μ»¨ν…μŠ€νŠΈ
# ---------------------------------------------------------------------------
SCHEMA_CONTEXT = """\
Table: VBAK (Sales Order Header)
VBELN: Sales order number (PK, TEXT 10-digit zero-padded), KUNNR: Customer code, AUDAT: Order date
Table: VBAP (Sales Order Item)
VBELN: Sales order number (FK), POSNR: Item number (INTEGER), MATNR: Material number,
KWMENG: Requested quantity (REAL), NETPR: Net price (REAL), ARKTX: Short text for sales order item
Table: VBEP (Delivery Schedule)
VBELN: Sales order number, POSNR: Item number, EDATU: Delivery date (TEXT YYYYMMDD), WMENG: Scheduled delivery quantity
Table: VBUP (Item Status)
VBELN: Sales order number, POSNR: Item number,
WBSTA: Goods movement status (A=Not yet processed, B=Partially processed, C=Fully processed)
Table: MARD (Plant Stock)
MATNR: Material number, WERKS: Plant, LGORT: Storage location, LABST: Unrestricted-use stock (REAL)
NOTE: a material has many MARD rows (one per plant/storage location). "available stock"
for an order line means the stock at THAT line's plant (VBAP.WERKS), summed over its
storage locations β€” NOT the material's stock across every plant.
Table: MAKT (Material Descriptions)
MATNR: Material number, SPRAS: Language key, MAKTX: Material description
Notes:
- Database engine is SQLite.
- VBELN is stored inconsistently: some rows are 10-digit zero-padded ('0000015353'),
others are raw numbers ('6105'). NEVER match VBELN with plain '=' equality.
Always normalize both sides numerically:
CAST(vbap.VBELN AS INTEGER) = CAST('<order_id>' AS INTEGER)
- POSNR is an INTEGER (e.g. 10, 20). Match with CAST(vbap.POSNR AS INTEGER) = CAST('<item_no>' AS INTEGER).
- VBAP and VBUP hold exactly ONE row per (VBELN, POSNR) β€” the order line is unique.
- EDATU is already stored as TEXT in ISO 'YYYY-MM-DD' format. Output it as-is (do NOT reformat).
"""
# ---------------------------------------------------------------------------
# Prompt
# ---------------------------------------------------------------------------
_SYSTEM_PROMPT = """\
You are a Text-to-SQL expert for SAP ERP data stored in SQLite.
Given the schema below and a request, generate a single valid SQLite SELECT query.
═════════════════ HARD RULES (violating ANY of these = wrong answer) ═════════════════
1. Output ONLY the raw SQL β€” no markdown, no explanation, no code fences.
2. The result must be exactly ONE row. It MUST ALWAYS include AT LEAST these five
aliases (use these EXACT alias names) β€” they are required on every query:
material_name, quantity, delivery_status, delivery_date, available_stock
These five are the MINIMUM, not the maximum: when the request needs extra context
to be validated, you MAY add further read-only columns (e.g. confirmed/open
quantity, plant WERKS, schedule line, requested date) with clear aliases. Never
drop, rename, or reorder the five required aliases. Use LEFT JOINs so missing data
yields NULL/0, never zero rows.
3. CRITICAL β€” MARD, MAKT, VBEP, VBUP have MULTIPLE rows per material/item (one row per
storage location / language / schedule line / status record). Direct JOIN + LIMIT 1
picks an ARBITRARY (often NULL or conflicting) row. You MUST aggregate them in subqueries:
available_stock β€” SUM stock at the ORDER LINE'S OWN PLANT (VBAP.WERKS), then
COALESCE to 0. Group MARD by (MATNR, WERKS) and join on BOTH MATNR and WERKS,
so you sum only the storage locations of that plant β€” never every plant:
LEFT JOIN (SELECT MATNR, WERKS, SUM(LABST) AS total_stock
FROM MARD GROUP BY MATNR, WERKS) stock
ON stock.MATNR = vbap.MATNR AND stock.WERKS = vbap.WERKS
SELECT COALESCE(stock.total_stock, 0) AS available_stock
material_name β€” prefer English (SPRAS='E'), else any language:
LEFT JOIN (SELECT MATNR,
COALESCE(MAX(CASE WHEN SPRAS='E' THEN MAKTX END), MIN(MAKTX)) AS material_name
FROM MAKT GROUP BY MATNR) makt ON makt.MATNR = vbap.MATNR
delivery_date β€” earliest schedule line, MIN(EDATU); EDATU is already ISO
'YYYY-MM-DD', so output it unchanged:
LEFT JOIN (SELECT VBELN, POSNR, MIN(EDATU) AS delivery_date
FROM VBEP WHERE EDATU IS NOT NULL GROUP BY VBELN, POSNR) vbep
ON CAST(vbep.VBELN AS INTEGER) = CAST(vbap.VBELN AS INTEGER)
AND CAST(vbep.POSNR AS INTEGER) = CAST(vbap.POSNR AS INTEGER)
delivery_status β€” one item can have several VBUP rows, sometimes with CONFLICTING
WBSTA. Pick the most advanced status deterministically with MAX(WBSTA) (A < B < C β€”
conservative: prefer "shipped" so a partially/fully processed item isn't wrongly editable):
LEFT JOIN (SELECT CAST(VBELN AS INTEGER) AS v, CAST(POSNR AS INTEGER) AS p,
MAX(WBSTA) AS delivery_status
FROM VBUP GROUP BY v, p) vbup
ON vbup.v = CAST(vbap.VBELN AS INTEGER) AND vbup.p = CAST(vbap.POSNR AS INTEGER)
4. ⚠️ MOST COMMON FAILURE β€” read carefully ⚠️
VBELN is stored INCONSISTENTLY in the DB: some rows are 10-digit zero-padded
('0000015353'), others are raw ('6105'). Plain string equality WILL fail and
return 0 rows for most inputs. You MUST normalize numerically on BOTH sides
of every VBELN comparison:
WHERE CAST(vbap.VBELN AS INTEGER) = CAST('<order_id>' AS INTEGER)
AND CAST(vbap.POSNR AS INTEGER) = CAST('<item_no>' AS INTEGER)
This applies to ALL VBELN/POSNR comparisons β€” WHERE clauses AND every
JOIN condition that uses VBELN or POSNR. Do NOT zero-pad the input yourself;
CAST handles it.
5. End with LIMIT 1 (with proper aggregation above, this only guards against
duplicate VBAP rows).
6. Use exact column and table names as shown in the schema.
═════════════════ EXAMPLES ═════════════════
❌ WRONG β€” this is what a careless LLM produces. It returns 0 rows because
VBELN is stored padded ('0000000040') but the request says '40', and MARD/MAKT/VBEP
are joined directly, picking arbitrary rows:
SELECT MAKT.MAKTX AS material_name, VBAP.KWMENG AS quantity,
VBUP.WBSTA AS delivery_status, VBEP.EDATU AS delivery_date,
COALESCE(MARD.LABST, 0) AS available_stock
FROM VBAP
LEFT JOIN MAKT ON VBAP.MATNR = MAKT.MATNR
LEFT JOIN VBUP ON VBAP.VBELN = VBUP.VBELN AND VBAP.POSNR = VBUP.POSNR
LEFT JOIN VBEP ON VBAP.VBELN = VBEP.VBELN AND VBAP.POSNR = VBEP.POSNR
LEFT JOIN MARD ON VBAP.MATNR = MARD.MATNR
WHERE VBAP.VBELN = '40' AND VBAP.POSNR = 10
LIMIT 1;
-- violations: rule 3 (no aggregation), rule 4 (no CAST on VBELN/POSNR)
βœ… CORRECT β€” aggregates MARD/MAKT/VBEP/VBUP in subqueries, scopes stock to the order's
plant (VBAP.WERKS), CASTs every VBELN/POSNR comparison to INTEGER, COALESCEs stock to 0:
SELECT
makt.material_name AS material_name,
vbap.KWMENG AS quantity,
vbup.delivery_status AS delivery_status,
vbep.delivery_date AS delivery_date,
COALESCE(stock.total_stock, 0) AS available_stock
FROM VBAP vbap
LEFT JOIN (
SELECT MATNR,
COALESCE(MAX(CASE WHEN SPRAS='E' THEN MAKTX END), MIN(MAKTX)) AS material_name
FROM MAKT GROUP BY MATNR
) makt ON makt.MATNR = vbap.MATNR
LEFT JOIN (
SELECT CAST(VBELN AS INTEGER) AS v, CAST(POSNR AS INTEGER) AS p,
MAX(WBSTA) AS delivery_status
FROM VBUP GROUP BY v, p
) vbup ON vbup.v = CAST(vbap.VBELN AS INTEGER)
AND vbup.p = CAST(vbap.POSNR AS INTEGER)
LEFT JOIN (
SELECT VBELN, POSNR, MIN(EDATU) AS delivery_date
FROM VBEP WHERE EDATU IS NOT NULL GROUP BY VBELN, POSNR
) vbep ON CAST(vbep.VBELN AS INTEGER) = CAST(vbap.VBELN AS INTEGER)
AND CAST(vbep.POSNR AS INTEGER) = CAST(vbap.POSNR AS INTEGER)
LEFT JOIN (
SELECT MATNR, WERKS, SUM(LABST) AS total_stock FROM MARD GROUP BY MATNR, WERKS
) stock ON stock.MATNR = vbap.MATNR AND stock.WERKS = vbap.WERKS
WHERE CAST(vbap.VBELN AS INTEGER) = CAST('40' AS INTEGER)
AND CAST(vbap.POSNR AS INTEGER) = CAST('10' AS INTEGER)
LIMIT 1;
Before answering, mentally check: did I CAST every VBELN/POSNR? Did I aggregate
MARD (by MATNR, WERKS), MAKT, VBEP? Did I join stock on BOTH MATNR and WERKS?
Did I COALESCE stock to 0? If any answer is "no", rewrite.
═════════════════ SCHEMA ═════════════════
{schema}
"""
_HUMAN_TEMPLATE = """\
Generate a SQLite SELECT query to validate the current state of a sales order item.
Parameters:
- VBELN (order_id): {order_id}
- POSNR (item_no) : {item_no}
Requested change to validate (decide whether any EXTRA columns are needed to check it;
the five required columns below must always be present regardless):
{request_context}
These columns are ALWAYS required in the result (use these exact aliases) β€” they are
the minimum, and you may add more read-only columns if the request needs extra context:
- material_name (MAKT.MAKTX β€” English preferred, else any language; see rule 3)
- quantity (from VBAP.KWMENG)
- delivery_status (from VBUP.WBSTA)
- delivery_date (earliest VBEP.EDATU, already ISO 'YYYY-MM-DD'; see rule 3)
- available_stock (SUM of MARD.LABST at the order's plant VBAP.WERKS, COALESCE to 0; see rule 3)
"""
_PROMPT = ChatPromptTemplate.from_messages([
("system", _SYSTEM_PROMPT),
("human", _HUMAN_TEMPLATE),
])
# ---------------------------------------------------------------------------
# LLM factory (OpenRouter)
# ---------------------------------------------------------------------------
def _build_openrouter_llm(model_name: str, temperature: float) -> ChatOpenAI:
api_key = os.getenv("OPENROUTER_API_KEY", "")
cfg = get_config()
return ChatOpenAI(
model=model_name,
temperature=temperature,
openai_api_key=api_key,
openai_api_base=cfg.openrouter.base_url,
default_headers={
"HTTP-Referer": "https://github.com/daisysooyeon/SAP-ERP-AI-Agent",
"X-Title": "SAP ERP AI Agent",
},
)
def _build_chain():
"""LLM 체인을 lazyν•˜κ²Œ 생성"""
cfg = get_config()
sql_cfg = cfg.models.worker_a_sql
return _PROMPT | _build_openrouter_llm(sql_cfg.name, sql_cfg.temperature)
_chain = None
def _get_chain():
global _chain
if _chain is None:
_chain = _build_chain()
return _chain
# ---------------------------------------------------------------------------
# SQL μ •μ œ 헬퍼
# ---------------------------------------------------------------------------
def _clean_sql(raw: str) -> str:
"""LLM μ‘λ‹΅μ—μ„œ λ§ˆν¬λ‹€μš΄ μ½”λ“œ 블둝 제거 ν›„ SQL만 μΆ”μΆœ"""
# ```sql ... ``` λ˜λŠ” ``` ... ``` 제거
raw = re.sub(r"```(?:sql)?", "", raw, flags=re.IGNORECASE).strip()
# 첫 번째 SELECT ~ λ¬Έμž₯만 μΆ”μΆœ
match = re.search(r"(SELECT\b.*)", raw, flags=re.IGNORECASE | re.DOTALL)
return match.group(1).strip() if match else raw.strip()
# ---------------------------------------------------------------------------
# Hardcoded fallback query
# ---------------------------------------------------------------------------
def _hardcoded_query(order_id: str, item_no: str) -> str:
# 이 μΏΌλ¦¬λŠ” ν‰κ°€μ˜ "μ •λ‹΅(golden)"을 λ§Œλ“œλŠ” λ ˆνΌλŸ°μŠ€μ΄κΈ°λ„ ν•˜λ‹€
# (generate_text2sql_dataset.pyκ°€ 동일 ν•¨μˆ˜λ₯Ό 호좜). λ”°λΌμ„œ ν”„λ‘¬ν”„νŠΈκ°€ LLMμ—κ²Œ
# μ§€μ‹œν•˜λŠ” μ˜λ―Έμ™€ 1:1둜 μΌμΉ˜ν•΄μ•Ό ν•œλ‹€. 결정적(deterministic) μ •μ˜:
# material_name : μ˜μ–΄(SPRAS='E') μš°μ„ , μ—†μœΌλ©΄ MIN(MAKTX)
# delivery_date : κ°€μž₯ 이λ₯Έ 일정라인 MIN(EDATU) β€” EDATUλŠ” 이미 ISO 'YYYY-MM-DD'
# available_stock : μ£Όλ¬Έ 라인의 ν”ŒλžœνŠΈ(VBAP.WERKS) ν•œμ • SUM(LABST), μ—†μœΌλ©΄ 0
# delivery_status : VBUPκ°€ (VBELN,POSNR)λ‹Ή μ—¬λŸ¬ ν–‰(λ•Œλ‘œ WBSTA 좩돌)일 수 μžˆμ–΄ MAX(WBSTA)둜
# 집계 β€” 좩돌 μ‹œ κ°€μž₯ μ§„ν–‰λœ μƒνƒœ(A<B<C)λ₯Ό κ²°μ •λ‘ μ Β·λ³΄μˆ˜μ μœΌλ‘œ 선택.
# 보쑰 ν…Œμ΄λΈ”μ„ λͺ¨λ‘ μ„œλΈŒμΏΌλ¦¬λ‘œ 라인당 1ν–‰μœΌλ‘œ μ ‘μœΌλ―€λ‘œ LIMIT 1은 μ•ˆμ „ κ°€λ“œμΌ 뿐이닀.
return f"""
SELECT
makt.material_name AS material_name,
vbap.KWMENG AS quantity,
vbup.delivery_status AS delivery_status,
vbep.delivery_date AS delivery_date,
COALESCE(stock.total_stock, 0) AS available_stock
FROM VBAP vbap
LEFT JOIN (
SELECT MATNR,
COALESCE(MAX(CASE WHEN SPRAS = 'E' THEN MAKTX END), MIN(MAKTX)) AS material_name
FROM MAKT GROUP BY MATNR
) makt ON makt.MATNR = vbap.MATNR
LEFT JOIN (
SELECT CAST(VBELN AS INTEGER) AS v, CAST(POSNR AS INTEGER) AS p,
MAX(WBSTA) AS delivery_status
FROM VBUP GROUP BY v, p
) vbup ON vbup.v = CAST(vbap.VBELN AS INTEGER)
AND vbup.p = CAST(vbap.POSNR AS INTEGER)
LEFT JOIN (
SELECT VBELN, POSNR, MIN(EDATU) AS delivery_date
FROM VBEP WHERE EDATU IS NOT NULL GROUP BY VBELN, POSNR
) vbep
ON CAST(vbep.VBELN AS INTEGER) = CAST(vbap.VBELN AS INTEGER)
AND CAST(vbep.POSNR AS INTEGER) = CAST(vbap.POSNR AS INTEGER)
LEFT JOIN (
SELECT MATNR, WERKS, SUM(LABST) AS total_stock FROM MARD GROUP BY MATNR, WERKS
) stock ON stock.MATNR = vbap.MATNR AND stock.WERKS = vbap.WERKS
WHERE CAST(vbap.VBELN AS INTEGER) = CAST('{order_id}' AS INTEGER)
AND CAST(vbap.POSNR AS INTEGER) = CAST('{item_no}' AS INTEGER)
LIMIT 1
"""
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def build_validation_query(
order_id: str,
item_no: str,
request_context: str | None = None,
) -> tuple[str, str]:
"""
LLM(primary β†’ hardcoded) μˆœμ„œλ‘œ SQLite 검증 쿼리λ₯Ό μƒμ„±ν•˜κ³  λ°˜ν™˜.
Args:
request_context: μ–΄λ–€ μš”μ²­μ„ κ²€μ¦ν•˜λ €λŠ”μ§€μ— λŒ€ν•œ μ„€λͺ…(μ•‘μ…˜ μ˜λ„). LLM이 ν•„μš”ν•œ
μΆ”κ°€ μ»¬λŸΌμ„ νŒλ‹¨ν•˜λŠ” 데 쓰인닀. None이면 일반 μƒνƒœ 쑰회둜 처리. ν•„μˆ˜ 5개
μ»¬λŸΌμ€ μ»¨ν…μŠ€νŠΈμ™€ λ¬΄κ΄€ν•˜κ²Œ 항상 ν¬ν•¨λœλ‹€. hardcoded fallback은 이λ₯Ό λ¬΄μ‹œν•œλ‹€.
Returns:
(sql, strategy) β€” strategyλŠ” λ‹€μŒ 쀑 ν•˜λ‚˜:
"primary" : LLM이 μ •μƒμ μœΌλ‘œ SQL을 생성
"hardcoded_llm_fail" : LLM 호좜/νŒŒμ‹± λ‹¨κ³„μ—μ„œ μ˜ˆμ™Έ β†’ hardcoded fallback μ‚¬μš©
(run_validation_queryλŠ” 여기에 더해 "hardcoded_zero_rows"λ₯Ό λ³„λ„λ‘œ μ‚¬μš©ν•œλ‹€.)
"""
invoke_input = {
"schema": SCHEMA_CONTEXT,
"order_id": order_id,
"item_no": item_no,
"request_context": request_context or "(none β€” general status validation)",
}
chain = _get_chain()
# ── 1. Primary LLM ─────────────────────────────────────────────────────
try:
response = chain.invoke(invoke_input)
sql = _clean_sql(response.content)
logger.info("[text_to_sql] strategy=primary | LLM 쿼리 생성 성곡 (order=%s item=%s)",
order_id, item_no)
logger.debug("[text_to_sql] SQL:\n%s", sql)
return sql, "primary"
except Exception as e:
# LLM μ‹€νŒ¨λŠ” μš΄μ˜μƒ μ£Όλͺ©ν•΄μ•Ό 함 β†’ ERROR 레벨 + λͺ…μ‹œμ  마컀
logger.error(
"[text_to_sql] FALLBACK(llm_fail) | LLM 호좜 μ‹€νŒ¨ β†’ hardcoded 쿼리 μ‚¬μš© "
"(order=%s item=%s) | reason=%s: %s",
order_id, item_no, type(e).__name__, e,
)
# ── 2. Hardcoded fallback ───────────────────────────────────────────────
return _hardcoded_query(order_id, item_no), "hardcoded_llm_fail"
def _execute_query(sql: str) -> dict | None:
"""단일 SQL을 μ‹€ν–‰ν•˜κ³  첫 행을 dict둜 λ°˜ν™˜ (μ—†κ±°λ‚˜ 였λ₯˜ μ‹œ None)."""
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
row = conn.execute(sql).fetchone()
return dict(row) if row else None
except sqlite3.Error as e:
logger.error("[text_to_sql] SQLite 였λ₯˜: %s\nSQL:\n%s", e, sql)
return None
finally:
conn.close()
def run_validation_query(
order_id: str,
item_no: str,
request_context: str | None = None,
) -> dict | None:
"""
build_validation_query()둜 μƒμ„±λœ 쿼리λ₯Ό SQLiteμ—μ„œ μ‹€ν–‰, 첫 번째 행을 dict둜 λ°˜ν™˜.
Primary(LLM) 쿼리가 0건을 λ°˜ν™˜ν•˜λ©΄ β€” LLM이 VBELN을 엄격 λ§€μΉ­(padded vs raw 뢈일치)
ν•˜λŠ” κ²½μš°κ°€ μžˆμœΌλ―€λ‘œ β€” robust hardcoded 쿼리둜 ν•œ 번 더 μž¬μ‹œλ„ν•œλ‹€. λ‘˜ λ‹€ μ‹€νŒ¨ μ‹œ None.
request_contextλŠ” LLM SQL μƒμ„±μ—λ§Œ μ „λ‹¬λœλ‹€(μΆ”κ°€ 컬럼 νŒλ‹¨μš©). 0건 μž¬μ‹œλ„ μ‹œ μ“°λŠ”
hardcoded μΏΌλ¦¬λŠ” 결정둠적 κ³ μ • μΏΌλ¦¬μ΄λ―€λ‘œ μ»¨ν…μŠ€νŠΈμ˜ 영ν–₯을 λ°›μ§€ μ•ŠλŠ”λ‹€.
fallback이 μΌμ–΄λ‚¬λŠ”μ§€ μš΄μ˜μƒ 좔적 κ°€λŠ₯ν•˜λ„λ‘, 각 κ²½λ‘œμ—μ„œ λͺ…μ‹œμ  WARNING 둜그λ₯Ό 남긴닀.
"""
sql, strategy = build_validation_query(order_id, item_no, request_context=request_context)
result = _execute_query(sql)
if result is None and strategy == "primary":
# LLM은 정상 ν˜ΈμΆœλλŠ”λ° μ‹€ν–‰ κ²°κ³Όκ°€ 0건 β†’ JOIN/WHERE κ·œμΉ™ λˆ„λ½μ΄ μ˜μ‹¬λ¨
logger.warning(
"[text_to_sql] FALLBACK(primary_zero_rows) | LLM SQL이 0건 λ°˜ν™˜ β†’ "
"hardcoded 쿼리둜 μž¬μ‹œλ„ (order=%s item=%s)",
order_id, item_no,
)
logger.debug("[text_to_sql] 0-row primary SQL:\n%s", sql)
result = _execute_query(_hardcoded_query(order_id, item_no))
if result is not None:
logger.warning(
"[text_to_sql] FALLBACK(primary_zero_rows) RECOVERED | hardcoded 쿼리가 "
"rowλ₯Ό λ°˜ν™˜ β†’ LLM 쿼리가 잘λͺ» λ§Œλ“€μ–΄μ‘Œλ‹€λŠ” κ°•ν•œ μ‹ ν˜Έ (order=%s item=%s)",
order_id, item_no,
)
else:
logger.warning(
"[text_to_sql] FALLBACK(primary_zero_rows) STILL_EMPTY | hardcoded 쿼리도 "
"0건 β†’ μ‹€μ œλ‘œ DB에 데이터가 없을 κ°€λŠ₯μ„± (order=%s item=%s)",
order_id, item_no,
)
logger.info("[text_to_sql] 쿼리 μ‹€ν–‰ μ™„λ£Œ: strategy=%s result=%s", strategy, result)
return result