Spaces:
Runtime error
Runtime error
| # ============================================================ | |
| # app.py β μμνμΈλ UI | |
| # ============================================================ | |
| import os, re, json, hashlib, warnings | |
| from datetime import datetime | |
| import html | |
| import streamlit as st | |
| from recommend import ( | |
| load_notices_from_supabase, | |
| two_tower_recommend, | |
| classify_job_type, | |
| get_supabase, | |
| ) | |
| from api.services.search_service import hybrid_search as pinecone_hybrid_search, _profile_summary | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except ImportError: | |
| pass | |
| warnings.filterwarnings("ignore") | |
| os.environ["TRANSFORMERS_VERBOSITY"] = "error" | |
| # ββ κ²½λ‘ μ€μ βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") | |
| os.makedirs(os.path.join(_BASE_DIR, "data"), exist_ok=True) | |
| # ββ μ λ²μ μΉ΄ν κ³ λ¦¬ βββββββββββββββββββββββββββββββββββββββββββ | |
| CATEGORIES = [ | |
| "μ·¨μ /μ±μ©", "νμ¬νμ ", "νμνλ/λΉκ΅κ³Ό", | |
| "λμΈνλ", "곡λͺ¨μ /κ²½μ§λν", "κ΅μ κ΅λ₯", "μ°½μ ", | |
| "μ₯νκΈ", "κΈ°μμ¬", "ROTC" | |
| ] | |
| PINECONE_CATEGORY_MAP = { | |
| "μ·¨μ /μ±μ©": ["μ·¨μ /μ±μ©", "μΈν΄μ"], | |
| "νμ¬νμ ": ["νμ¬νμ "], | |
| "νμνλ/λΉκ΅κ³Ό": ["νμνλ/λΉκ΅κ³Ό", "λΉκ΅κ³Ό", "κ΅μ‘/νΉκ°"], | |
| "λμΈνλ": ["λμΈνλ", "λ΄μ¬/μν¬ν°μ¦"], | |
| "곡λͺ¨μ /κ²½μ§λν": ["곡λͺ¨μ /κ²½μ§λν"], | |
| "κ΅μ κ΅λ₯": ["κ΅μ κ΅λ₯"], | |
| "μ°½μ ": ["μ°½μ "], | |
| "μ₯νκΈ": ["μ₯νκΈ", "νμκΈ/κ·Όλ‘μ₯ν"], | |
| "κΈ°μμ¬": ["κΈ°μμ¬", "κΈ°μμ¬/μνκ΄"], | |
| "ROTC": ["ROTC"], | |
| } | |
| COLLEGE_MAP = { | |
| "ν¬λ¦¬μμ΄ν°λΈμΈλ¬Έμμ λν": ["μλ―Έλ¬Ένμ½ν μΈ νΈλ", "μλ―ΈμΈμ΄μ 보νΈλ", "νκ΅μ΄κ΅μ‘νΈλ", "μμ¬λ¬Έννλ μ΄μ νΈλ", "μμ¬μ½ν μΈ νΈλ", "μ§μμ 보문ννΈλ", "λμ§νΈμΈλ¬Έμ 보ννΈλ", "λμνμ 곡", "μμνμ 곡", "νκ΅λ¬΄μ©μ 곡", "νλ무μ©μ 곡", "λ°λ μ 곡"], | |
| "λ―Έλμ΅ν©μ¬νκ³Όνλν": ["κ΅μ 무μνΈλ", "κΈλ‘λ²λΉμ§λμ€νΈλ", "κΈ°μ γκ²½μ λΆμνΈλ", "κ²½μ κΈμ΅ν¬μνΈλ", "곡곡νμ νΈλ", "λ²&μ μ± νΈλ", "λΆλμ°νΈλ", "μ€λ§νΈλμγκ΅ν΅κ³ννΈλ", "κΈ°μ κ²½μνΈλ", "λΉμ§λμ€μ λ리ν±μ€νΈλ", "νκ³γμ¬λ¬΄κ²½μνΈλ"], | |
| "λμμΈλν": ["ν¨μ λ§μΌν νΈλ", "ν¨μ λμμΈνΈλ", "ν¨μ ν¬λ¦¬μμ΄ν°λΈλλ μ νΈλ", "λ―Έλμ΄λμμΈνΈλ", "μκ°λμμΈνΈλ", "μμγμ λλ©μ΄μ λμμΈνΈλ", "UX/UIλμμΈνΈλ", "μΈν 리μ΄λμμΈνΈλ", "VMDγμ μλμμΈνΈλ", "κ²μκ·Έλν½λμμΈνΈλ", "λ·°ν°λμμΈλ§€λμ§λ¨ΌνΈνκ³Ό"], | |
| "IT곡과λν": ["λͺ¨λ°μΌμννΈμ¨μ΄νΈλ", "λΉ λ°μ΄ν°νΈλ", "λμ§νΈμ½ν μΈ γκ°μνμ€νΈλ", "μΉκ³΅ννΈλ", "μ μνΈλ", "μμ€ν λ°λ체νΈλ", "κΈ°κ³μμ€ν λμμΈνΈλ", "AIλ‘λ΄μ΅ν©νΈλ", "μ°μ 곡ννΈλ", "μμ©μ°μ λ°μ΄ν°κ³΅ννΈλ"], | |
| "μ°½μμ΅ν©λν": ["μμλ ₯μΈμ¬νλΆ", "λ¬Ένλ¬Ένμ½ν μΈ νκ³Ό", "AIμμ©νκ³Ό", "μ΅ν©λ³΄μνκ³Ό", "λ―Έλλͺ¨λΉλ¦¬ν°νκ³Ό"], | |
| "κΈλ‘λ²μΈμ¬λν": ["νκ΅μΈμ΄λ¬Ένκ΅μ‘νκ³Ό", "κΈλ‘λ²KλΉμ§λμ€νκ³Ό", "μμμν°ν μΈλ¨ΌνΈνκ³Ό", "ν¨μ λ·°ν°ν¬λ¦¬μμ΄μ νκ³Ό", "SWμ΅ν©νκ³Ό", "κΈλ‘λ²λ²€μ²μ°½μ νκ³Ό"], | |
| "λ―Έλνλ¬μ€λν": ["μ΅ν©νμ νκ³Ό", "νΈν μΈμκ²½μνκ³Ό", "λ·°ν°λμμΈνκ³Ό", "λΉμ§λμ€μ»¨μ€ν νκ³Ό", "ICTμ΅ν©λμμΈνκ³Ό", "AIγμννΈμ¨μ΄νκ³Ό", "λ·°ν°λ§€λμ§λ¨ΌνΈνκ³Ό", "λμ§νΈμ½ν μΈ λμμΈνκ³Ό", "μΈν 리μ΄λμμΈνκ³Ό", "μ€λ§νΈμ μ‘°νμ 컨μ€ν νκ³Ό"], | |
| } | |
| DEPT_URLS = { | |
| "νκ΅μΈμ΄λ¬Ένκ΅μ‘νκ³Ό":"https://www.hansung.ac.kr/global/1511/subview.do","κΈλ‘λ²KλΉμ§λμ€νκ³Ό":"https://www.hansung.ac.kr/global/1516/subview.do","μμμν°ν μΈλ¨ΌνΈνκ³Ό":"https://www.hansung.ac.kr/global/1521/subview.do","ν¨μ λ·°ν°ν¬λ¦¬μμ΄μ νκ³Ό":"https://www.hansung.ac.kr/global/1526/subview.do","SWμ΅ν©νκ³Ό":"https://www.hansung.ac.kr/global/1531/subview.do","κΈλ‘λ²λ²€μ²μ°½μ νκ³Ό":"https://www.hansung.ac.kr/global/6807/subview.do","μ΅ν©νμ νκ³Ό":"https://www.hansung.ac.kr/futureplus/731/subview.do","νΈν μΈμκ²½μνκ³Ό":"https://www.hansung.ac.kr/futureplus/734/subview.do","λ·°ν°λμμΈνκ³Ό":"https://www.hansung.ac.kr/futureplus/737/subview.do","λΉμ§λμ€μ»¨μ€ν νκ³Ό":"https://www.hansung.ac.kr/futureplus/740/subview.do","ICTμ΅ν©λμμΈνκ³Ό":"https://www.hansung.ac.kr/futureplus/743/subview.do","AIγμννΈμ¨μ΄νκ³Ό":"https://www.hansung.ac.kr/futureplus/746/subview.do","λ·°ν°λ§€λμ§λ¨ΌνΈνκ³Ό":"https://www.hansung.ac.kr/futureplus/749/subview.do","λμ§νΈμ½ν μΈ λμμΈνκ³Ό":"https://www.hansung.ac.kr/futureplus/754/subview.do","μΈν 리μ΄λμμΈνκ³Ό":"https://www.hansung.ac.kr/futureplus/759/subview.do","μ€λ§νΈμ μ‘°νμ 컨μ€ν νκ³Ό":"https://www.hansung.ac.kr/futureplus/764/subview.do", | |
| "μμλ ₯μΈμ¬νλΆ":"https://www.hansung.ac.kr/CreCon/2761/subview.do","λ¬Ένλ¬Ένμ½ν μΈ νκ³Ό":"https://www.hansung.ac.kr/CreCon/2768/subview.do","AIμμ©νκ³Ό":"https://www.hansung.ac.kr/CreCon/2777/subview.do","μ΅ν©λ³΄μνκ³Ό":"https://www.hansung.ac.kr/CreCon/2787/subview.do","λ―Έλλͺ¨λΉλ¦¬ν°νκ³Ό":"https://www.hansung.ac.kr/CreCon/2796/subview.do", | |
| "κ΅μ 무μνΈλ":"https://www.hansung.ac.kr/SclScn/5260/subview.do","κΈλ‘λ²λΉμ§λμ€νΈλ":"https://www.hansung.ac.kr/SclScn/5267/subview.do","κΈ°μ γκ²½μ λΆμνΈλ":"https://www.hansung.ac.kr/SclScn/5274/subview.do","κ²½μ κΈμ΅ν¬μνΈλ":"https://www.hansung.ac.kr/SclScn/5281/subview.do","곡곡νμ νΈλ":"https://www.hansung.ac.kr/SclScn/5295/subview.do","λ²&μ μ± νΈλ":"https://www.hansung.ac.kr/SclScn/5303/subview.do","λΆλμ°νΈλ":"https://www.hansung.ac.kr/SclScn/5313/subview.do","μ€λ§νΈλμγκ΅ν΅κ³ννΈλ":"https://www.hansung.ac.kr/SclScn/5321/subview.do","κΈ°μ κ²½μνΈλ":"https://www.hansung.ac.kr/SclScn/5328/subview.do","λΉμ§λμ€μ λ리ν±μ€νΈλ":"https://www.hansung.ac.kr/SclScn/5336/subview.do","νκ³γμ¬λ¬΄κ²½μνΈλ":"https://www.hansung.ac.kr/SclScn/5344/subview.do", | |
| "λͺ¨λ°μΌμννΈμ¨μ΄νΈλ":"https://www.hansung.ac.kr/Engineering/4887/subview.do","λΉ λ°μ΄ν°νΈλ":"https://www.hansung.ac.kr/Engineering/4894/subview.do","λμ§νΈμ½ν μΈ γκ°μνμ€νΈλ":"https://www.hansung.ac.kr/Engineering/4901/subview.do","μΉκ³΅ννΈλ":"https://www.hansung.ac.kr/Engineering/4908/subview.do","μ μνΈλ":"https://www.hansung.ac.kr/Engineering/4915/subview.do","μμ€ν λ°λ체νΈλ":"https://www.hansung.ac.kr/Engineering/4922/subview.do","κΈ°κ³μμ€ν λμμΈνΈλ":"https://www.hansung.ac.kr/Engineering/4929/subview.do","AIλ‘λ΄μ΅ν©νΈλ":"https://www.hansung.ac.kr/Engineering/4936/subview.do","μ°μ 곡ννΈλ":"https://www.hansung.ac.kr/Engineering/4992/subview.do","μμ©μ°μ λ°μ΄ν°κ³΅ννΈλ":"https://www.hansung.ac.kr/Engineering/5020/subview.do", | |
| "ν¨μ λ§μΌν νΈλ":"https://www.hansung.ac.kr/Design/5103/subview.do","ν¨μ λμμΈνΈλ":"https://www.hansung.ac.kr/Design/5110/subview.do","ν¨μ ν¬λ¦¬μμ΄ν°λΈλλ μ νΈλ":"https://www.hansung.ac.kr/Design/5117/subview.do","λ―Έλμ΄λμμΈνΈλ":"https://www.hansung.ac.kr/Design/5124/subview.do","μκ°λμμΈνΈλ":"https://www.hansung.ac.kr/Design/5145/subview.do","μμγμ λλ©μ΄μ λμμΈνΈλ":"https://www.hansung.ac.kr/Design/5131/subview.do","UX/UIλμμΈνΈλ":"https://www.hansung.ac.kr/Design/5173/subview.do","μΈν 리μ΄λμμΈνΈλ":"https://www.hansung.ac.kr/Design/5159/subview.do","VMDγμ μλμμΈνΈλ":"https://www.hansung.ac.kr/Design/5152/subview.do","κ²μκ·Έλν½λμμΈνΈλ":"https://www.hansung.ac.kr/Design/5166/subview.do","λ·°ν°λμμΈλ§€λμ§λ¨ΌνΈνκ³Ό":"https://www.hansung.ac.kr/Design/5180/subview.do", | |
| "μλ―Έλ¬Ένμ½ν μΈ νΈλ":"https://www.hansung.ac.kr/HmnArt/5641/subview.do","μλ―ΈμΈμ΄μ 보νΈλ":"https://www.hansung.ac.kr/HmnArt/5577/subview.do","νκ΅μ΄κ΅μ‘νΈλ":"https://www.hansung.ac.kr/HmnArt/5584/subview.do","μμ¬λ¬Έννλ μ΄μ νΈλ":"https://www.hansung.ac.kr/HmnArt/5627/subview.do","μμ¬μ½ν μΈ νΈλ":"https://www.hansung.ac.kr/HmnArt/5634/subview.do","μ§μμ 보문ννΈλ":"https://www.hansung.ac.kr/HmnArt/5613/subview.do","λμ§νΈμΈλ¬Έμ 보ννΈλ":"https://www.hansung.ac.kr/HmnArt/5620/subview.do","λμνμ 곡":"https://www.hansung.ac.kr/HmnArt/5648/subview.do","μμνμ 곡":"https://www.hansung.ac.kr/HmnArt/5655/subview.do","νκ΅λ¬΄μ©μ 곡":"https://www.hansung.ac.kr/HmnArt/5662/subview.do","νλ무μ©μ 곡":"https://www.hansung.ac.kr/HmnArt/5669/subview.do","λ°λ μ 곡":"https://www.hansung.ac.kr/HmnArt/5676/subview.do", | |
| } | |
| # ============================================================ | |
| # μ νΈ | |
| # ============================================================ | |
| def _load_image_b64(filename): | |
| import base64 | |
| try: | |
| with open(os.path.join(_BASE_DIR, filename), "rb") as f: | |
| return base64.b64encode(f.read()).decode() | |
| except: return "" | |
| def get_logo_base64(): return _load_image_b64("logo.png") | |
| def get_hsu_base64(): return _load_image_b64("hsu.png") | |
| # ============================================================ | |
| # λͺ¨λΈ λ‘λ | |
| # ============================================================ | |
| def summarize_notice(title, body): | |
| import html as _html | |
| body = body or '' | |
| clean_body = re.sub(r'<[^>]+>', '', body) | |
| result = clean_body[:200] + '...' if len(clean_body) > 200 else clean_body | |
| return _html.escape(result) | |
| def get_gemini_model(api_key): | |
| try: | |
| import google.generativeai as genai | |
| genai.configure(api_key=api_key) | |
| return genai.GenerativeModel("gemini-2.5-flash") | |
| except Exception as e: | |
| st.error(f"[Gemini μ€λ₯] {e}"); return None | |
| def _result_content_for_llm(result: dict, body_map: dict[str, str] | None = None) -> str: | |
| content = str(result.get("content") or "").strip() | |
| if len(content) >= 80: | |
| return content | |
| if body_map: | |
| fallback = str(body_map.get(result.get("url"), "")).strip() | |
| if fallback: | |
| return fallback | |
| return content | |
| def generate_llm_reply(user_query, results, profile, is_first=False): | |
| model = get_gemini_model(GEMINI_API_KEY) if GEMINI_API_KEY else None | |
| if not model: return f"μ΄ {len(results)}κ°μ κ΄λ ¨ 곡μ§λ₯Ό μ°Ύμμ΅λλ€." if results else "κ΄λ ¨ 곡μ§λ₯Ό μ°Ύμ§ λͺ»νμ΅λλ€." | |
| if not results: return "κ΄λ ¨ 곡μ§λ₯Ό μ°Ύμ§ λͺ»νμ΅λλ€. λ€λ₯Έ ν€μλλ‘ κ²μν΄λ³΄μΈμ." | |
| top_results = results[:3] | |
| needs_body_fallback = any(len(str(r.get("content") or "").strip()) < 80 for r in top_results) | |
| body_map = {} | |
| if needs_body_fallback: | |
| notices_data = load_notices_from_supabase() | |
| body_map = {n["url"]: n.get("body", "") for n in notices_data} | |
| context_parts = [] | |
| for i, r in enumerate(top_results, start=1): | |
| body = _result_content_for_llm(r, body_map)[:800] | |
| context_parts.append( | |
| f"[κ³΅μ§ {i}]\nμ λͺ©: {r.get('title', '')}\nλ μ§: {r.get('date', '')}\nλ΄μ©: {body if body else '(λ³Έλ¬Έ μμ)'}" | |
| ) | |
| context = "\n\n".join(context_parts) | |
| greeting = f"{profile.get('name','')}λ, μλ νμΈμ. " if is_first else "" | |
| route = (results[0].get("query_routing") or {}).get("route") if results else {} | |
| personalization = (route or {}).get("personalization") or {} | |
| profile_summary = personalization.get("profile_summary") or _profile_summary(profile) | |
| personalization_instruction = "" | |
| if personalization.get("enabled"): | |
| personalization_instruction = ( | |
| "\n- μ¬μ©μ νλ‘νμ λ°μν μ§λ¬Έμ λλ€. κ° μΆμ² 곡μ§λ§λ€ μ¬μ©μμ νκ³Ό/νλ /κ΄μ¬μ¬/μ§μκ³Ό " | |
| "μ λ§λμ§ λλ μ§μ κ°λ₯μ±κ³Ό κ΄λ ¨λ κ·Όκ±°λ₯Ό ν μ€λ‘ λ§λΆμ΄μΈμ." | |
| ) | |
| if profile_summary: | |
| personalization_instruction += f"\n- μ¬μ©μ νλ‘ν: {profile_summary}" | |
| prompt = f"""λΉμ μ νμ±λνκ΅ κ³΅μ§μ¬ν μλ΄ λμ°λ―Έμ λλ€. | |
| μλ 곡μ§μ¬ν λ³Έλ¬Έμ λ°νμΌλ‘ μ¬μ©μ μ§λ¬Έμ μ§μ μ μ΄κ³ ꡬ체μ μΌλ‘ λ΅λ³νμΈμ. | |
| - λ μ§, κΈμ‘, 쑰건 λ± κ΅¬μ²΄μ μΈ μ λ³΄κ° μμΌλ©΄ λ°λμ ν¬ν¨νμΈμ. | |
| - "곡μ§λ₯Ό μ°Έκ³ νμΈμ" κ°μ λ§μ μ λ νμ§ λ§μΈμ. | |
| - 2~3λ¬Έμ₯μΌλ‘ κ°κ²°νκ² λ΅λ³νμΈμ. | |
| - λ΅λ³ μμ: "{greeting}" | |
| {personalization_instruction} | |
| [κ³΅μ§ λ³Έλ¬Έ] | |
| {context} | |
| [μ§λ¬Έ] | |
| {user_query}""" | |
| try: return get_gemini_model(GEMINI_API_KEY).generate_content(prompt).text.strip() | |
| except Exception as e: return f"[Gemini μ€λ₯] {e}" | |
| # ============================================================ | |
| # CSS | |
| # ============================================================ | |
| GLOBAL_CSS = """ | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap'); | |
| html, body, [data-testid="stAppViewContainer"], [data-testid="stMain"] { background-color: #f2f2f7 !important; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Noto Sans KR", sans-serif !important; } | |
| .block-container { padding-left: 2rem !important; padding-right: 2rem !important; max-width: 860px !important; margin: 0 auto !important; } | |
| [data-testid="collapsedControl"], [data-testid="stSidebarCollapsedControl"], [data-testid="stSidebarCollapseButton"], button[kind="header"], .st-emotion-cache-h5rgaw, [data-testid="stSidebar"] > div:first-child > div:first-child button { display: none !important; } | |
| section[data-testid="stSidebar"] { width: 260px !important; min-width: 260px !important; transform: translateX(0) !important; visibility: visible !important; background: #ffffff !important; border-right: 1px solid rgba(0,0,0,0.08) !important; } | |
| section[data-testid="stSidebar"] > div:first-child { width: 260px !important; padding: 8px 16px 16px 16px !important; overflow: hidden !important; } | |
| [data-testid="stSidebar"] > div:first-child > div:first-child { padding-top: 0 !important; margin-top: -3rem !important; } | |
| .stButton > button { background: #0a84ff !important; color: white !important; border: none !important; border-radius: 8px !important; font-size: 13px !important; font-weight: 500 !important; padding: 7px 14px !important; transition: all 0.15s ease !important; } | |
| .stButton > button:hover { background: #409cff !important; transform: scale(1.01) !important; } | |
| .stTextInput > div > div > input { background: white !important; border: 1px solid rgba(0,0,0,0.12) !important; border-radius: 12px !important; font-size: 15px !important; padding: 12px 16px !important; } | |
| .stTextInput > div > div > input:focus { border-color: #0a84ff !important; box-shadow: 0 0 0 3px rgba(10,132,255,0.15) !important; outline: none !important; } | |
| .stSelectbox > div > div, .stMultiSelect > div > div { background: white !important; border-radius: 10px !important; border: 1px solid rgba(0,0,0,0.12) !important; } | |
| :root { --primary-color: #0a84ff !important; } | |
| [data-baseweb="tag"] { background-color: rgba(10,132,255,0.12) !important; } | |
| [data-baseweb="tag"] span { color: #0a84ff !important; } | |
| .stTabs [data-baseweb="tab-list"] { background: rgba(120,120,128,0.12) !important; border-radius: 12px !important; padding: 3px !important; gap: 2px !important; width: fit-content !important; margin: 0 auto 16px auto !important; } | |
| .stTabs [data-baseweb="tab"] { border-radius: 10px !important; font-size: 14px !important; font-weight: 500 !important; color: #3c3c43 !important; padding: 8px 20px !important; } | |
| .stTabs [aria-selected="true"] { background: white !important; color: #1d1d1f !important; box-shadow: 0 1px 4px rgba(0,0,0,0.12) !important; font-weight: 600 !important; } | |
| .stTabs [data-baseweb="tab-border"] { background-color: transparent !important; } | |
| .chat-bubble-user { background: #0a84ff; color: white; border-radius: 20px 20px 5px 20px; padding: 10px 16px; max-width: 55%; width: fit-content; margin-left: auto; margin-bottom: 10px; font-size: 14px; line-height: 1.5; word-break: break-word; } | |
| .chat-bubble-bot { background: #f0f4ff; color: #1d1d1f; border-radius: 20px 20px 20px 5px; padding: 12px 18px; max-width: 75%; width: fit-content; margin-bottom: 10px; font-size: 14px; line-height: 1.55; border: 1px solid #dce8ff; word-break: break-word; } | |
| .mac-bar { display: flex; align-items: center; gap: 7px; padding: 10px 16px; background: #e8eef5; border-radius: 10px 10px 0 0; margin: -14px -14px 0 -14px; border-bottom: 1px solid rgba(0,0,0,0.07); } | |
| .mac-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } | |
| .mac-dot-red { background: #ff5f57; } .mac-dot-yellow { background: #febc2e; } .mac-dot-green { background: #28c840; } | |
| [data-testid="stMarkdownContainer"]:has(.mac-bar) { margin-bottom: -1rem !important; line-height: 0 !important; } | |
| [data-testid="stVerticalBlockBorderWrapper"]:has(.chat-bubble-user), [data-testid="stVerticalBlockBorderWrapper"]:has(.chat-bubble-bot), [data-testid="stVerticalBlockBorderWrapper"]:has(.mac-bar) { background: #f9f9fb !important; border-radius: 13px !important; min-height: 0 !important; border: 1px solid rgba(0,0,0,0.07) !important; padding: 14px !important; } | |
| .notice-card { background: white; border-radius: 14px; padding: 16px 20px; margin-bottom: 12px; box-shadow: 0 1px 5px rgba(0,0,0,0.06); border: 1px solid rgba(0,0,0,0.05); transition: box-shadow 0.15s ease; } | |
| .notice-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.10); } | |
| .notice-tag { display: inline-block; background: rgba(10,132,255,0.1); color: #0a84ff; border-radius: 7px; padding: 2px 9px; font-size: 11px; font-weight: 700; margin-right: 8px; } | |
| .notice-title { font-size: 15px; font-weight: 600; color: #1d1d1f; margin: 6px 0 2px 0; line-height: 1.4; } | |
| .notice-date { font-size: 12px; color: #86868b; } | |
| .notice-summary { font-size: 13px; color: #3c3c43; margin-top: 8px; line-height: 1.55; } | |
| .sb-label { font-size: 10px; font-weight: 700; color: rgba(0,0,0,0.35); text-transform: uppercase; letter-spacing: 0.1em; margin: 18px 0 6px 2px; } | |
| .sb-info-row { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; } | |
| .sb-info-key { font-size: 12px; color: rgba(0,0,0,0.4); min-width: 52px; } | |
| .sb-info-val { font-size: 13px; font-weight: 500; color: #1d1d1f; line-height: 1.4; } | |
| [data-testid="stVerticalBlockBorderWrapper"] { background: white !important; border-radius: 20px !important; padding: 32px 36px !important; box-shadow: 0 4px 24px rgba(0,0,0,0.08) !important; border: none !important; } | |
| hr { border: none; border-top: 1px solid rgba(0,0,0,0.08) !important; margin: 14px 0 !important; } | |
| #MainMenu, footer, header { visibility: hidden; } | |
| </style> | |
| """ | |
| # ============================================================ | |
| # μλ¦Ό λμμ λ°ν | |
| # ============================================================ | |
| def _notification_user_id(profile: dict) -> str: | |
| phone = profile.get("phone") or "" | |
| seed = phone or profile.get("name") or json.dumps(profile, ensure_ascii=False, sort_keys=True) | |
| return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24] | |
| def sync_profile_to_supabase_user(profile: dict) -> None: | |
| """λ‘컬 μ¨λ³΄λ© νλ‘νμ Supabase users ν μ΄λΈμ μ μνΈνλ€.""" | |
| if not profile.get("name"): | |
| return | |
| user_row = { | |
| "user_id": _notification_user_id(profile), | |
| "name": profile.get("name", ""), | |
| "phone": profile.get("phone"), | |
| "interests": profile.get("interests") or [], | |
| "track": profile.get("track", ""), | |
| "college": profile.get("college", ""), | |
| "grade": profile.get("grade", ""), | |
| "income_level": profile.get("income_level"), | |
| "gpa": profile.get("gpa"), | |
| "region": profile.get("region"), | |
| "loan": profile.get("loan"), | |
| "gender": profile.get("gender"), | |
| "dorm_interest": profile.get("dorm_interest") or [], | |
| "rotc_interest": profile.get("rotc_interest", False), | |
| } | |
| supabase_url = os.getenv("SUPABASE_URL") | |
| supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_KEY") | |
| try: | |
| if supabase_url and supabase_key and not supabase_url.startswith("your_"): | |
| try: | |
| get_supabase().table("users").upsert(user_row, on_conflict="user_id").execute() | |
| except Exception as rest_error: | |
| if os.getenv("SUPABASE_DB_URL"): | |
| print(f"Supabase REST users λκΈ°ν μ€ν¨, DB μ§μ μ°κ²°λ‘ μ¬μλ: {rest_error}") | |
| _sync_profile_to_supabase_user_via_db(user_row) | |
| else: | |
| raise | |
| else: | |
| _sync_profile_to_supabase_user_via_db(user_row) | |
| except Exception as e: | |
| print(f"Supabase users λκΈ°ν μ€ν¨: {e}") | |
| def _sync_profile_to_supabase_user_via_db(user_row: dict) -> None: | |
| """SUPABASE_DB_URLλ§ μλ λ‘컬 νκ²½μμ users ν μ΄λΈ μμ± ν μ μνΈνλ€.""" | |
| from crawling.supabase_store import ensure_schema, _connect | |
| ensure_schema() | |
| with _connect() as conn: | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| """ | |
| insert into users (user_id, name, phone, interests, track, updated_at) | |
| values (%s, %s, %s, %s, %s, now()) | |
| on conflict (user_id) do update set | |
| name = excluded.name, | |
| phone = excluded.phone, | |
| interests = excluded.interests, | |
| track = excluded.track, | |
| updated_at = now() | |
| """, | |
| ( | |
| user_row["user_id"], | |
| user_row["name"], | |
| user_row["phone"], | |
| user_row["interests"], | |
| user_row["track"], | |
| ), | |
| ) | |
| conn.commit() | |
| def get_notification_targets(notices: list[dict], users: list[dict] | None = None) -> list[dict]: | |
| """ | |
| μ κ· κ³΅μ§ λ¦¬μ€νΈλ₯Ό λ°μ μλ¦Ό λμμ μ 보 λ°ν. | |
| μλ¦Ό λ΄λΉμμκ² μ λ¬ν λ°μ΄ν° ꡬ쑰: | |
| [ | |
| { | |
| "notice_id": "222025", | |
| "title": "κ³΅μ§ μ λͺ©", | |
| "category": "μ₯νκΈ", | |
| "category_type": ["μ₯νκΈ"], | |
| "url": "https://...", | |
| "user_name": "νκΈΈλ", | |
| "phone": "010-1234-5678", | |
| "track": "μλ―Έλ¬Ένμ½ν μΈ νΈλ", | |
| "interests": ["μ₯νκΈ", "μ·¨μ /μ±μ©"], | |
| }, | |
| ... | |
| ] | |
| """ | |
| if users is None: | |
| try: | |
| res = get_supabase().table("users").select("user_id,name,phone,interests,track").execute() | |
| users = res.data or [] | |
| except Exception as e: | |
| print(f"users ν μ΄λΈ λ‘λ μ€λ₯: {e}") | |
| return [] | |
| targets = [] | |
| for notice in notices: | |
| category = notice.get('category', '') | |
| category_type = notice.get('category_type') or notice.get('job_types') or [] | |
| if isinstance(category_type, str): | |
| try: | |
| category_type = json.loads(category_type) | |
| except: | |
| category_type = [] | |
| for user in users: | |
| if not user or not user.get('phone'): | |
| continue | |
| interests = user.get('interests', []) | |
| if isinstance(interests, str): | |
| try: | |
| interests = json.loads(interests) | |
| except: | |
| interests = [interests] | |
| interest_set = set(interests) | |
| matched = category in interest_set or bool(interest_set.intersection(category_type)) | |
| if not matched: | |
| continue | |
| targets.append({ | |
| "notice_db_id": notice.get('id'), | |
| "notice_id": notice.get('notice_id', ''), | |
| "title": notice.get('title', ''), | |
| "category": category, | |
| "category_type": category_type, | |
| "url": notice.get('url', ''), | |
| "user_id": user.get('user_id') or user.get('id') or user.get('phone'), | |
| "user_name": user.get('name', ''), | |
| "phone": user.get('phone'), | |
| "track": user.get('track', ''), | |
| "interests": interests, | |
| }) | |
| return targets | |
| # ============================================================ | |
| # νν°λ§ ν¨μ | |
| # ============================================================ | |
| def filter_scholarships(profile) -> tuple: | |
| """(μ μ² κ³΅μ§ λ¦¬μ€νΈ, κ΄λ ¨ κ³΅μ§ λ¦¬μ€νΈ) λ°ν""" | |
| 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 != "λͺ¨λ¦/ν΄λΉμμ": | |
| try: | |
| gpa_num = float(gpa.split(" ")[0]) | |
| 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 and region not in [None, "λͺ¨λ¦/ν΄λΉμμ"]: | |
| if s['region'] != 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) | |
| # κ΄λ ¨ κ³΅μ§ μ΅μ μ 3κ° | |
| 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}") | |
| import traceback; traceback.print_exc() | |
| return [], [] | |
| def filter_dormitory(profile) -> list: | |
| 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}") | |
| import traceback; traceback.print_exc() | |
| return [] | |
| def filter_rotc() -> list: | |
| 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 render_onboarding(): | |
| st.markdown(GLOBAL_CSS, unsafe_allow_html=True) | |
| st.markdown("<div style='height:48px'></div>", unsafe_allow_html=True) | |
| col_logo, col_form = st.columns([1, 2], gap="large") | |
| with col_logo: | |
| st.markdown("<div style='height:60px'></div>", unsafe_allow_html=True) | |
| logo_b64 = get_logo_base64() | |
| logo_img = f'<img src="data:image/png;base64,{logo_b64}" style="width:90px;height:auto;object-fit:contain;display:block;">' if logo_b64 else '<div style="font-size:56px;">π</div>' | |
| st.markdown(f'<div style="padding-left:8px;">{logo_img}<div style="font-size:28px;font-weight:700;color:#1d1d1f;letter-spacing:-0.03em;margin-top:16px;">μμνμΈλ</div><div style="font-size:14px;color:#86868b;margin-top:6px;line-height:1.6;">νμ±λ 곡μ§λ₯Ό<br>μ€λ§νΈνκ² κ²μνμΈμ.</div></div>', unsafe_allow_html=True) | |
| with col_form: | |
| with st.container(border=True): | |
| st.markdown('<div style="font-size:17px;font-weight:700;color:#1d1d1f;margin-bottom:3px;">λ°κ°μ΅λλ€ π</div><div style="font-size:13px;color:#86868b;margin-bottom:14px;">κΈ°λ³Έ μ 보λ₯Ό μλ €μ£ΌμΈμ.</div>', unsafe_allow_html=True) | |
| r1c1, r1c2 = st.columns(2) | |
| with r1c1: name = st.text_input("μ΄λ¦", placeholder="νκΈΈλ", key="ob_name") | |
| with r1c2: college = st.selectbox("λ¨κ³Όλ", list(COLLEGE_MAP.keys()), key="ob_college") | |
| r2c1, r2c2 = st.columns(2) | |
| with r2c1: track = st.selectbox("νΈλ / νκ³Ό", COLLEGE_MAP.get(college, ["κΈ°ν"]), key="ob_track") | |
| with r2c2: grade = st.selectbox("νλ ", ["1νλ ","2νλ ","3νλ ","4νλ "], key="ob_grade") | |
| interests = st.multiselect( | |
| "κ΄μ¬μ¬", | |
| ["μ·¨μ /μ±μ©","νμ¬νμ ","νμνλ/λΉκ΅κ³Ό","λμΈνλ","곡λͺ¨μ /κ²½μ§λν","κ΅μ κ΅λ₯","μ°½μ ","μ₯νκΈ","κΈ°μμ¬","ROTC"], | |
| placeholder="κ΄μ¬ μΉ΄ν κ³ λ¦¬λ₯Ό μ ννμΈμ", | |
| key="ob_interests" | |
| ) | |
| # ββ μ₯νκΈ μΆκ° νλͺ© βββββββββββββββββββββββββββββββ | |
| income_level = None | |
| gpa = None | |
| region = None | |
| loan = None | |
| if "μ₯νκΈ" in interests: | |
| st.markdown("<div style='font-size:13px;font-weight:600;color:#1d1d1f;margin-top:12px;margin-bottom:6px;'>π μ₯νκΈ λ§μΆ€ νν°</div>", unsafe_allow_html=True) | |
| sc1, sc2 = st.columns(2) | |
| with sc1: | |
| income_level = st.selectbox( | |
| "μλλΆμ", | |
| ["λͺ¨λ¦/ν΄λΉμμ","1λΆμ","2λΆμ","3λΆμ","4λΆμ","5λΆμ","6λΆμ","7λΆμ","8λΆμ","9λΆμ","10λΆμ"], | |
| key="ob_income" | |
| ) | |
| with sc2: | |
| gpa = st.selectbox( | |
| "μ΅κ·Ό νμ ", | |
| ["λͺ¨λ¦/ν΄λΉμμ","4.0 μ΄μ","3.5 μ΄μ","3.0 μ΄μ","2.5 μ΄μ","2.0 μ΄μ","2.0 λ―Έλ§"], | |
| key="ob_gpa" | |
| ) | |
| sc3, sc4 = st.columns(2) | |
| with sc3: | |
| region = st.selectbox( | |
| "κ±°μ£Ό μ§μ", | |
| ["μμΈ","κ²½κΈ°/μΈμ²","μ§λ°©","ν΄μΈ"], | |
| key="ob_region" | |
| ) | |
| with sc4: | |
| loan = st.selectbox( | |
| "νμκΈ λμΆ μ¬λΆ", | |
| ["ν΄λΉμμ","λμΆ μμ"], | |
| key="ob_loan" | |
| ) | |
| # ββ κΈ°μμ¬ μΆκ° νλͺ© βββββββββββββββββββββββββββββββ | |
| gender = None | |
| dorm_interest = None | |
| if "κΈ°μμ¬" in interests: | |
| st.markdown("<div style='font-size:13px;font-weight:600;color:#1d1d1f;margin-top:12px;margin-bottom:6px;'>π κΈ°μμ¬ λ§μΆ€ νν°</div>", unsafe_allow_html=True) | |
| dc1, dc2 = st.columns(2) | |
| with dc1: | |
| gender = st.selectbox( | |
| "μ±λ³", | |
| ["μ νμν¨","λ¨μ±","μ¬μ±"], | |
| key="ob_gender" | |
| ) | |
| with dc2: | |
| dorm_interest = st.multiselect( | |
| "κ΄μ¬ κΈ°μμ¬", | |
| ["μμλΉλ¦¬μ§","μ°μ΄νμ¬","λμλ¬Έν볡기μμ¬","μνΌμλ","μλκΈ°μμ¬"], | |
| key="ob_dorm" | |
| ) | |
| # ββ ROTC μΆκ° νλͺ© βββββββββββββββββββββββββββββββββ | |
| rotc_interest = False | |
| if "ROTC" in interests: | |
| st.markdown("<div style='font-size:13px;font-weight:600;color:#1d1d1f;margin-top:12px;margin-bottom:6px;'>π ROTC</div>", unsafe_allow_html=True) | |
| rotc_interest = st.checkbox("ROTC ν보μ λͺ¨μ§ κ³΅μ§ λ°κΈ°", value=True, key="ob_rotc") | |
| # ββ μ νλ²νΈ (μλ¦Όμ©) ββββββββββββββββββββββββββββββ | |
| st.markdown("<div style='font-size:13px;color:#86868b;margin-top:12px;margin-bottom:4px;'>π± κ³΅μ§ μλ¦Ό μμ μ νλ²νΈ <span style='font-size:11px;'>(μ ν, λΉμΉΈμ΄λ©΄ μλ¦Ό λ―Έμ¬μ©)</span></div>", unsafe_allow_html=True) | |
| pc1, pc2, pc3 = st.columns(3) | |
| with pc1: phone1 = st.text_input("", placeholder="010", key="ob_phone1", max_chars=3, label_visibility="collapsed") | |
| with pc2: phone2 = st.text_input("", placeholder="0000", key="ob_phone2", max_chars=4, label_visibility="collapsed") | |
| with pc3: phone3 = st.text_input("", placeholder="0000", key="ob_phone3", max_chars=4, label_visibility="collapsed") | |
| phone1 = re.sub(r'\D', '', phone1) | |
| phone2 = re.sub(r'\D', '', phone2) | |
| phone3 = re.sub(r'\D', '', phone3) | |
| phone = f"{phone1}-{phone2}-{phone3}" if phone1 and phone2 and phone3 else None | |
| st.markdown("<div style='height:4px'></div>", unsafe_allow_html=True) | |
| if st.button("μμνκΈ° β", use_container_width=True): | |
| if not name.strip(): | |
| st.markdown('<div style="background:#e8f1ff;color:#0a84ff;border-radius:10px;padding:10px 16px;font-size:14px;font-weight:500;border:1px solid #b3d1ff;">βοΈ μ΄λ¦μ μ λ ₯ν΄ μ£ΌμΈμ.</div>', unsafe_allow_html=True) | |
| else: | |
| profile_data = { | |
| "name": name.strip(), | |
| "college": college, | |
| "track": track, | |
| "grade": grade, | |
| "interests": interests, | |
| "income_level": income_level, | |
| "gpa": gpa, | |
| "region": region, | |
| "loan": loan, | |
| "gender": gender, | |
| "dorm_interest": dorm_interest or [], | |
| "rotc_interest": rotc_interest, | |
| "phone": phone, | |
| } | |
| st.session_state.profile = profile_data | |
| st.session_state.onboarded = True | |
| sync_profile_to_supabase_user(profile_data) | |
| st.rerun() | |
| # ============================================================ | |
| # μ¬μ΄λλ° | |
| # ============================================================ | |
| def render_sidebar(profile): | |
| with st.sidebar: | |
| logo_b64 = get_logo_base64() | |
| logo_img = f'<img src="data:image/png;base64,{logo_b64}" style="width:32px;height:32px;object-fit:contain;flex-shrink:0;">' if logo_b64 else 'π' | |
| st.markdown(f'<div style="display:flex;align-items:center;gap:10px;padding-bottom:10px;">{logo_img}<div><div style="font-size:14px;font-weight:700;color:#1d1d1f;">μμνμΈλ</div><div style="font-size:10px;color:#86868b;margin-top:1px;">Hansung Notice Finder</div></div></div><hr/>', unsafe_allow_html=True) | |
| st.markdown('<div class="sb-label">λ΄ μ 보</div>', unsafe_allow_html=True) | |
| info_html = "".join([f'<div class="sb-info-row"><span class="sb-info-key">{k}</span><span class="sb-info-val">{v}</span></div>' for k, v in [("μ΄λ¦", profile.get("name","")), ("λ¨κ³Όλν", profile.get("college","")), ("νΈλ/νκ³Ό", profile.get("track","")), ("νλ ", profile.get("grade",""))]]) | |
| st.markdown(info_html, unsafe_allow_html=True) | |
| st.markdown("<hr/>", unsafe_allow_html=True) | |
| st.markdown('<div class="sb-label">λ°λ‘κ°κΈ°</div>', unsafe_allow_html=True) | |
| links = [("π«","νμ±λνκ΅","https://www.hansung.ac.kr/hansung/index.do"),("π»","νμ± e-class","https://learn.hansung.ac.kr/"),("π","μ’ ν©μ 보μμ€ν ","https://info.hansung.ac.kr/"),("π","μ€λ§νΈμκΈ°κ΄λ¦¬μμ€ν ","https://hsportal.hansung.ac.kr/"),("π","νμ μ 보κ΄","https://hsel.hansung.ac.kr/")] | |
| track = profile.get("track","") | |
| if track in DEPT_URLS: links.append(("π", track, DEPT_URLS[track])) | |
| link_html = "".join([f'<a href="{url}" target="_blank" style="display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;text-decoration:none;color:#1d1d1f;font-size:13px;font-weight:500;margin-bottom:2px;" onmouseover="this.style.background=\'rgba(0,0,0,0.05)\'" onmouseout="this.style.background=\'transparent\'"><span style="font-size:15px;">{icon}</span><span>{label}</span><span style="margin-left:auto;font-size:11px;color:#aeaeb2;">β</span></a>' for icon, label, url in links]) | |
| st.markdown(link_html, unsafe_allow_html=True) | |
| st.markdown("<hr/>", unsafe_allow_html=True) | |
| _, btn_col, _ = st.columns([0.3, 2.4, 0.3]) | |
| with btn_col: | |
| if st.button("λ΄ μ 보 λ€μ μ λ ₯", use_container_width=True): | |
| st.session_state.onboarded = False; st.session_state.profile = {}; st.session_state.chat_history = [] | |
| st.rerun() | |
| # ============================================================ | |
| # μ±λ΄ | |
| # ============================================================ | |
| def search_rag_notices(query: str, category_filter: str = "μ 체", top_k: int = 5) -> list[dict]: | |
| 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=st.session_state.get("profile", {}), | |
| ) | |
| except Exception as e: | |
| st.error(f"[Pinecone κ²μ μ€λ₯] {e}") | |
| return [] | |
| def render_chatbot(profile): | |
| with st.container(border=True): | |
| st.markdown('<div class="mac-bar"><div class="mac-dot mac-dot-red"></div><div class="mac-dot mac-dot-yellow"></div><div class="mac-dot mac-dot-green"></div></div><div style="margin-top:12px;"></div>', unsafe_allow_html=True) | |
| if not st.session_state.chat_history: | |
| name = profile.get("name",""); hsu_b64 = get_hsu_base64() | |
| hsu_img = f'<img src="data:image/png;base64,{hsu_b64}" style="width:60px;height:60px;object-fit:contain;display:block;margin:0 auto 14px auto;">' if hsu_b64 else '<div style="font-size:38px;margin-bottom:14px;">π</div>' | |
| st.markdown(f'<div style="text-align:center;padding:24px 0 20px;color:#86868b;">{hsu_img}<div style="font-size:17px;font-weight:600;color:#1d1d1f;margin-bottom:6px;">μλ νμΈμ, {name}λ!</div><div style="font-size:13px;line-height:1.6;">νμ±λ 곡μ§λ₯Ό μμ°μ΄λ‘ κ²μν΄λ³΄μΈμ.<br/><span style="color:#aeaeb2;">"μ₯νκΈ μ μ² κΈ°κ° μλ €μ€" Β· "μ·¨μ λ°λν μΈμ μΌ?" Β· "λΉκ΅κ³Ό νλ‘κ·Έλ¨ μΆμ²ν΄μ€"</span></div></div>', unsafe_allow_html=True) | |
| else: | |
| for msg in st.session_state.chat_history: | |
| if msg["role"] == "user": | |
| st.markdown(f'<div class="chat-bubble-user">{msg["content"]}</div>', unsafe_allow_html=True) | |
| else: | |
| st.markdown(f'<div class="chat-bubble-bot">{msg["content"]}</div>', unsafe_allow_html=True) | |
| if msg.get("results"): | |
| for idx, r in enumerate(msg["results"][:3], start=1): | |
| title_safe = html.escape(str(r.get("title", ""))) | |
| cat_safe = html.escape(str(r.get("category", "κΈ°ν"))) | |
| date_safe = html.escape(str(r.get("date", ""))) | |
| url_safe = html.escape(str(r.get("url", "#")), quote=True) | |
| st.markdown( | |
| '<div class="notice-card">' | |
| f'<span style="font-size:11px;color:#86868b;font-weight:500;">μ°Έκ³ κ³΅μ§ Top{idx}</span> ' | |
| f'<span class="notice-tag">{cat_safe}</span>' | |
| f'<span class="notice-date">{date_safe}</span>' | |
| f'<div class="notice-title">{title_safe}</div>' | |
| '<div style="margin-top:8px;">' | |
| f'<a href="{url_safe}" target="_blank" style="font-size:12px;color:#0a84ff;text-decoration:none;font-weight:600;">κ³΅μ§ λ°λ‘κ°κΈ° β</a>' | |
| '</div>' | |
| '</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| with st.form("chat_form", clear_on_submit=True): | |
| c0, c1, c2 = st.columns([1.2, 4, 0.8]) | |
| with c0: cat_filter = st.selectbox("μΉ΄ν κ³ λ¦¬", ["μ 체"]+CATEGORIES, label_visibility="collapsed", key="chat_cat") | |
| with c1: user_input = st.text_input("λ©μμ§", placeholder="무μμ΄ κΆκΈνμΈμ?", label_visibility="collapsed") | |
| with c2: submitted = st.form_submit_button("μ μ‘", use_container_width=True) | |
| if submitted and user_input: | |
| st.session_state.chat_history.append({"role": "user", "content": user_input}) | |
| cat_filter = st.session_state.get("chat_cat", "μ 체") | |
| results = search_rag_notices(user_input, cat_filter, top_k=5) | |
| with st.spinner("λ΅λ³ μμ± μ€..."): | |
| reply = generate_llm_reply(user_input, results, st.session_state.profile, is_first=len(st.session_state.chat_history)==1) | |
| st.session_state.chat_history.append({"role": "bot", "content": reply, "results": results}) | |
| st.rerun() | |
| if st.session_state.chat_history: | |
| if st.button("λν μ΄κΈ°ν"): st.session_state.chat_history = []; st.rerun() | |
| # ============================================================ | |
| # μΆμ² κ²μλ¬Ό | |
| # ============================================================ | |
| def render_notice_card(notice): | |
| jts = classify_job_type(notice) | |
| job_str = " Β· ".join([t['job_type'] for t in jts]) if jts else "" | |
| summary = summarize_notice(notice.get('title',''), notice.get('body','')) | |
| title_safe = html.escape(notice.get('title', '')) | |
| cat_safe = html.escape(notice.get('category', 'κΈ°ν')) | |
| date_val = re.sub(r'<[^>]+>', '', str(notice.get('date', notice.get('posted_at','')))).strip()[:10] | |
| job_html = f'<span style="font-size:11px;color:#86868b;">{html.escape(job_str)}</span>' if job_str else "" | |
| sum_html = f'<div class="notice-summary">{summary}</div>' if summary else "" | |
| st.markdown( | |
| '<div class="notice-card">' | |
| '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">' | |
| f'<span class="notice-tag">{cat_safe}</span>{job_html}' | |
| f'<span class="notice-date" style="margin-left:auto;">{date_val}</span>' | |
| '</div>' | |
| f'<div class="notice-title">{title_safe}</div>' | |
| f'{sum_html}' | |
| '<div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;">' | |
| f'<a href="{notice.get("url","#")}" target="_blank" style="font-size:12px;color:#0a84ff;text-decoration:none;font-weight:600;">κ³΅μ§ λ°λ‘κ°κΈ° β</a>' | |
| '</div>' | |
| '</div>', | |
| unsafe_allow_html=True | |
| ) | |
| def render_recommend(profile): | |
| st.markdown( | |
| f'<div style="background:white;border-radius:14px;padding:16px 20px;margin-bottom:20px;' | |
| f'box-shadow:0 1px 5px rgba(0,0,0,0.06);display:flex;align-items:center;gap:16px;">' | |
| f'<div style="font-size:30px;">π</div>' | |
| f'<div><div style="font-size:15px;font-weight:700;color:#1d1d1f;">' | |
| f'{profile.get("college","")} Β· {profile.get("track","")} Β· {profile.get("grade","")}' | |
| f'</div><div style="font-size:13px;color:#86868b;margin-top:3px;">' | |
| f'κ΄μ¬μ¬: {", ".join(profile.get("interests",[])) or "μμ"}' | |
| f'</div></div></div>', | |
| unsafe_allow_html=True | |
| ) | |
| col_l, col_c, col_r = st.columns([2, 1.5, 2]) | |
| with col_c: | |
| btn_rec = st.button("λ§μΆ€ κ³΅μ§ μΆμ²λ°κΈ°", type="primary", use_container_width=True) | |
| if btn_rec: | |
| scholarship_results = [] | |
| scholarship_related = [] | |
| dorm_results = [] | |
| rotc_results = [] | |
| interests = profile.get('interests', []) | |
| # ββ Two-Tower μΆμ² (νν°λ§ μΉ΄ν κ³ λ¦¬ μ μΈ) βββββββββββββ | |
| FILTER_CATS = ["μ₯νκΈ", "κΈ°μμ¬", "ROTC"] | |
| rec_interests = [i for i in interests if i not in FILTER_CATS] | |
| with st.spinner("μΆμ² μ€..."): | |
| recs = two_tower_recommend( | |
| college = profile.get('college', ''), | |
| track = profile.get('track', ''), | |
| year = profile.get('grade', ''), | |
| interests = rec_interests, | |
| top_k = 10, | |
| ) if rec_interests else [] | |
| # ββ μ₯νκΈ νν°λ§ ββββββββββββββββββββββββββββββββββββββ | |
| if "μ₯νκΈ" in interests: | |
| with st.spinner("μ₯νκΈ νν°λ§ μ€..."): | |
| scholarship_results, scholarship_related = filter_scholarships(profile) | |
| # ββ κΈ°μμ¬ νν°λ§ ββββββββββββββββββββββββββββββββββββββ | |
| if "κΈ°μμ¬" in interests: | |
| with st.spinner("κΈ°μμ¬ νν°λ§ μ€..."): | |
| dorm_results = filter_dormitory(profile) | |
| # ββ ROTC νν°λ§ ββββββββββββββββββββββββββββββββββββββββ | |
| if "ROTC" in interests and profile.get('rotc_interest', False): | |
| with st.spinner("ROTC κ³΅μ§ λ‘λ© μ€..."): | |
| rotc_results = filter_rotc() | |
| total = len(recs) + len(scholarship_results) + len(scholarship_related) + len(dorm_results) + len(rotc_results) | |
| if total == 0: | |
| st.info("μΆμ² κ²°κ³Όκ° μμ΅λλ€.") | |
| return | |
| # ββ λ§μΆ€ μΆμ² μΆλ ₯ βββββββββββββββββββββββββββββββββββββ | |
| if recs: | |
| st.markdown(f"<div style='font-size:14px;font-weight:600;color:#1d1d1f;margin:16px 0 8px;'>π― λ§μΆ€ μΆμ² κ³΅μ§ ({len(recs)}κ°)</div>", unsafe_allow_html=True) | |
| for rec in recs: | |
| render_notice_card(rec['notice']) | |
| # ββ μ₯νκΈ μ μ² κ³΅μ§ βββββββββββββββββββββββββββββββββββ | |
| if scholarship_results: | |
| st.markdown(f"<div style='font-size:14px;font-weight:600;color:#1d1d1f;margin:16px 0 8px;'>π° μ μ² κ°λ₯ν μ₯νκΈ ({len(scholarship_results)}κ°)</div>", unsafe_allow_html=True) | |
| for notice in scholarship_results: | |
| render_notice_card(notice) | |
| # ββ μ₯νκΈ κ΄λ ¨ κ³΅μ§ βββββββββββββββββββββββββββββββββββ | |
| if scholarship_related: | |
| st.markdown(f"<div style='font-size:14px;font-weight:600;color:#1d1d1f;margin:16px 0 8px;'>π’ μ₯νκΈ κ΄λ ¨ κ³΅μ§ ({len(scholarship_related)}κ°)</div>", unsafe_allow_html=True) | |
| for notice in scholarship_related: | |
| render_notice_card(notice) | |
| # ββ κΈ°μμ¬ μΆλ ₯ ββββββββββββββββββββββββββββββββββββββββ | |
| if dorm_results: | |
| st.markdown(f"<div style='font-size:14px;font-weight:600;color:#1d1d1f;margin:16px 0 8px;'>π κΈ°μμ¬ ({len(dorm_results)}κ°)</div>", unsafe_allow_html=True) | |
| for notice in dorm_results: | |
| render_notice_card(notice) | |
| # ββ ROTC μΆλ ₯ ββββββββββββββββββββββββββββββββββββββββββ | |
| if rotc_results: | |
| st.markdown(f"<div style='font-size:14px;font-weight:600;color:#1d1d1f;margin:16px 0 8px;'>ποΈ ROTC ({len(rotc_results)}κ°)</div>", unsafe_allow_html=True) | |
| for notice in rotc_results: | |
| render_notice_card(notice) | |
| # ============================================================ | |
| # μνΈλ¦¬ν¬μΈνΈ | |
| # ============================================================ | |
| def main(): | |
| from PIL import Image | |
| try: hsu_icon = Image.open(os.path.join(_BASE_DIR, "hsu.png")) | |
| except: hsu_icon = "π" | |
| st.set_page_config(page_title="μμνμΈλ", page_icon=hsu_icon, layout="wide", initial_sidebar_state="expanded") | |
| if "chat_history" not in st.session_state: st.session_state.chat_history = [] | |
| if "onboarded" not in st.session_state: | |
| st.session_state.profile = {} | |
| st.session_state.onboarded = False | |
| if not st.session_state.onboarded: | |
| render_onboarding(); return | |
| st.markdown(GLOBAL_CSS, unsafe_allow_html=True) | |
| profile = st.session_state.profile | |
| render_sidebar(profile) | |
| tab_chat, tab_rec = st.tabs([" π¬ μ±λ΄ κ²μ ", " β¨ μΆμ² κ²μλ¬Ό "]) | |
| with tab_chat: render_chatbot(profile) | |
| with tab_rec: render_recommend(profile) | |
| if __name__ == "__main__": | |
| main() | |