File size: 24,088 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
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
"""
src/evaluation/eval_text2sql.py
Text-to-SQL 성곡λ₯  평가

νŒμ • κΈ°μ€€:
  [P1] SQL ꡬ문 μ‹€ν–‰ κ°€λŠ₯ μ—¬λΆ€          (syntax / runtime error β†’ FAIL)
  [P2] 응닡 μ‹œκ°„ 10초 이내              (timeout β†’ FAIL)
  [P3] ν•„μˆ˜ alias 컬럼 4개 포함 μ—¬λΆ€    (quantity, delivery_status, delivery_date, available_stock)
  [P4] expected_values 일치 μ—¬λΆ€        (expected_values=None은 SQL 정상 싀행이면 PASS;
                                         값이 λͺ…μ‹œλλŠ”λ° rowκ°€ μ—†μœΌλ©΄ FAIL β€” 이전엔 skipλ˜μ–΄ κ±°μ§“ PASSμ˜€μŒ)
  [P5] 생성 κΈ°μ€€(criteria) μ€€μˆ˜ μ—¬λΆ€    (LLM이 μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈμ˜ 핡심 κ·œμΉ™μ„ μ§€μΌ°λŠ”μ§€ 정적 검사;
                                         μ‹€ν–‰ κ²°κ³Όκ°€ μ•„λ‹Œ "μ–΄λ–€ κΈ°μ€€μœΌλ‘œ SQL을 λ§Œλ“€μ—ˆλŠ”μ§€" ν™•μΈμš©)

λͺ©ν‘œ: P1+P2+P3+P4 κΈ°μ€€ 95% 이상 (P4λŠ” expected_values=None이면 μžλ™ PASS) / P5(criteria) 별도 집계
      β†’ 이전엔 P1+P2+P3만 λ΄€λ”λ‹ˆ κ±°μ§“ 100%κ°€ λ‚˜μ™”μŒ. P4κ°€ μ‹€μ œ 정확도λ₯Ό λ‹΄λ³΄ν•˜λŠ” 핡심.

μ‹€ν–‰:
  python -m src.evaluation.eval_text2sql
  python -m src.evaluation.eval_text2sql --report reports/worker_a_eval_result.json
"""

from __future__ import annotations

import argparse
import json
import logging
import re
import sqlite3
import sys
import threading
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from src.tools.text2sql import build_validation_query, _hardcoded_query

# Windows cp949 ν™˜κ²½μ—μ„œ μœ λ‹ˆμ½”λ“œ 둜그 좜λ ₯ μ•ˆμ „ν™”
sys.stdout.reconfigure(encoding="utf-8", errors="replace")

# ---------------------------------------------------------------------------
# λ‘œκΉ… μ„€μ •
# ---------------------------------------------------------------------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    stream=sys.stdout,
)
logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# 경둜 μƒμˆ˜
# ---------------------------------------------------------------------------
DB_PATH        = "data/sap_erp.db"
TEST_CASES_PATH = "data/eval/text2sql_test_cases.json"
REQUIRED_ALIASES = {"quantity", "delivery_status", "delivery_date", "available_stock"}
TIMEOUT_SECS   = 10 # μ‘°μ • κ°€λŠ₯

# ---------------------------------------------------------------------------
# 데이터 클래슀
# ---------------------------------------------------------------------------

