File size: 9,302 Bytes
fbd1091
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Step 16: 현업 정답셋 기반 검색엔진 품질 평가 (Top5)

현업이 검토한 Menusearch_100_answer.xlsx를 읽어
각 쿼리를 검색엔진에 돌리고 Top5 결과를 채워 저장.

실행:
    python scripts/16_eval_with_answer.py

출력:
    data/generated/Menusearch_100_answer.xlsx (Top1~5 컬럼 + 평가결과)
    콘솔: Acc@1, Acc@3, Acc@5
"""
import sys
import re
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

import pandas as pd
from openpyxl import load_workbook
from openpyxl.styles import PatternFill, Font, Alignment

from core.search_engine import MenuSearchEngine

EXCEL_PATH = Path(__file__).parent.parent / "data" / "generated" / "Menusearch_100_answer.xlsx"

# HyDE 활성 여부 (True: GPT 가상 문서 임베딩, False: 기존 쿼리 임베딩)
USE_HYDE = True


# ---------------------------------------------------------------------------
# 정답 비교 유틸
# ---------------------------------------------------------------------------

def normalize_path(path: str) -> str:
    """메뉴 경로 정규화: 공백/괄호 제거, 소문자"""
    if not path:
        return ""
    path = re.sub(r'\s*>\s*', '>', path)
    path = re.sub(r'\(.*?\)', '', path)
    return path.strip().lower()


def matches(pred: str, ans: str) -> bool:
    """말단 메뉴명 + 상위경로 겹침으로 정답 판단"""
    if not pred or not ans:
        return False
    if pred == ans:
        return True
    pred_end = pred.split(">")[-1].strip()
    ans_end  = ans.split(">")[-1].strip()
    if pred_end and ans_end and pred_end == ans_end:
        pred_parts = pred.split(">")
        ans_parts  = ans.split(">")
        overlap = sum(1 for p in pred_parts if p in ans_parts)
        return overlap >= min(2, len(ans_parts))
    return False


def evaluate(answer_cell, top_paths: list) -> tuple:
    """
    Acc@1, Acc@3, Acc@5 정답 여부 반환.
    정답 없는 케이스 → (None, None, None)
    """
    if not answer_cell or pd.isna(answer_cell):
        return None, None, None

    ans_lower = str(answer_cell).lower()
    # 메뉴 경로가 아닌 특수 케이스 제외
    first_line = ans_lower.split("\n")[0]
    if ">" not in first_line and any(
        first_line.startswith(m) for m in ["hts", "계좌개설", "마이페이지"]
    ):
        return None, None, None

    valid_answers = [normalize_path(a) for a in str(answer_cell).split("\n") if a.strip()]
    norm_top = [normalize_path(p) for p in top_paths if p]

    def hit_at(k):
        return any(matches(p, a) for p in norm_top[:k] for a in valid_answers)

    return hit_at(1), hit_at(3), hit_at(5)


# ---------------------------------------------------------------------------
# 메인
# ---------------------------------------------------------------------------

def main():
    mode_label = "HyDE 활성" if USE_HYDE else "일반 모드"
    print(f"[16_eval] 현업 정답셋 기반 검색엔진 평가 시작 (Top5, {mode_label})")

    # ── 1. 원본 Excel 로드 (쿼리/정답 컬럼만 사용) ─────────────────────────
    df_src = pd.read_excel(EXCEL_PATH, sheet_name="시트1", usecols=["No", "쿼리", "정답 메뉴"])
    # 이전 실행에서 추가된 요약 행 제거 (No가 숫자가 아닌 행)
    df_src = df_src[pd.to_numeric(df_src["No"], errors="coerce").notna()].reset_index(drop=True)
    print(f"[16_eval] {len(df_src)}개 쿼리 로드 완료")

    # ── 2. 검색엔진 초기화 ──────────────────────────────────────────────────
    print("[16_eval] 검색엔진 초기화 중...")
    engine = MenuSearchEngine.get_instance()
    print("[16_eval] 검색엔진 준비 완료\n")

    # ── 3. 각 쿼리 검색 (Top5) ──────────────────────────────────────────────
    rows = []
    acc1_list, acc3_list, acc5_list = [], [], []

    for _, row in df_src.iterrows():
        query      = str(row["쿼리"]).strip()
        answer_raw = row["정답 메뉴"] if pd.notna(row.get("정답 메뉴")) else None

        hits      = engine.search(query, top_n=5, threshold=0.0, use_hyde=USE_HYDE)
        top_paths = [h.get("menu_path", "") for h in hits]
        # 5개 미만이면 빈 문자열로 패딩
        while len(top_paths) < 5:
            top_paths.append("")
        top_sims  = [round(h["similarity"], 4) for h in hits]
        while len(top_sims) < 5:
            top_sims.append(0.0)

        a1, a3, a5 = evaluate(answer_raw, top_paths)
        acc1_list.append(a1)
        acc3_list.append(a3)
        acc5_list.append(a5)

        # 평가결과 라벨
        if a1 is None:
            label = "평가제외"
        elif a1:
            label = "Top1 정답"
        elif a3:
            label = "Top3 정답"
        elif a5:
            label = "Top5 정답"
        else:
            label = "오답"

        rows.append({
            "No":       row["No"],
            "쿼리":     query,
            "정답 메뉴": answer_raw if answer_raw else "",
            "Top1":     top_paths[0],
            "Top2":     top_paths[1],
            "Top3":     top_paths[2],
            "Top4":     top_paths[3],
            "Top5":     top_paths[4],
            "평가결과": label,
            "Top1 유사도": top_sims[0],
        })

        status = "[O]" if a1 else ("[  ]" if a1 is None else "[X]")
        print(f"  {status} [{int(row['No']):3d}] {query[:28]:<28} -> {top_paths[0]}")

    # ── 4. 통계 계산 ────────────────────────────────────────────────────────
    valid_count = sum(a is not None for a in acc1_list)
    skip_count  = 100 - valid_count
    acc1_n = sum(a for a in acc1_list if a is not None)
    acc3_n = sum(a for a in acc3_list if a is not None)
    acc5_n = sum(a for a in acc5_list if a is not None)

    acc1_rate = acc1_n / valid_count if valid_count else 0
    acc3_rate = acc3_n / valid_count if valid_count else 0
    acc5_rate = acc5_n / valid_count if valid_count else 0

    print("\n" + "="*60)
    print("== 평가 결과 요약 ==")
    print("="*60)
    print(f"  전체 쿼리        : 100개")
    print(f"  평가 제외 (빈칸) : {skip_count}개  (챗봇 범위 외)")
    print(f"  유효 평가 대상   : {valid_count}개")
    print(f"  Acc@1            : {acc1_n}/{valid_count} = {acc1_rate:.1%}")
    print(f"  Acc@3            : {acc3_n}/{valid_count} = {acc3_rate:.1%}")
    print(f"  Acc@5            : {acc5_n}/{valid_count} = {acc5_rate:.1%}")
    print("="*60)

    # ── 5. Excel 저장 ────────────────────────────────────────────────────────
    df_out = pd.DataFrame(rows)
    df_out.to_excel(EXCEL_PATH, sheet_name="시트1", index=False)

    wb = load_workbook(EXCEL_PATH)
    ws = wb["시트1"]

    # 열 너비: A=6, B=38, C=38, D~H=32, I=14, J=12
    widths = {"A": 6, "B": 38, "C": 38,
              "D": 32, "E": 32, "F": 32, "G": 32, "H": 32,
              "I": 14, "J": 12}
    for col, w in widths.items():
        ws.column_dimensions[col].width = w

    # 헤더 스타일
    hdr_fill = PatternFill("solid", start_color="1F4E79")
    hdr_font = Font(bold=True, color="FFFFFF", name="맑은 고딕", size=10)
    for cell in ws[1]:
        cell.fill = hdr_fill
        cell.font = hdr_font
        cell.alignment = Alignment(horizontal="center", vertical="center")

    # 행별 색상
    fill_map = {
        "Top1 정답": PatternFill("solid", start_color="C6EFCE"),  # 초록
        "Top3 정답": PatternFill("solid", start_color="FFEB9C"),  # 노랑
        "Top5 정답": PatternFill("solid", start_color="FCE4D6"),  # 주황
        "오답":      PatternFill("solid", start_color="FFC7CE"),  # 빨강
        "평가제외":  PatternFill("solid", start_color="F2F2F2"),  # 회색
    }

    for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
        label_val = row[8].value if len(row) > 8 else ""   # I열 = 평가결과
        fill = fill_map.get(label_val, fill_map["평가제외"])
        for cell in row:
            cell.fill = fill
            cell.font = Font(name="맑은 고딕", size=9)
            cell.alignment = Alignment(wrap_text=True, vertical="center")

    # 요약 행
    ws.append([])
    ws.append([
        "", f"【요약】 ({mode_label})",
        f"유효평가 {valid_count}개 / 제외 {skip_count}개",
        f"Acc@1: {acc1_rate:.1%}",
        f"Acc@3: {acc3_rate:.1%}",
        f"Acc@5: {acc5_rate:.1%}",
        "", "", "", "",
    ])
    sum_fill = PatternFill("solid", start_color="D9E1F2")
    sum_font = Font(bold=True, name="맑은 고딕", size=10)
    for cell in ws[ws.max_row]:
        cell.fill = sum_fill
        cell.font = sum_font

    wb.save(EXCEL_PATH)
    print(f"\n[16_eval] 저장 완료: {EXCEL_PATH}")
    print("  초록=Top1정답  노랑=Top3정답  주황=Top5정답  빨강=오답  회색=평가제외")


if __name__ == "__main__":
    main()