#!/usr/bin/env python3 """Reclassify stored Supabase notices with Gemini.""" from __future__ import annotations import argparse import json import os import time from datetime import date, datetime from typing import Any import google.generativeai as genai import psycopg from psycopg.types.json import Jsonb SOURCE = "hansung" VALID_CATEGORIES = [ "취업/채용", "학사행정", "학생활동/비교과", "대외활동", "공모전/경진대회", "국제교류", "창업", "장학금", "기숙사", "ROTC", ] CATEGORY_TYPE_MAP = { "취업/채용": ["경영/금융/사무", "IT/정보통신", "디자인/예술/방송", "공학/기술", "교육/법률/공공", "교내채용"], "학생활동/비교과": ["IT/AI/SW", "진로/취업/현장실습", "디자인/콘텐츠", "심리/상담/자기계발", "인문/어학", "행사/생활"], "공모전/경진대회": ["IT/AI/SW", "창업/아이디어", "디자인/콘텐츠", "글쓰기/발표/어학", "정책/사회/ESG"], "창업": ["창업"], "국제교류": ["교환학생/파견", "해외인턴/현장실습", "해외연수", "외국인학생/글로벌교류", "해외봉사"], "학사행정": ["수업/수강", "전공/트랙/학적", "성적/졸업", "시험/학사일정", "시설/시스템/행정"], "장학금": ["장학금"], "대외활동": ["봉사활동", "멘토링", "서포터즈/홍보대사", "기획/미디어"], "ROTC": ["ROTC"], "기숙사": ["기숙사"], } def _parse_date(value: str) -> date: return datetime.strptime(value, "%Y-%m-%d").date() def _db_url() -> str: value = os.getenv("SUPABASE_DB_URL") or os.getenv("DATABASE_URL") if not value: raise RuntimeError("SUPABASE_DB_URL or DATABASE_URL is required.") return value def _clean(value: Any) -> Any: if isinstance(value, str): return value.replace("\x00", "") if isinstance(value, list): return [_clean(item) for item in value] if isinstance(value, dict): return {key: _clean(item) for key, item in value.items()} return value def classify_with_gemini(model: genai.GenerativeModel, title: str, body: str, max_retry: int) -> dict[str, Any]: prompt = f""" 한성대학교 공지사항을 아래 카테고리 중 하나로 분류하고, 해당 카테고리의 세부 유형도 분류해. 반드시 JSON 형식으로만 답해. 카테고리 목록: {json.dumps(VALID_CATEGORIES, ensure_ascii=False)} 카테고리별 세부 유형: {json.dumps(CATEGORY_TYPE_MAP, ensure_ascii=False)} 공지 제목: {title} 공지 본문: {(body or '')[:500]} 아래 JSON 형식으로만 출력해: {{"category": "카테고리명", "category_type": ["세부유형1", "세부유형2"]}} 규칙: - category는 반드시 위 카테고리 목록 중 하나 - category_type은 해당 카테고리의 세부 유형 중 해당하는 것만 - 장학금/기숙사/ROTC/창업은 category_type이 하나 """ last_error: Exception | None = None for attempt in range(1, max_retry + 1): try: res = model.generate_content(prompt) result = json.loads(res.text) category = result.get("category") category_type = result.get("category_type") or [] if category not in VALID_CATEGORIES: raise ValueError(f"invalid category: {category!r}") valid_types = CATEGORY_TYPE_MAP.get(category, []) category_type = [item for item in category_type if item in valid_types] return {"category": category, "category_type": category_type} except Exception as exc: # noqa: BLE001 last_error = exc print(f" Gemini failed ({attempt}/{max_retry}): {exc}", flush=True) time.sleep(2) raise RuntimeError("Gemini classification failed") from last_error def load_candidates( conn: psycopg.Connection, start_date: date, end_date: date, only_fallback: bool, limit: int | None, ) -> list[dict[str, Any]]: params: list[Any] = [SOURCE, start_date, end_date] fallback_sql = "" if only_fallback: fallback_sql = """ and ( category is null or category = '기타' or coalesce(jsonb_array_length(category_type), 0) = 0 ) """ limit_sql = "" if limit: limit_sql = "limit %s" params.append(limit) with conn.cursor() as cur: cur.execute( f""" select id, title, body, posted_at, category, category_type, raw from notices where source = %s and posted_at between %s and %s {fallback_sql} order by posted_at desc, id desc {limit_sql} """, params, ) rows = cur.fetchall() return [ { "id": row[0], "title": row[1] or "", "body": row[2] or "", "posted_at": row[3], "category": row[4], "category_type": row[5] or [], "raw": row[6] or {}, } for row in rows ] def update_notice(conn: psycopg.Connection, notice: dict[str, Any], result: dict[str, Any]) -> None: raw = dict(notice.get("raw") or {}) raw["category"] = result["category"] raw["category_type"] = result["category_type"] raw["job_types"] = result["category_type"] with conn.cursor() as cur: cur.execute( """ update notices set category = %s, category_type = %s, job_types = %s, raw = %s, updated_at = now() where id = %s """, ( result["category"], Jsonb(_clean(result["category_type"])), Jsonb(_clean(result["category_type"])), Jsonb(_clean(raw)), notice["id"], ), ) def main() -> None: parser = argparse.ArgumentParser(description="Reclassify Supabase notices with Gemini.") parser.add_argument("--start-date", required=True, type=_parse_date) parser.add_argument("--end-date", required=True, type=_parse_date) parser.add_argument("--limit", type=int) parser.add_argument("--all", action="store_true", help="Reclassify all rows in range, not just fallback rows.") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--max-retry", type=int, default=3) parser.add_argument("--sleep", type=float, default=1.0) args = parser.parse_args() if args.end_date < args.start_date: raise ValueError("--end-date must be on or after --start-date") api_key = os.getenv("GEMINI_API_KEY") if not api_key: raise RuntimeError("GEMINI_API_KEY is required.") genai.configure(api_key=api_key) model = genai.GenerativeModel("gemini-2.5-flash", generation_config={"response_mime_type": "application/json"}) with psycopg.connect(_db_url(), autocommit=False, connect_timeout=10, prepare_threshold=None) as conn: notices = load_candidates(conn, args.start_date, args.end_date, not args.all, args.limit) print(f"Target notices: {len(notices)}", flush=True) updated = 0 failed = 0 for index, notice in enumerate(notices, start=1): print(f"[{index}/{len(notices)}] id={notice['id']} {notice['posted_at']} {notice['title'][:80]}", flush=True) try: result = classify_with_gemini(model, notice["title"], notice["body"], args.max_retry) print(f" -> {result['category']} {result['category_type']}", flush=True) if not args.dry_run: update_notice(conn, notice, result) conn.commit() updated += 1 except Exception as exc: # noqa: BLE001 conn.rollback() failed += 1 print(f" failed: {type(exc).__name__}: {exc}", flush=True) time.sleep(args.sleep) print(f"Done. updated={updated}, failed={failed}, total={len(notices)}", flush=True) if failed: raise RuntimeError(f"Reclassification failed for {failed}/{len(notices)} notices") if __name__ == "__main__": main()