@dataclass
class CaseResult:
    id:          str
    description: str
    order_id:    str
    item_no:     int
    passed:      bool           # P1+P2+P3+P4 λͺ¨λ‘ 톡과 (P4=None이면 P1+P2+P3만으둜 νŒμ •)
    p1_syntax:   bool = False   # SQL μ‹€ν–‰ κ°€λŠ₯
    p2_timeout:  bool = False   # 10초 이내
    p3_columns:  bool = False   # ν•„μˆ˜ alias 쑴재
    p4_values:   bool | None = None  # κΈ°λŒ€κ°’ 일치 (None=검증 μƒλž΅)
    p5_criteria: bool = False   # 생성 κΈ°μ€€ λͺ¨λ‘ μΆ©μ‘± (score == total)
    elapsed_ms:  float = 0.0
    generated_sql: str = ""
    actual_result: Any = None
    error: str = ""
    sql_strategy:  str = ""     # "primary" | "fallback" | "hardcoded"
    p4_mismatches: list = field(default_factory=list)  # [{column, expected, actual}, ...]
    criteria_checks: dict = field(default_factory=dict)  # {rule_name: bool, ...}
    criteria_score: int = 0     # ν†΅κ³Όν•œ rule 수
    criteria_total: int = 0     # κ²€μ‚¬ν•œ rule 수
    # ── Fallback 좔적 (운영 λ‘œκΉ…μš© β€” evalμ—μ„œλ„ 같은 μ‹œκ·Έλ„μ„ λ³Έλ‹€) ─────────────
    fallback_triggered:  bool = False  # μš΄μ˜μ—μ„œ hardcoded fallback이 (ν˜Ήμ€ νŠΈλ¦¬κ±°λμ„) μΌ€μ΄μŠ€
    fallback_reason:     str  = ""     # "llm_fail" | "primary_zero_rows" | ""
    hardcoded_recovered: bool | None = None  # primary_zero_rows일 λ•Œ hardcodedκ°€ rowλ₯Ό μž‘μ•˜λŠ”μ§€


@dataclass
class EvalReport:
    generated_at:  str = ""
    model_primary: str = ""
    total:         int = 0
    p1_pass:       int = 0   # syntax OK
    p2_pass:       int = 0   # timeout OK
    p3_pass:       int = 0   # columns OK
    p4_pass:       int = 0   # values OK
    p4_total:      int = 0   # p4 검증 λŒ€μƒ 수
    p5_pass:       int = 0   # criteria μ „ κ·œμΉ™ 톡과
    overall_pass:  int = 0   # P1+P2+P3+P4 λͺ¨λ‘ 톡과 (P4=None은 PASS둜 κ°„μ£Ό)
    success_rate:  float = 0.0  # overall_pass / total
    p4_accuracy:   float = 0.0  # p4_pass / p4_total
    p5_compliance: float = 0.0  # p5_pass / total (μ „ κ·œμΉ™ 만쑱 λΉ„μœ¨)
    criteria_rule_pass: dict = field(default_factory=dict)  # {rule_name: 톡과 μΌ€μ΄μŠ€ 수}
    criteria_avg_score: float = 0.0  # μΌ€μ΄μŠ€λ‹Ή 평균 점수 (score/total)
    strategy_primary:    int = 0  # primary LLM으둜 μƒμ„±λœ μΌ€μ΄μŠ€ 수 (build μ‹œμ )
    strategy_hardcoded:  int = 0  # build λ‹¨κ³„μ—μ„œ hardcoded둜 폴백된 수 (LLM 호좜/νŒŒμ‹± μ‹€νŒ¨)
    fallback_zero_rows:  int = 0  # LLM SQL이 싀행은 λμ§€λ§Œ 0건 λ°˜ν™˜ β†’ μš΄μ˜μ—μ„œλŠ” hardcoded둜 μž¬μ‹œλ„λ˜λŠ” μΌ€μ΄μŠ€
    fallback_zero_rows_recoverable: int = 0  # κ·Έ 쀑 hardcoded둜 μž¬μ‹œλ„ν•˜λ©΄ rowκ°€ μž‘ν˜€ LLM 잘λͺ»μ΄ μž…μ¦λ˜λŠ” 수
    p4_mismatch_cases: list[dict] = field(default_factory=list)  # P4 뢈일치 μΌ€μ΄μŠ€ 상세
    fallback_cases:    list[dict] = field(default_factory=list)  # fallback이 μΌμ–΄λ‚¬κ±°λ‚˜ νŠΈλ¦¬κ±°λμ„ μΌ€μ΄μŠ€ 상세
    results:       list[dict] = field(default_factory=list)


