""" 평가셋 300개 구축 스크립트 =========================== top200 메뉴에서 270개 (메뉴별 균등 분산) + 201위이하에서 30개 = 300개 핵심 원칙: 뽑힌 300개는 쿼리 인덱스에서 제거 (데이터 누수 방지) 실행: .venv/Scripts/python.exe scripts/21_build_eval300.py .venv/Scripts/python.exe scripts/21_build_eval300.py --seed 42 # 재현 가능 .venv/Scripts/python.exe scripts/21_build_eval300.py --dry-run # 통계만 """ import sys import re import json import random import argparse from pathlib import Path from collections import defaultdict sys.path.insert(0, str(Path(__file__).parent.parent)) from openpyxl import load_workbook from openpyxl.cell.rich_text import CellRichText, TextBlock EXCEL_PATH = Path(__file__).parent.parent / "data" / "MenuSearch_review_all_20260525_edit.xlsx" EVAL_OUT = Path(__file__).parent.parent / "data" / "eval_queries_300.json" INDEX_REMAINING_OUT = Path(__file__).parent.parent / "data" / "query_index_pool.json" TOP200_N = 270 # top200에서 뽑을 수 OUTSIDE_N = 30 # 201+에서 뽑을 수 LLM_COL = 8 HUMAN_COL = 9 def parse_q(text: str) -> list[str]: if not text or str(text).strip() in ("", "nan"): return [] text = str(text).strip() parts = re.split(r"\n?\s*\d+[.)]\s+", text) if len(parts) > 1: return [p.strip() for p in parts if len(p.strip()) >= 4] return [p.strip() for p in re.split(r"\s*/\s*|\n", text) if len(p.strip()) >= 4] def extract_kept(val) -> list[str]: """취소선 제외하고 유효 텍스트만 반환""" if val is None: return [] if isinstance(val, CellRichText): kept = [] for block in val: if isinstance(block, TextBlock): if not (block.font and block.font.strike): kept.append(str(block.text)) elif isinstance(block, str): kept.append(block) return parse_q("".join(kept)) return parse_q(str(val)) def load_all_queries(excel_path: Path) -> tuple[list[dict], list[dict]]: """top200 쿼리와 201+ 쿼리를 분리해서 반환""" wb = load_workbook(excel_path, rich_text=True) ws = wb.active top200, outside = [], [] for row_idx in range(2, ws.max_row + 1): rank = ws.cell(row_idx, 1).value menu_id = ws.cell(row_idx, 2).value menu_name = ws.cell(row_idx, 3).value menu_path = ws.cell(row_idx, 4).value if not menu_id or str(menu_id).strip() in ("", "nan"): continue try: rank_int = int(rank) except (TypeError, ValueError): rank_int = 9999 menu_id = str(menu_id).strip() menu_name = str(menu_name).strip() if menu_name else "" menu_path = str(menu_path).strip() if menu_path else "" llm_qs = extract_kept(ws.cell(row_idx, LLM_COL).value) human_qs = extract_kept(ws.cell(row_idx, HUMAN_COL).value) for q in human_qs: item = dict(query=q, menu_id=menu_id, menu_name=menu_name, menu_path=menu_path, rank=rank_int, source="human") (top200 if rank_int <= 200 else outside).append(item) for q in llm_qs: item = dict(query=q, menu_id=menu_id, menu_name=menu_name, menu_path=menu_path, rank=rank_int, source="llm") (top200 if rank_int <= 200 else outside).append(item) return top200, outside def stratified_sample(queries: list[dict], n: int, rng: random.Random) -> list[dict]: """ 메뉴별로 균등하게 n개 샘플링. 각 메뉴에서 최소 1개 보장 후, 남은 슬롯은 쿼리 수 비례 배분. human 쿼리를 llm보다 우선 선택. """ # 메뉴별 그룹화 (human 먼저 정렬) by_menu: dict[str, list[dict]] = defaultdict(list) for item in queries: by_menu[item["menu_id"]].append(item) # 각 메뉴 내에서 human 우선 셔플 for mid in by_menu: human = [x for x in by_menu[mid] if x["source"] == "human"] llm = [x for x in by_menu[mid] if x["source"] == "llm"] rng.shuffle(human) rng.shuffle(llm) by_menu[mid] = human + llm # human 먼저 menus = list(by_menu.keys()) n_menus = len(menus) n = min(n, len(queries)) # 1단계: 메뉴별 최소 1개 selected: list[dict] = [] pool_by_menu: dict[str, list[dict]] = {m: list(v) for m, v in by_menu.items()} for mid in menus: if pool_by_menu[mid]: selected.append(pool_by_menu[mid].pop(0)) if len(selected) >= n: break # 2단계: 남은 슬롯 채우기 (쿼리 많은 메뉴 우선) remaining_flat = [] for mid in menus: remaining_flat.extend(pool_by_menu[mid]) rng.shuffle(remaining_flat) for item in remaining_flat: if len(selected) >= n: break selected.append(item) rng.shuffle(selected) return selected def main(): parser = argparse.ArgumentParser() parser.add_argument("--seed", type=int, default=2025, help="랜덤 시드") parser.add_argument("--dry-run", action="store_true", help="통계만 출력, 파일 저장 안 함") args = parser.parse_args() rng = random.Random(args.seed) print(f"엑셀 로드: {EXCEL_PATH}") top200_pool, outside_pool = load_all_queries(EXCEL_PATH) print(f" top200 풀: {len(top200_pool):,}개 ({len(set(x['menu_id'] for x in top200_pool))}개 메뉴)") print(f" 201+ 풀: {len(outside_pool):,}개 ({len(set(x['menu_id'] for x in outside_pool))}개 메뉴)") # ── 샘플링 ──────────────────────────────────────────────────────────────── eval_top200 = stratified_sample(top200_pool, TOP200_N, rng) eval_outside = rng.sample(outside_pool, min(OUTSIDE_N, len(outside_pool))) eval_set = eval_top200 + eval_outside rng.shuffle(eval_set) # eval ID 부여 for i, item in enumerate(eval_set, 1): item["id"] = i # ── 인덱스용 나머지 (eval 제외한 top200만) ─────────────────────────────── eval_ids = {(x["query"], x["menu_id"]) for x in eval_set} index_pool = [x for x in top200_pool if (x["query"], x["menu_id"]) not in eval_ids] # ── 통계 ────────────────────────────────────────────────────────────────── eval_menus = len(set(x["menu_id"] for x in eval_set)) top200_menus_in_eval = len(set(x["menu_id"] for x in eval_top200)) outside_menus_in_eval = len(set(x["menu_id"] for x in eval_outside)) human_cnt = sum(1 for x in eval_set if x["source"] == "human") llm_cnt = sum(1 for x in eval_set if x["source"] == "llm") print(f"\n[평가셋 구성]") print(f" 총 쿼리: {len(eval_set)}개") print(f" top200 쿼리: {len(eval_top200)}개 ({top200_menus_in_eval}개 메뉴)") print(f" 201+ 쿼리: {len(eval_outside)}개 ({outside_menus_in_eval}개 메뉴)") print(f" 총 메뉴 수: {eval_menus}개") print(f" human 쿼리: {human_cnt}개 | llm 쿼리: {llm_cnt}개") print(f"\n[인덱스 풀 (eval 제외)]") print(f" top200 남은 쿼리: {len(index_pool):,}개") if args.dry_run: print("\n[dry-run] 파일 저장 안 함") return # ── 저장 ────────────────────────────────────────────────────────────────── with open(EVAL_OUT, "w", encoding="utf-8") as f: json.dump(eval_set, f, ensure_ascii=False, indent=2) print(f"\n평가셋 저장: {EVAL_OUT}") with open(INDEX_REMAINING_OUT, "w", encoding="utf-8") as f: json.dump(index_pool, f, ensure_ascii=False, indent=2) print(f"인덱스 풀 저장: {INDEX_REMAINING_OUT}") print("\n다음 단계: scripts/20_build_query_index.py --reset --from-pool") if __name__ == "__main__": main()