File size: 19,671 Bytes
50efdc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
"""
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