# ---------------------------------------------------------------------------
# P5: SQL 생성 κΈ°μ€€(criteria) 정적 검사
# ---------------------------------------------------------------------------
#
# SQL 문법/싀행은 P1이 보고, μ—¬κΈ°μ„œλŠ” "LLM이 μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈμ˜ 핡심 κ·œμΉ™μ„ λ”°λžλŠ”κ°€"λ₯Ό
# 정적 μ •κ·œμ‹μœΌλ‘œ λ³Έλ‹€. rowκ°€ 비어도 β€” 즉 결과적으둜 0건이 λ‚˜μ™€λ„ β€” κ·œμΉ™μ„ μ–΄λ””μ„œ
# μ–΄κ²ΌλŠ”μ§€ λ³΄κ³ ν•˜κΈ° μœ„ν•¨μ΄λ‹€.
#
# κ·œμΉ™ (text2sql.py _SYSTEM_PROMPT μ°Έμ‘°):
#   r1 cast_vbeln          : VBELN을 CAST(... AS INTEGER)둜 μ •κ·œν™”        (rule 4)
#   r2 cast_posnr          : POSNR을 CAST(... AS INTEGER)둜 μ •κ·œν™”        (rule 4)
#   r3 mard_sum_aggregation: μ €μž₯μœ„μΉ˜λ³„ λ‹€ν–‰ β†’ SUM(LABST) 집계             (rule 3a)
#   r4 makt_lang_handling  : 언어별 λ‹€ν–‰ β†’ SPRAS κΈ°μ€€ μš°μ„ μˆœμœ„              (rule 3b)
#   r5 min_edatu           : 일정라인 κ°€μž₯ 이λ₯Έ λ‚ μ§œ β†’ MIN(EDATU)           (rule 3c)
#   r6 coalesce_zero       : NULL stock β†’ 0 으둜 보정 COALESCE(..., 0)     (rule 3a)

_CRITERIA_PATTERNS = {
    "cast_vbeln":           r"CAST\s*\(\s*[\w.]*VBELN\s+AS\s+INTEGER\s*\)",
    "cast_posnr":           r"CAST\s*\(\s*[\w.]*POSNR\s+AS\s+INTEGER\s*\)",
    "mard_sum_aggregation": r"SUM\s*\(\s*[\w.]*LABST\s*\)",
    "makt_lang_handling":   r"\bSPRAS\b",
    "min_edatu":            r"MIN\s*\(\s*[\w.]*EDATU\s*\)",
    "coalesce_zero":        r"COALESCE\s*\([^()]*,\s*0\s*\)",
}


def _check_criteria(sql: str) -> tuple[dict, int, int]:
    """LLM이 μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈμ˜ μ–΄λ–€ κ·œμΉ™μ„ μ§€μΌ°λŠ”μ§€ 정적 검사.

    Returns:
        (checks, score, total) β€” checksλŠ” {rule_name: bool}, score/total은 톡과/전체 κ·œμΉ™ 수
    """
    s = sql.upper()
    checks = {name: bool(re.search(pat, s, re.IGNORECASE)) for name, pat in _CRITERIA_PATTERNS.items()}
    score = sum(checks.values())
    total = len(checks)
    return checks, score, total

# ---------------------------------------------------------------------------
# μ‹€ν–‰ 헬퍼
# ---------------------------------------------------------------------------

def _run_with_timeout(fn, timeout: int):
    """fn()을 별도 μŠ€λ ˆλ“œλ‘œ μ‹€ν–‰, timeout 초 초과 μ‹œ TimeoutError"""
    result_box: list = [None]
    error_box:  list = [None]

    def target():
        try:
            result_box[0] = fn()
        except Exception as e:
            error_box[0] = e

    t = threading.Thread(target=target, daemon=True)
    t.start()
    t.join(timeout=timeout)

    if t.is_alive():
        raise TimeoutError(f"SQL execution exceeded {timeout}s")
    if error_box[0] is not None:
        raise error_box[0]
    return result_box[0]


def _execute_sql(sql: str, db_path: str = DB_PATH) -> tuple[list[dict], list[str]]:
    """SQL μ‹€ν–‰ β†’ (rows, column_names) νŠœν”Œ λ°˜ν™˜. rowκ°€ 없어도 컨럼λͺ… 확인 κ°€λŠ₯."""
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    try:
        cursor = conn.execute(sql)
        rows = [dict(row) for row in cursor.fetchall()]
        col_names = [d[0] for d in cursor.description] if cursor.description else []
        return rows, col_names
    finally:
        conn.close()

# ---------------------------------------------------------------------------
# μΌ€μ΄μŠ€ λ‹¨μœ„ 평가
# ---------------------------------------------------------------------------

