Spaces:
Runtime error
Runtime error
| # ============================================================ | |
| # app_api.py β μμνμΈλ FastAPI λ°±μλ | |
| # uvicorn app_api:app --reload --port 8000 | |
| # ============================================================ | |
| import logging | |
| import os, sys, re, json | |
| from datetime import datetime | |
| from typing import Optional | |
| # ββ sys.path μ΅μ°μ μ€μ (λ€λ₯Έ import μ μ λ°λμ μμΉν΄μΌ ν¨) ββ | |
| _BASE = os.path.dirname(os.path.abspath(__file__)) | |
| if _BASE not in sys.path: | |
| sys.path.insert(0, _BASE) | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except ImportError: | |
| pass | |
| from fastapi import FastAPI | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.responses import FileResponse | |
| from pydantic import BaseModel | |
| GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") | |
| logger = logging.getLogger(__name__) | |
| # ββ μΈλΆ λͺ¨λ import βββββββββββββββββββββββββββββββββββββββββ | |
| from recommend import load_notices_from_supabase, two_tower_recommend, get_supabase | |
| from api.services.search_service import hybrid_search as pinecone_hybrid_search, generate_llm_reply as search_generate_llm_reply | |
| PINECONE_CATEGORY_MAP = { | |
| "μ·¨μ /μ±μ©": ["μ·¨μ /μ±μ©", "μΈν΄μ"], | |
| "νμ¬νμ ": ["νμ¬νμ "], | |
| "νμνλ/λΉκ΅κ³Ό": ["νμνλ/λΉκ΅κ³Ό", "λΉκ΅κ³Ό", "κ΅μ‘/νΉκ°"], | |
| "λμΈνλ": ["λμΈνλ", "λ΄μ¬/μν¬ν°μ¦"], | |
| "곡λͺ¨μ /κ²½μ§λν": ["곡λͺ¨μ /κ²½μ§λν"], | |
| "κ΅μ κ΅λ₯": ["κ΅μ κ΅λ₯"], | |
| "μ°½μ ": ["μ°½μ "], | |
| "μ₯νκΈ": ["μ₯νκΈ", "νμκΈ/κ·Όλ‘μ₯ν"], | |
| "κΈ°μμ¬": ["κΈ°μμ¬", "κΈ°μμ¬/μνκ΄"], | |
| "ROTC": ["ROTC"], | |
| } | |
| # ββ ν΅μ¬ ν¨μ ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def search_rag_notices(query, category_filter="μ 체", top_k=20, profile=None): | |
| try: | |
| pinecone_categories = ( | |
| PINECONE_CATEGORY_MAP.get(category_filter, [category_filter]) | |
| if category_filter != "μ 체" else None | |
| ) | |
| return pinecone_hybrid_search( | |
| query=query, | |
| top_k=top_k, | |
| category_filter=pinecone_categories, | |
| candidate_k=50, | |
| feature_rerank=True, | |
| profile=profile or {}, | |
| ) | |
| except Exception as e: | |
| print(f"[κ²μ μ€λ₯] {e}") | |
| return [] | |
| def generate_llm_reply(user_query, results, profile, is_first=False): | |
| try: | |
| return search_generate_llm_reply( | |
| user_query=user_query, | |
| results=results, | |
| profile=profile, | |
| is_first=is_first, | |
| ) | |
| except Exception as e: | |
| print(f"[LLM μ€λ₯] {e}") | |
| return f"μ΄ {len(results)}κ°μ κ΄λ ¨ 곡μ§λ₯Ό μ°Ύμμ΅λλ€." if results else "κ΄λ ¨ 곡μ§λ₯Ό μ°Ύμ§ λͺ»νμ΅λλ€." | |
| def _region_match(user_region: str, db_region: str) -> bool: | |
| """μ¬μ©μ μ λ ₯ μ§μ(ν΅μΌ νμ)κ³Ό DB μ§μκ° λ§€μΉ""" | |
| if not db_region: | |
| return True # DBμ μ§μ 쑰건 μμΌλ©΄ λꡬλ ν΄λΉ | |
| if not user_region: | |
| return False # μ¬μ©μ μ§μ λ―Έμ λ ₯ μ μ§μ 쑰건 μλ μ₯νκΈ μ μΈ | |
| u = user_region.strip() | |
| d = db_region.strip() | |
| if u == d: | |
| return True # μμ μΌμΉ | |
| # μ¬μ©μκ° 'μμΈνΉλ³μ κ°λ¨κ΅¬' β DBκ° 'μμΈνΉλ³μ'(μμΈ μ 체)λ©΄ ν΄λΉ | |
| # μ¬μ©μκ° 'κ²½κΈ°λ μμμ' β DBκ° 'κ²½κΈ°λ'(κ²½κΈ° μ 체)λ©΄ ν΄λΉ | |
| u_parts = u.split() | |
| if len(u_parts) >= 2 and d == u_parts[0]: | |
| return True | |
| return False | |
| def filter_scholarships(profile): | |
| try: | |
| today = datetime.now().strftime('%Y-%m-%d') | |
| income_level = profile.get('income_level') | |
| gpa = profile.get('gpa') | |
| region = profile.get('region') | |
| loan = profile.get('loan') | |
| grade = profile.get('grade', '') | |
| grade_num = None | |
| try: grade_num = int(grade.replace('νλ ','').strip()) | |
| except: pass | |
| income_num = None | |
| if income_level and income_level != "λͺ¨λ¦/ν΄λΉμμ": | |
| try: income_num = int(income_level.replace("λΆμ","")) | |
| except: pass | |
| gpa_num = None | |
| if gpa and gpa not in ("λͺ¨λ¦/ν΄λΉμμ", "", None): | |
| try: gpa_num = float(str(gpa)) | |
| except: pass | |
| res = get_supabase().table("scholarships").select("*").eq("is_application", True).execute() | |
| filtered = [] | |
| for s in (res.data or []): | |
| target_grade = s.get('target_grade') | |
| if isinstance(target_grade, str): | |
| try: target_grade = json.loads(target_grade) | |
| except: target_grade = [] | |
| target_status = s.get('target_status') | |
| if isinstance(target_status, str): | |
| try: target_status = json.loads(target_status) | |
| except: target_status = [] | |
| if s.get('end_date_type') == 'λͺ μ' and s.get('end_date'): | |
| if s['end_date'] < today: continue | |
| if grade_num and target_grade: | |
| if grade_num not in target_grade: continue | |
| if target_status and 'μ¬ν' not in target_status: continue | |
| if income_num is not None and s.get('income_max') is not None: | |
| if income_num > s['income_max']: continue | |
| if gpa_num is not None and s.get('min_gpa') is not None: | |
| if gpa_num < s['min_gpa']: continue | |
| if s.get('region') is not None: | |
| if not _region_match(region, s['region']): continue | |
| if s.get('income_required') and loan != "κ΄μ¬μμ": continue | |
| filtered.append(s) | |
| application_notices = [] | |
| if filtered: | |
| notice_ids = [s['notice_id'] for s in filtered] | |
| notices_res = get_supabase().table("notices").select( | |
| "id,notice_id,title,url,posted_at,body,category" | |
| ).in_("notice_id", notice_ids).execute() | |
| notices_map = {n['notice_id']: n for n in (notices_res.data or [])} | |
| for s in filtered: | |
| notice = notices_map.get(s['notice_id'], {}) | |
| if notice: | |
| notice['scholarship_info'] = s | |
| application_notices.append(notice) | |
| rel_res = get_supabase().table("scholarships").select("notice_id").eq("is_application", False).execute() | |
| related_notices = [] | |
| if rel_res.data: | |
| rel_ids = [r['notice_id'] for r in rel_res.data] | |
| rel_notices = get_supabase().table("notices").select( | |
| "id,notice_id,title,url,posted_at,body,category" | |
| ).in_("notice_id", rel_ids).order("posted_at", desc=True).limit(3).execute() | |
| related_notices = rel_notices.data or [] | |
| return application_notices, related_notices | |
| except Exception as e: | |
| print(f"μ₯νκΈ νν°λ§ μ€λ₯: {e}") | |
| return [], [] | |
| def filter_dormitory(profile): | |
| try: | |
| today = datetime.now().strftime('%Y-%m-%d') | |
| gender = profile.get('gender') | |
| dorm_interest = profile.get('dorm_interest', []) | |
| res = get_supabase().table("dormitories").select("*").execute() | |
| filtered = [] | |
| for d in (res.data or []): | |
| if d.get('end_date_type') == 'λͺ μ' and d.get('end_date'): | |
| if d['end_date'] < today: continue | |
| if dorm_interest: # κ΄μ¬ κΈ°μμ¬ μ ννμ λλ§ μ΄λ¦ νν° μ μ© | |
| if not any(dorm in (d.get('name') or '') for dorm in dorm_interest): continue | |
| if gender == 'λ¨μ±' and d.get('male_quota') == 0: continue | |
| if gender == 'μ¬μ±' and d.get('female_quota') == 0: continue | |
| filtered.append(d) | |
| if not filtered: return [] | |
| notice_ids = [d['notice_id'] for d in filtered] | |
| notices_res = get_supabase().table("notices").select( | |
| "id,notice_id,title,url,posted_at,body,category" | |
| ).in_("notice_id", notice_ids).execute() | |
| notices_map = {n['notice_id']: n for n in (notices_res.data or [])} | |
| results = [] | |
| for d in filtered: | |
| notice = notices_map.get(d['notice_id'], {}) | |
| if notice: | |
| notice['dormitory_info'] = d | |
| results.append(notice) | |
| return results | |
| except Exception as e: | |
| print(f"κΈ°μμ¬ νν°λ§ μ€λ₯: {e}") | |
| return [] | |
| def filter_rotc(): | |
| try: | |
| res = get_supabase().table("notices").select( | |
| "id,notice_id,title,url,posted_at,body,category" | |
| ).eq("category", "ROTC").order("posted_at", desc=True).limit(5).execute() | |
| return res.data or [] | |
| except Exception as e: | |
| print(f"ROTC νν°λ§ μ€λ₯: {e}") | |
| return [] | |
| def sync_profile_to_supabase_user(profile): | |
| try: | |
| from app import sync_profile_to_supabase_user as _sync | |
| _sync(profile) | |
| except Exception as e: | |
| print(f"νλ‘ν λκΈ°ν μ€λ₯: {e}") | |
| # ββ FastAPI μ± βββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="μμνμΈλ API") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| app.mount("/images", StaticFiles(directory=os.path.join(_BASE, "images")), name="images") | |
| def favicon(): | |
| return FileResponse( | |
| os.path.join(_BASE, "images", "icon_μμλΆκΈ°.png"), | |
| media_type="image/png", | |
| ) | |
| def root(): | |
| return FileResponse(os.path.join(_BASE, "sangsang_finder.html")) | |
| # ββ μμ² λͺ¨λΈ ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Profile(BaseModel): | |
| name: str = "" | |
| college: str = "" | |
| track: str = "" | |
| grade: str = "" | |
| interests: list[str] = [] | |
| income_level: Optional[str] = None | |
| gpa: Optional[str] = None | |
| region: Optional[str] = None | |
| loan: Optional[str] = None | |
| gender: Optional[str] = None | |
| dorm_interest: list[str] = [] | |
| rotc_interest: bool = False | |
| phone: Optional[str] = None | |
| gemini_api_key: Optional[str] = None | |
| class ChatRequest(BaseModel): | |
| query: str | |
| category: str = "μ 체" | |
| profile: Profile | |
| history: list[dict] = [] | |
| is_first: bool = False | |
| class RecommendRequest(BaseModel): | |
| profile: Profile | |
| # ββ μ±λ΄ API βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def chat(req: ChatRequest): | |
| profile = req.profile.dict() | |
| results = search_rag_notices(req.query, req.category, top_k=20, profile=profile) | |
| reply = generate_llm_reply(req.query, results, profile, req.is_first) | |
| reranker_warning = any( | |
| ((r.get("feature_reranker") or {}).get("features") or {}).get("extraction", {}).get("llm_failed") | |
| for r in results | |
| ) | |
| if reranker_warning: | |
| logger.warning("LLM μ°κ²°μ μ€ν¨νμ¬ κ·μΉ κΈ°λ°μΌλ‘ κ³μ°ν κ²°κ³Όμ λλ€.") | |
| notices = [] | |
| for r in results[:3]: | |
| notices.append({ | |
| "title": r.get("title", ""), | |
| "category": r.get("category", ""), | |
| "date": (r.get("posted_at") or r.get("date") or "")[:10], | |
| "url": r.get("url") or r.get("link", "#"), | |
| "summary": (r.get("body") or r.get("content") or "")[:120], | |
| }) | |
| return { | |
| "reply": reply, | |
| "results": notices, | |
| } | |
| # ββ μΆμ² API βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def recommend(req: RecommendRequest): | |
| p = req.profile.dict() | |
| if p.get("phone"): | |
| try: sync_profile_to_supabase_user(p) | |
| except: pass | |
| interests = p.get("interests", []) | |
| def fmt(n): | |
| return { | |
| "title": n.get("title", ""), | |
| "category": n.get("category", ""), | |
| "date": (n.get("posted_at") or n.get("date") or "")[:10], | |
| "url": n.get("url") or n.get("link", "#"), | |
| "summary": (n.get("body") or "")[:120], | |
| } | |
| # ββ Two-Tower μΌλ° μΆμ² ββββββββββββββββββββββββββββββββββββ | |
| recs = [] | |
| try: | |
| recs = two_tower_recommend( | |
| college=p.get("college", ""), | |
| track=p.get("track", ""), | |
| year=p.get("grade", ""), | |
| interests=interests, | |
| top_k=10, | |
| ) | |
| except Exception as e: | |
| print(f"two_tower μ€λ₯: {e}") | |
| seen = set() | |
| general = [] | |
| for res in recs: | |
| n = res.get('notice', res) | |
| if not n.get("title"): continue | |
| key = n.get("url") or n.get("title", "") | |
| if key not in seen: | |
| seen.add(key) | |
| general.append(fmt(n)) | |
| # ββ μ₯νκΈ μΉμ βββββββββββββββββββββββββββββββββββββββββββ | |
| scholarships = [] | |
| scholarship_related = [] | |
| if "μ₯νκΈ" in interests: | |
| try: | |
| sch, rel = filter_scholarships(p) | |
| for n in sch: | |
| if not n.get("title"): continue | |
| key = n.get("url") or n.get("title", "") | |
| if key not in seen: | |
| seen.add(key) | |
| scholarships.append(fmt(n)) | |
| # κ΄λ ¨ μλ΄λ νν° κ²°κ³Ό μμ΄λ μ΅μ 3κ° λ¬΄μ‘°κ±΄ | |
| for n in rel[:3]: | |
| if not n.get("title"): continue | |
| key = n.get("url") or n.get("title", "") | |
| if key not in seen: | |
| seen.add(key) | |
| scholarship_related.append(fmt(n)) | |
| except Exception as e: | |
| print(f"μ₯νκΈ νν° μ€λ₯: {e}") | |
| # ββ κΈ°μμ¬ μΉμ βββββββββββββββββββββββββββββββββββββββββββ | |
| dormitories = [] | |
| if "κΈ°μμ¬" in interests: | |
| try: | |
| for n in filter_dormitory(p)[:3]: | |
| if not n.get("title"): continue | |
| key = n.get("url") or n.get("title", "") | |
| if key not in seen: | |
| seen.add(key) | |
| dormitories.append(fmt(n)) | |
| except Exception as e: | |
| print(f"κΈ°μμ¬ νν° μ€λ₯: {e}") | |
| # ββ ROTC μΉμ (rotc_interest 무κ΄νκ² κ΄μ¬μ¬μ ROTC μμΌλ©΄ νμ) ββ | |
| rotc = [] | |
| if "ROTC" in interests: | |
| try: | |
| for n in filter_rotc()[:2]: | |
| if not n.get("title"): continue | |
| key = n.get("url") or n.get("title", "") | |
| if key not in seen: | |
| seen.add(key) | |
| rotc.append(fmt(n)) | |
| except Exception as e: | |
| print(f"ROTC νν° μ€λ₯: {e}") | |
| return { | |
| "scholarships": scholarships, # is_application=True μ₯νκΈ | |
| "scholarship_related": scholarship_related, # μ₯νκΈ κ΄λ ¨ μλ΄ | |
| "dormitories": dormitories, | |
| "rotc": rotc, | |
| "notices": general[:10], | |
| } | |
| # ββ νλ‘ν λκΈ°ν API βββββββββββββββββββββββββββββββββββββββββ | |
| async def sync_profile_endpoint(req: RecommendRequest): | |
| try: | |
| sync_profile_to_supabase_user(req.profile.dict()) | |
| print(f"[sync-profile] {req.profile.name} μ μ₯ μλ£") | |
| return {"status": "ok"} | |
| except Exception as e: | |
| print(f"[sync-profile μ€λ₯] {e}") | |
| return {"status": "error", "message": str(e)} | |