Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """Generate a robust reranker evaluation dataset from Supabase notices. | |
| This script samples representative notices from the Supabase `notices` table, | |
| uses Gemini to create realistic search-query variants for each sampled notice, | |
| and attaches heuristic hard-negative qrels from nearby/confusable notices. | |
| It intentionally does not evaluate search quality. Its output is an offline | |
| dataset that can be consumed by a separate retrieval/reranker evaluation script. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import os | |
| import random | |
| import re | |
| import sys | |
| import time | |
| from collections import defaultdict | |
| from dataclasses import dataclass | |
| from datetime import date, datetime | |
| from pathlib import Path | |
| from typing import Any | |
| ROOT = Path(__file__).resolve().parents[1] | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv(ROOT / ".env") | |
| except ImportError: | |
| pass | |
| from api.core.utils import clean_title, tokenize_ko # noqa: E402 | |
| from crawling.supabase_store import _connect # noqa: E402 | |
| DATA_DIR = ROOT / "data" | |
| DEFAULT_OUTPUT_PATH = DATA_DIR / "reranker_eval_dataset_gemini.json" | |
| DEFAULT_CACHE_PATH = DATA_DIR / "reranker_eval_dataset_gemini_cache.json" | |
| MIN_BODY_CHARS = 80 | |
| MAX_PROMPT_BODY_CHARS = 1800 | |
| DEFAULT_SAMPLE_SIZE = 150 | |
| DEFAULT_QUERIES_PER_NOTICE = 5 | |
| DEFAULT_HARD_NEGATIVES = 6 | |
| DEFAULT_MAX_RETRIES = 3 | |
| ACTION_KEYWORDS = { | |
| "apply": [ | |
| "신청", "지원", "접수", "모집", "참여", "선발", "장학생", "장학금", | |
| "근로장학생", "교환학생", "현장실습", "인턴십", "프로그램", "캠프", | |
| ], | |
| "schedule": ["일정", "기간", "언제", "마감", "납부기간", "정정기간", "발표일"], | |
| "result": ["합격자", "선정자", "결과", "발표", "선발 결과"], | |
| "guide": ["방법", "안내", "절차", "기준", "요건", "조건", "수수료", "비용", "발급"], | |
| "download": ["양식", "서류", "파일", "제출서류", "신청서"], | |
| "contact": ["문의", "전화", "연락처", "담당자", "위치"], | |
| } | |
| FAMILY_HINTS = [ | |
| "해외봉사", "월드프렌즈", "WFK", "KOICA", "청년봉사단", "봉사단", | |
| "국가장학금", "근로장학", "근로장학생", "장학금", "장학생", | |
| "기숙사", "생활관", "상상빌리지", "우촌학사", | |
| "수강신청", "수강정정", "계절학기", "강의평가", | |
| "등록금", "분할납부", "휴학", "복학", "졸업", "조기졸업", | |
| "복수전공", "부전공", "전과", "교환학생", "어학연수", | |
| "현장실습", "인턴십", "취업", "특강", "상담", "증명서", | |
| ] | |
| class Notice: | |
| id: int | |
| notice_id: str | None | |
| title: str | |
| url: str | |
| posted_at: str | None | |
| category: str | |
| body: str | |
| category_type: list[str] | |
| job_types: list[str] | |
| def year(self) -> int | None: | |
| if not self.posted_at: | |
| return None | |
| match = re.search(r"(20\d{2})", self.posted_at) | |
| return int(match.group(1)) if match else None | |
| def text_for_matching(self) -> str: | |
| return f"{self.title}\n{self.body[:1200]}" | |
| def normalize_space(value: Any) -> str: | |
| return re.sub(r"\s+", " ", str(value or "")).strip() | |
| def infer_action(text: str) -> str: | |
| counts = { | |
| action: sum(1 for keyword in keywords if keyword in text) | |
| for action, keywords in ACTION_KEYWORDS.items() | |
| } | |
| action, count = max(counts.items(), key=lambda item: item[1]) | |
| return action if count > 0 else "unknown" | |
| def infer_family(title: str, body: str) -> str: | |
| text = f"{clean_title(title)} {body[:400]}".upper() | |
| hints = [hint for hint in FAMILY_HINTS if hint.upper() in text] | |
| if hints: | |
| return " / ".join(hints[:3]) | |
| title_text = re.sub(r"\[[^\]]+\]", " ", clean_title(title)) | |
| title_text = re.sub(r"20\d{2}[\w\s.월년학년도-]*", " ", title_text) | |
| title_text = re.sub(r"\d+\s*(기|차|회)", " ", title_text) | |
| title_text = re.sub(r"(모집|안내|신청|접수|발표|결과|선발|공고|공지|새글)", " ", title_text) | |
| tokens = [token for token in tokenize_ko(title_text) if len(token) >= 2] | |
| return " ".join(tokens[:4]) or clean_title(title)[:30] | |
| def notice_signature(notice: Notice) -> str: | |
| raw = f"{notice.id}|{notice.url}|{notice.title}|{notice.posted_at}|{notice.body[:500]}" | |
| return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] | |
| def load_supabase_notices() -> list[Notice]: | |
| with _connect() as conn: | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| select | |
| id, | |
| notice_id, | |
| title, | |
| url, | |
| posted_at::text, | |
| coalesce(category, ''), | |
| coalesce(body, ''), | |
| coalesce(category_type, '{}'), | |
| coalesce(job_types, '{}') | |
| from notices | |
| where source = 'hansung' | |
| and title is not null | |
| and url is not null | |
| and length(coalesce(body, '')) >= %s | |
| order by posted_at desc nulls last, id desc | |
| """, | |
| (MIN_BODY_CHARS,), | |
| ) | |
| rows = cur.fetchall() | |
| notices = [] | |
| for row in rows: | |
| notices.append( | |
| Notice( | |
| id=row[0], | |
| notice_id=row[1], | |
| title=normalize_space(row[2]), | |
| url=row[3], | |
| posted_at=row[4], | |
| category=row[5] or "기타", | |
| body=normalize_space(row[6]), | |
| category_type=list(row[7] or []), | |
| job_types=list(row[8] or []), | |
| ) | |
| ) | |
| return notices | |
| def stratify(notice: Notice) -> tuple[str, str, str, str]: | |
| year = str(notice.year or "unknown") | |
| action = infer_action(notice.text_for_matching) | |
| family = infer_family(notice.title, notice.body) | |
| return (notice.category or "기타", year, action, family) | |
| def sample_representative_notices( | |
| notices: list[Notice], | |
| sample_size: int, | |
| seed: int, | |
| ) -> list[Notice]: | |
| rng = random.Random(seed) | |
| strata: dict[tuple[str, str, str, str], list[Notice]] = defaultdict(list) | |
| for notice in notices: | |
| strata[stratify(notice)].append(notice) | |
| for bucket in strata.values(): | |
| rng.shuffle(bucket) | |
| selected: list[Notice] = [] | |
| seen_urls: set[str] = set() | |
| # First pass: take one from as many category/year/action/family buckets as possible. | |
| for key in sorted(strata, key=lambda item: (item[0], item[1], item[2], item[3])): | |
| if len(selected) >= sample_size: | |
| break | |
| for notice in strata[key]: | |
| if notice.url not in seen_urls: | |
| selected.append(notice) | |
| seen_urls.add(notice.url) | |
| break | |
| # Second pass: fill remaining slots while preserving deterministic randomness. | |
| remaining = [notice for notice in notices if notice.url not in seen_urls] | |
| rng.shuffle(remaining) | |
| for notice in remaining: | |
| if len(selected) >= sample_size: | |
| break | |
| selected.append(notice) | |
| seen_urls.add(notice.url) | |
| return selected | |
| def token_overlap(a: str, b: str) -> float: | |
| left = {token for token in tokenize_ko(a) if len(token) >= 2} | |
| right = {token for token in tokenize_ko(b) if len(token) >= 2} | |
| if not left or not right: | |
| return 0.0 | |
| return len(left & right) / len(left | right) | |
| def find_hard_negatives( | |
| target: Notice, | |
| all_notices: list[Notice], | |
| limit: int, | |
| ) -> list[dict[str, Any]]: | |
| target_action = infer_action(target.text_for_matching) | |
| target_family = infer_family(target.title, target.body) | |
| scored = [] | |
| for other in all_notices: | |
| if other.url == target.url: | |
| continue | |
| other_action = infer_action(other.text_for_matching) | |
| other_family = infer_family(other.title, other.body) | |
| same_category = other.category == target.category | |
| same_family = other_family == target_family | |
| same_year = other.year is not None and other.year == target.year | |
| overlap = token_overlap(target.title, other.title) | |
| score = overlap | |
| score += 0.35 if same_category else 0.0 | |
| score += 0.45 if same_family else 0.0 | |
| score += 0.15 if same_year else 0.0 | |
| score += 0.25 if other_action != target_action and other_action != "unknown" else 0.0 | |
| if score <= 0: | |
| continue | |
| reason_bits = [] | |
| if same_family: | |
| reason_bits.append("same_family") | |
| if same_category: | |
| reason_bits.append("same_category") | |
| if same_year: | |
| reason_bits.append("same_year") | |
| if other_action != target_action: | |
| reason_bits.append(f"action_mismatch:{target_action}->{other_action}") | |
| if overlap: | |
| reason_bits.append(f"title_overlap:{overlap:.2f}") | |
| scored.append( | |
| { | |
| "notice_id": other.id, | |
| "url": other.url, | |
| "title": other.title, | |
| "category": other.category, | |
| "posted_at": other.posted_at, | |
| "relevance": 1 if same_family or same_category else 0, | |
| "reason": ", ".join(reason_bits) or "lexically similar", | |
| "_score": score, | |
| } | |
| ) | |
| scored.sort(key=lambda row: row["_score"], reverse=True) | |
| negatives = [] | |
| seen_urls = set() | |
| for row in scored: | |
| if row["url"] in seen_urls: | |
| continue | |
| seen_urls.add(row["url"]) | |
| row.pop("_score", None) | |
| negatives.append(row) | |
| if len(negatives) >= limit: | |
| break | |
| return negatives | |
| def load_cache(path: Path) -> dict[str, Any]: | |
| if not path.exists(): | |
| return {} | |
| with path.open(encoding="utf-8") as f: | |
| data = json.load(f) | |
| return data if isinstance(data, dict) else {} | |
| def save_cache(path: Path, cache: dict[str, Any]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| tmp_path = path.with_suffix(path.suffix + ".tmp") | |
| with tmp_path.open("w", encoding="utf-8") as f: | |
| json.dump(cache, f, ensure_ascii=False, indent=2) | |
| f.write("\n") | |
| tmp_path.replace(path) | |
| def parse_json_response(text: str) -> dict[str, Any]: | |
| cleaned = text.strip() | |
| if cleaned.startswith("```"): | |
| cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) | |
| cleaned = re.sub(r"\s*```$", "", cleaned) | |
| try: | |
| return json.loads(cleaned) | |
| except json.JSONDecodeError: | |
| start = cleaned.find("{") | |
| end = cleaned.rfind("}") | |
| if start >= 0 and end > start: | |
| return json.loads(cleaned[start : end + 1]) | |
| raise | |
| def build_generation_prompt(notice: Notice, queries_per_notice: int) -> str: | |
| return f""" | |
| 너는 한성대학교 공지 검색 시스템의 평가셋을 만드는 검색 품질 엔지니어다. | |
| 아래 공지를 정확히 찾을 수 있는 한국어 검색 질의 {queries_per_notice}개를 만들어라. | |
| 요구사항: | |
| - 실제 학생이 검색창에 입력할 법한 질의로 작성한다. | |
| - 서로 다른 유형을 섞는다: 짧은 키워드형, 자연어형, 조건 포함형, 약어/영문 포함형, 일정/마감/대상자 질문형. | |
| - 정답 공지를 과하게 그대로 복사하지 말고, 그래도 이 공지가 1순위로 나와야 한다. | |
| - 다른 유사 공지와 헷갈릴 수 있는 표현도 일부 포함한다. | |
| - 각 질의는 4~45자 정도로 작성한다. | |
| - JSON만 출력한다. 설명 문장은 쓰지 않는다. | |
| 출력 형식: | |
| {{ | |
| "queries": [ | |
| {{ | |
| "query": "검색 질의", | |
| "query_type": "keyword|natural|condition|abbreviation|schedule|audience", | |
| "intent": "사용자 의도 한 문장", | |
| "must_match": ["랭킹에서 중요하게 봐야 할 조건"] | |
| }} | |
| ] | |
| }} | |
| [공지] | |
| 제목: {notice.title} | |
| 카테고리: {notice.category} | |
| 게시일: {notice.posted_at or ""} | |
| 본문: {notice.body[:MAX_PROMPT_BODY_CHARS]} | |
| """.strip() | |
| def generate_queries_with_gemini( | |
| notice: Notice, | |
| queries_per_notice: int, | |
| model_name: str, | |
| cache: dict[str, Any], | |
| max_retries: int, | |
| ) -> list[dict[str, Any]]: | |
| cache_key = f"{notice.id}:{notice_signature(notice)}:{queries_per_notice}:{model_name}" | |
| if cache_key in cache: | |
| return cache[cache_key]["queries"] | |
| api_key = os.getenv("GEMINI_API_KEY_PAID_TIER") | |
| if not api_key: | |
| raise RuntimeError("GEMINI_API_KEY_PAID_TIER is required in .env") | |
| import google.generativeai as genai | |
| genai.configure(api_key=api_key) | |
| model = genai.GenerativeModel(model_name) | |
| last_error: Exception | None = None | |
| parsed: dict[str, Any] | None = None | |
| prompt = build_generation_prompt(notice, queries_per_notice) | |
| for attempt in range(1, max_retries + 1): | |
| try: | |
| response = model.generate_content( | |
| prompt, | |
| generation_config=genai.types.GenerationConfig( | |
| temperature=0.45 if attempt == 1 else 0.25, | |
| top_p=0.9, | |
| max_output_tokens=2400, | |
| response_mime_type="application/json", | |
| ), | |
| ) | |
| parsed = parse_json_response(response.text) | |
| break | |
| except Exception as exc: | |
| last_error = exc | |
| if attempt >= max_retries: | |
| raise | |
| time.sleep(min(2 * attempt, 8)) | |
| if parsed is None: | |
| raise RuntimeError(f"Gemini response parse failed for notice id={notice.id}") from last_error | |
| queries = parsed.get("queries", []) | |
| if not isinstance(queries, list) or not queries: | |
| raise ValueError(f"Gemini returned no queries for notice id={notice.id}") | |
| cleaned = [] | |
| seen = set() | |
| for item in queries: | |
| if not isinstance(item, dict): | |
| continue | |
| query = normalize_space(item.get("query")) | |
| if not query or query in seen: | |
| continue | |
| seen.add(query) | |
| cleaned.append( | |
| { | |
| "query": query, | |
| "query_type": normalize_space(item.get("query_type")) or "unknown", | |
| "intent": normalize_space(item.get("intent")), | |
| "must_match": [ | |
| normalize_space(value) | |
| for value in item.get("must_match", []) | |
| if normalize_space(value) | |
| ], | |
| } | |
| ) | |
| if len(cleaned) >= queries_per_notice: | |
| break | |
| if not cleaned: | |
| raise ValueError(f"Gemini queries were invalid for notice id={notice.id}") | |
| cache[cache_key] = { | |
| "notice_id": notice.id, | |
| "notice_url": notice.url, | |
| "generated_at": datetime.now().isoformat(timespec="seconds"), | |
| "queries": cleaned, | |
| } | |
| return cleaned | |
| def build_dataset( | |
| notices: list[Notice], | |
| sampled: list[Notice], | |
| queries_per_notice: int, | |
| hard_negative_count: int, | |
| model_name: str, | |
| cache_path: Path, | |
| sleep_seconds: float, | |
| save_every: int, | |
| max_retries: int, | |
| ) -> dict[str, Any]: | |
| cache = load_cache(cache_path) | |
| query_rows = [] | |
| for index, notice in enumerate(sampled, start=1): | |
| generated_queries = generate_queries_with_gemini( | |
| notice=notice, | |
| queries_per_notice=queries_per_notice, | |
| model_name=model_name, | |
| cache=cache, | |
| max_retries=max_retries, | |
| ) | |
| hard_negatives = find_hard_negatives(notice, notices, hard_negative_count) | |
| for query_index, query_item in enumerate(generated_queries, start=1): | |
| query_id = f"n{notice.id:06d}_q{query_index:02d}" | |
| qrels = [ | |
| { | |
| "notice_id": notice.id, | |
| "url": notice.url, | |
| "title": notice.title, | |
| "relevance": 3, | |
| "reason": "target_notice", | |
| } | |
| ] | |
| qrels.extend(hard_negatives) | |
| query_rows.append( | |
| { | |
| "id": query_id, | |
| "query": query_item["query"], | |
| "query_type": query_item["query_type"], | |
| "intent": query_item["intent"], | |
| "must_match": query_item["must_match"], | |
| "positive_notice": { | |
| "notice_id": notice.id, | |
| "source_notice_id": notice.notice_id, | |
| "url": notice.url, | |
| "title": notice.title, | |
| "category": notice.category, | |
| "posted_at": notice.posted_at, | |
| "action": infer_action(notice.text_for_matching), | |
| "family": infer_family(notice.title, notice.body), | |
| }, | |
| "qrels": qrels, | |
| } | |
| ) | |
| if index % save_every == 0: | |
| save_cache(cache_path, cache) | |
| if sleep_seconds > 0 and index < len(sampled): | |
| time.sleep(sleep_seconds) | |
| save_cache(cache_path, cache) | |
| return { | |
| "generated_at": datetime.now().isoformat(timespec="seconds"), | |
| "source": { | |
| "table": "notices", | |
| "sampled_notice_count": len(sampled), | |
| "loaded_notice_count": len(notices), | |
| }, | |
| "generation": { | |
| "model": model_name, | |
| "queries_per_notice": queries_per_notice, | |
| "hard_negatives_per_query": hard_negative_count, | |
| "api_key_env": "GEMINI_API_KEY_PAID_TIER", | |
| }, | |
| "labels": { | |
| "3": "exact target notice", | |
| "1": "hard negative: related/confusable but not the exact target", | |
| "0": "hard negative: weakly related lexical/category confounder", | |
| }, | |
| "queries": query_rows, | |
| } | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="Generate Gemini-based reranker evaluation queries from Supabase notices.", | |
| ) | |
| parser.add_argument("--sample-size", type=int, default=DEFAULT_SAMPLE_SIZE) | |
| parser.add_argument("--queries-per-notice", type=int, default=DEFAULT_QUERIES_PER_NOTICE) | |
| parser.add_argument("--hard-negatives", type=int, default=DEFAULT_HARD_NEGATIVES) | |
| parser.add_argument("--seed", type=int, default=42) | |
| parser.add_argument("--model", default="gemini-2.5-flash") | |
| parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH) | |
| parser.add_argument("--cache", type=Path, default=DEFAULT_CACHE_PATH) | |
| parser.add_argument("--sleep-seconds", type=float, default=4.0) | |
| parser.add_argument("--save-every", type=int, default=10) | |
| parser.add_argument("--max-retries", type=int, default=DEFAULT_MAX_RETRIES) | |
| args = parser.parse_args() | |
| if args.sample_size < 1: | |
| raise ValueError("--sample-size must be at least 1") | |
| if args.queries_per_notice < 1: | |
| raise ValueError("--queries-per-notice must be at least 1") | |
| if args.hard_negatives < 0: | |
| raise ValueError("--hard-negatives must be non-negative") | |
| notices = load_supabase_notices() | |
| if not notices: | |
| raise SystemExit("No Supabase notices loaded. Check SUPABASE_DB_URL and notices table contents.") | |
| sampled = sample_representative_notices( | |
| notices=notices, | |
| sample_size=min(args.sample_size, len(notices)), | |
| seed=args.seed, | |
| ) | |
| dataset = build_dataset( | |
| notices=notices, | |
| sampled=sampled, | |
| queries_per_notice=args.queries_per_notice, | |
| hard_negative_count=args.hard_negatives, | |
| model_name=args.model, | |
| cache_path=args.cache, | |
| sleep_seconds=args.sleep_seconds, | |
| save_every=max(1, args.save_every), | |
| max_retries=max(1, args.max_retries), | |
| ) | |
| args.output.parent.mkdir(parents=True, exist_ok=True) | |
| with args.output.open("w", encoding="utf-8") as f: | |
| json.dump(dataset, f, ensure_ascii=False, indent=2) | |
| f.write("\n") | |
| print(f"Loaded notices: {len(notices)}") | |
| print(f"Sampled notices: {len(sampled)}") | |
| print(f"Generated queries: {len(dataset['queries'])}") | |
| print(f"Wrote: {args.output}") | |
| if __name__ == "__main__": | |
| main() | |