def _evaluate_case(tc: dict, db_path: str = DB_PATH) -> CaseResult:

    order_id = str(tc["order_id"])
    item_no  = int(tc["item_no"])
    expected = tc.get("expected_values")  # None or dict

    cr = CaseResult(
        id=tc["id"],
        description=tc["description"],
        order_id=order_id,
        item_no=item_no,
        passed=False,
    )

    # ── SQL 생성 ─────────────────────────────────────────────────────────────
    try:
        sql, strategy = build_validation_query(order_id, str(item_no))
        cr.generated_sql = sql
        cr.sql_strategy = strategy
    except Exception as e:
        cr.error = f"SQL generation failed: {e}"
        logger.warning("[%s] SQL 생성 μ‹€νŒ¨: %s", tc["id"], e)
        return cr

    # ── P5: 생성 κΈ°μ€€(criteria) 정적 검사 ─────────────────────────────────────
    # SQL이 μ‹€ν–‰λ˜λ“  μ•ˆ λ˜λ“ , μ–΄λ–€ κ·œμΉ™μ„ μ§€μΌ°λŠ”μ§€ 항상 κΈ°λ‘ν•œλ‹€.
    checks, score, total = _check_criteria(sql)
    cr.criteria_checks = checks
    cr.criteria_score  = score
    cr.criteria_total  = total
    cr.p5_criteria     = (score == total)

    # ── P1: ꡬ문 μ‹€ν–‰ + P2: Timeout ──────────────────────────────────────────
    t0 = time.perf_counter()
    try:
        rows, col_names = _run_with_timeout(lambda: _execute_sql(sql, db_path), TIMEOUT_SECS)
        cr.elapsed_ms = (time.perf_counter() - t0) * 1000
        cr.p1_syntax  = True
        cr.p2_timeout = True
        cr.actual_result = rows[0] if rows else None
    except TimeoutError as e:
        cr.elapsed_ms = TIMEOUT_SECS * 1000
        cr.error = str(e)
        cr.p1_syntax  = True   # 싀행은 μ‹œμž‘λμœΌλ―€λ‘œ 문법 OK
        cr.p2_timeout = False
        logger.warning("[%s] timeout: %s", tc["id"], e)
        return cr
    except Exception as e:
        cr.elapsed_ms = (time.perf_counter() - t0) * 1000
        cr.error = str(e)
        cr.p1_syntax  = False
        cr.p2_timeout = False
        logger.warning("[%s] SQL execution failed: %s", tc["id"], e)
        return cr

    # ── P3: ν•„μˆ˜ alias 컬럼 포함 (row 유무 상관없이 cursor.description으둜 검증) ────
    actual_col_set = set(col_names)
    cr.p3_columns = REQUIRED_ALIASES.issubset(actual_col_set)
    if not cr.p3_columns:
        missing = REQUIRED_ALIASES - actual_col_set
        cr.error = f"Missing columns: {missing}"
        logger.warning("[%s] Missing columns: %s", tc["id"], missing)

    # ── Fallback 좔적 (μš΄μ˜μ—μ„œ hardcoded 폴백이 μ‹€μ œ μΌμ–΄λ‚¬κ±°λ‚˜, 일어났을 μΌ€μ΄μŠ€) ─────
    # eval은 build_validation_query만 ν˜ΈμΆœν•˜λ―€λ‘œ 운영 μ½”λ“œ(run_validation_query)의
    # "primary 0건 β†’ hardcoded μž¬μ‹œλ„" κ²½λ‘œλŠ” μžλ™μœΌλ‘œ 타지 μ•ŠλŠ”λ‹€.
    # 같은 μ‹œκ·Έλ„μ„ μž¬ν˜„ν•˜κΈ° μœ„ν•΄ μ—¬κΈ°μ„œ 동일 쑰건을 직접 ν™•μΈν•˜κ³  hardcoded κ²°κ³ΌκΉŒμ§€ 비ꡐ.
    if strategy == "hardcoded_llm_fail":
        cr.fallback_triggered = True
        cr.fallback_reason    = "llm_fail"
        logger.warning(
            "[%s] FALLBACK(llm_fail) | LLM 호좜 μ‹€νŒ¨ β†’ hardcoded 쿼리둜 평가됨",
            tc["id"],
        )
    elif strategy == "primary" and cr.actual_result is None:
        cr.fallback_triggered = True
        cr.fallback_reason    = "primary_zero_rows"
        # hardcoded 쿼리둜 μž¬μ‹€ν–‰ν•΄μ„œ LLM 잘λͺ»μΈμ§€ / 싀데이터 μ—†μŒμΈμ§€ κ°€λ₯Έλ‹€
        try:
            hc_rows, _ = _execute_sql(_hardcoded_query(order_id, str(item_no)), db_path)
            cr.hardcoded_recovered = len(hc_rows) > 0
            logger.warning(
                "[%s] FALLBACK(primary_zero_rows) | LLM SQL 0건, hardcoded μž¬μ‹œλ„ β†’ %s",
                tc["id"],
                "row 발견 (LLM 쿼리 였λ₯˜ κ°•ν•œ μ‹ ν˜Έ)" if cr.hardcoded_recovered
                    else "μ—¬μ „νžˆ 0건 (싀데이터 λΆ€μž¬ κ°€λŠ₯)",
            )
        except Exception as e:
            logger.warning("[%s] hardcoded μž¬μ‹œλ„ μ‹€νŒ¨: %s", tc["id"], e)
            cr.hardcoded_recovered = None
    # ── P4: κΈ°λŒ€κ°’ 일치 ───────────────────────────────────────────────────────
    if expected is not None:
        if cr.actual_result is None:
            # 이전 버그: μ—¬κΈ°μ„œ p4_values=None으둜 두면 평가 μ§‘κ³„μ—μ„œ ν†΅μ§Έλ‘œ λΉ μ Έ
            # "κ±°μ§“ 100% PASS"κ°€ λ‚˜μ™”λ‹€. κΈ°λŒ€κ°’μ΄ μžˆλŠ”λ° rowκ°€ 0건이면 λͺ…λ°±νžˆ FAIL.
            cr.p4_values = False
            cr.p4_mismatches = [{
                "column":  "<row>",
                "expected": "row exists matching expected_values",
                "actual":   "no row returned (SQL ran but matched 0 rows)",
            }]
            cr.error = (cr.error + " | " if cr.error else "") + \
                       "P4 FAIL: expected_values set but SQL returned 0 rows"
        else:
            mismatches = []
            for col, exp_val in expected.items():
                act_val = cr.actual_result.get(col)
                if act_val != exp_val:
                    mismatches.append({"column": col, "expected": exp_val, "actual": act_val})
            cr.p4_mismatches = mismatches
            cr.p4_values = (len(mismatches) == 0)
            if mismatches:
                mismatch_strs = [
                    f"{m['column']}: expected={m['expected']!r}, actual={m['actual']!r}"
                    for m in mismatches
                ]
                cr.error += " | Value mismatches: " + "; ".join(mismatch_strs)
    elif expected is None:
        # expected_values=null -> rowκ°€ μ—†μ–΄μ•Ό 정상 OR 컬럼이 맞으면 OK
        cr.p4_values = True  # no-row μΌ€μ΄μŠ€λŠ” SQL만 정상이면 PASS

    # ── μ΅œμ’… 톡과 νŒμ • ────────────────────────────────────────────────────────
    # P4κ°€ None(=검증 λŒ€μƒ μ•„λ‹˜)이면 기술 톡과(P1+P2+P3)만으둜 PASS,
    # P4κ°€ True/Falseλ©΄ κ·Έκ²ƒκΉŒμ§€ 포함해 PASS κ²°μ •. Falseλ©΄ μ¦‰μ‹œ FAIL.
    p4_ok = cr.p4_values is not False
    cr.passed = cr.p1_syntax and cr.p2_timeout and cr.p3_columns and p4_ok

    status = "PASS" if cr.passed else "FAIL"
    logger.info(
        "[%s] %s | elapsed=%.0fms | p1=%s p2=%s p3=%s p4=%s p5=%d/%d strategy=%s",
        tc["id"], status, cr.elapsed_ms,
        cr.p1_syntax, cr.p2_timeout, cr.p3_columns, cr.p4_values,
        cr.criteria_score, cr.criteria_total, cr.sql_strategy,
    )
    return cr

# ---------------------------------------------------------------------------
# 메인 평가 루프
# ---------------------------------------------------------------------------

def run_eval(
    test_cases_path: str = TEST_CASES_PATH,
    db_path:         str = DB_PATH,
    report_path:     str | None = None,
) -> EvalReport:
    from src.config import get_config

    cfg = get_config()
    test_cases = json.loads(Path(test_cases_path).read_text(encoding="utf-8"))
    logger.info("=== Text-to-SQL 평가 μ‹œμž‘ | μΌ€μ΄μŠ€ 수: %d ===", len(test_cases))

    report = EvalReport(
        generated_at   = datetime.now(timezone.utc).isoformat(),
        model_primary  = cfg.models.worker_a_sql.name,
        total          = len(test_cases),
    )

    # P5 rule별 λˆ„μ  μΉ΄μš΄ν„° β€” μ–΄λ–€ κ·œμΉ™μ΄ 자주 λˆ„λ½λ˜λŠ”μ§€ 보기 μœ„ν•¨
    rule_pass_counter: dict[str, int] = {name: 0 for name in _CRITERIA_PATTERNS.keys()}
    criteria_score_sum = 0

    for tc in test_cases:
        cr = _evaluate_case(tc, db_path)
        report.results.append(asdict(cr))

        if cr.p1_syntax:   report.p1_pass += 1
        if cr.p2_timeout:  report.p2_pass += 1
        if cr.p3_columns:  report.p3_pass += 1
        if cr.p5_criteria: report.p5_pass += 1
        if cr.passed:      report.overall_pass += 1

        if cr.sql_strategy == "primary":              report.strategy_primary += 1
        elif cr.sql_strategy == "hardcoded_llm_fail": report.strategy_hardcoded += 1

        if cr.fallback_triggered:
            if cr.fallback_reason == "primary_zero_rows":
                report.fallback_zero_rows += 1
                if cr.hardcoded_recovered:
                    report.fallback_zero_rows_recoverable += 1
            report.fallback_cases.append({
                "id":           cr.id,
                "description":  cr.description,
                "order_id":     cr.order_id,
                "item_no":      cr.item_no,
                "reason":       cr.fallback_reason,
                "hardcoded_recovered": cr.hardcoded_recovered,
                "sql_strategy": cr.sql_strategy,
            })

        # P5: rule별 톡과 카운트
        for rule_name, ok in cr.criteria_checks.items():
            if ok:
                rule_pass_counter[rule_name] = rule_pass_counter.get(rule_name, 0) + 1
        criteria_score_sum += cr.criteria_score

        if cr.p4_values is not None:
            report.p4_total += 1
            if cr.p4_values:
                report.p4_pass += 1
            else:
                report.p4_mismatch_cases.append({
                    "id":           cr.id,
                    "description":  cr.description,
                    "order_id":     cr.order_id,
                    "item_no":      cr.item_no,
                    "sql_strategy": cr.sql_strategy,
                    "actual_result": cr.actual_result,
                    "mismatches":   cr.p4_mismatches,
                })

    n = report.total
    report.success_rate = round(report.overall_pass / n, 4) if n else 0.0
    report.p4_accuracy  = round(report.p4_pass / report.p4_total, 4) if report.p4_total else 0.0
    report.p5_compliance = round(report.p5_pass / n, 4) if n else 0.0
    report.criteria_rule_pass = rule_pass_counter
    total_rules = len(_CRITERIA_PATTERNS)
    report.criteria_avg_score = round(criteria_score_sum / (n * total_rules), 4) if n else 0.0

    # ── μš”μ•½ 좜λ ₯ ─────────────────────────────────────────────────────────────
    print("\n" + "=" * 60)
    print("  Text-to-SQL 평가 κ²°κ³Ό")
    print("=" * 60)
    print(f"  Primary model  : {report.model_primary}")
    print(f"  Total cases    : {n}")
    print(f"  P1 Syntax OK   : {report.p1_pass}/{n}  ({report.p1_pass/n*100:.1f}%)")
    print(f"  P2 Timeout OK  : {report.p2_pass}/{n}  ({report.p2_pass/n*100:.1f}%)")
    print(f"  P3 Columns OK  : {report.p3_pass}/{n}  ({report.p3_pass/n*100:.1f}%)")
    print(f"  Overall PASS   : {report.overall_pass}/{n}  ({report.success_rate*100:.1f}%)  [Target >= 95%]")
    if report.p4_total:
        print(f"  P4 Value Match : {report.p4_pass}/{report.p4_total}  ({report.p4_accuracy*100:.1f}%)")
    print(f"  P5 Criteria    : {report.p5_pass}/{n}  ({report.p5_compliance*100:.1f}%)  "
          f"[avg score {report.criteria_avg_score*100:.1f}%]")
    target_met = report.success_rate >= 0.95
    print(f"  Target Met     : {'YES' if target_met else 'NO'}")
    print("-" * 60)
    print(f"  SQL Strategy   : primary={report.strategy_primary}  "
          f"hardcoded(llm_fail)={report.strategy_hardcoded}")
    print(f"  Fallback (운영 μ‹œ hardcoded μž¬μ‹œλ„κ°€ νŠΈλ¦¬κ±°λμ„ μΌ€μ΄μŠ€):")
    print(f"    primary_zero_rows         : {report.fallback_zero_rows}/{n}  "
          f"(LLM SQL이 0건 β†’ hardcoded μž¬μ‹œλ„ λ°œμƒ)")
    print(f"    └─ hardcoded둜 볡ꡬ κ°€λŠ₯ : {report.fallback_zero_rows_recoverable}/{report.fallback_zero_rows or 1}  "
          f"(LLM 쿼리가 μ‹€μ œλ‘œ 잘λͺ»λλ‹€λŠ” μ‹ ν˜Έ)")
    print("-" * 60)
    print("  P5 Rule-level pass rate (μ–΄λ–€ κΈ°μ€€μœΌλ‘œ SQL을 λ§Œλ“€μ—ˆλŠ”μ§€):")
    for rule_name, cnt in report.criteria_rule_pass.items():
        pct = (cnt / n * 100) if n else 0.0
        print(f"    {rule_name:<22}: {cnt}/{n}  ({pct:.1f}%)")
    print("=" * 60)

    # μ‹€νŒ¨ μΌ€μ΄μŠ€ 상세 좜λ ₯
    failed = [r for r in report.results if not r["passed"]]
    if failed:
        print(f"\n  Failed cases ({len(failed)}):")
        for r in failed:
            print(f"    [{r['id']}] {r['description']}")
            print(f"      Error: {r['error']}")

    # Fallback μΌ€μ΄μŠ€ 상세 좜λ ₯ (LLM이 μ–΄λ””μ„œ μ‹€νŒ¨ν•΄ hardcoded둜 λ–¨μ–΄μ‘ŒλŠ”μ§€)
    if report.fallback_cases:
        print(f"\n  Fallback cases ({len(report.fallback_cases)}):")
        for c in report.fallback_cases:
            recov = c.get("hardcoded_recovered")
            recov_str = (
                "hardcoded둜 row 회수 (LLM SQL 였λ₯˜)" if recov is True
                else "hardcoded도 0건"                if recov is False
                else "n/a"
            )
            print(f"    [{c['id']}] reason={c['reason']}  strategy={c['sql_strategy']}  β†’ {recov_str}")
            print(f"      order_id={c['order_id']} item_no={c['item_no']} β€” {c['description']}")

    # P4 뢈일치 μΌ€μ΄μŠ€ 상세 좜λ ₯
    if report.p4_mismatch_cases:
        print(f"\n  P4 Value Mismatch cases ({len(report.p4_mismatch_cases)}):")
        for c in report.p4_mismatch_cases:
            print(f"    [{c['id']}] {c['description']}  (strategy={c['sql_strategy']})")
            print(f"      actual_result: {c['actual_result']}")
            for m in c["mismatches"]:
                print(f"      {m['column']}: expected={m['expected']!r}  actual={m['actual']!r}")

    # ── 리포트 μ €μž₯ ─────────────────────────────────────────────────────────
    if report_path:
        out = Path(report_path)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(json.dumps(asdict(report), indent=2, ensure_ascii=False), encoding="utf-8")
        print(f"\n  Report saved: {report_path}")

    return report

# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Text-to-SQL 성곡λ₯  평가")
    parser.add_argument(
        "--test-cases", default=TEST_CASES_PATH,
        help=f"ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€ JSON 경둜 (default: {TEST_CASES_PATH})"
    )
    parser.add_argument(
        "--db", default=DB_PATH,
        help=f"SQLite DB 경둜 (default: {DB_PATH})"
    )
    parser.add_argument(
        "--report", default=None,
        help="리포트 μ €μž₯ 경둜 (예: reports/worker_a_eval_result.json)"
    )
    args = parser.parse_args()

    run_eval(
        test_cases_path=args.test_cases,
        db_path=args.db,
        report_path=args.report,
    )