sangsangfinder / app.py
cksleigen's picture
Initial clean deploy
54656fc
Raw
History Blame Contribute Delete
53.8 kB
# ============================================================
# 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;">"μž₯ν•™κΈˆ μ‹ μ²­ κΈ°κ°„ μ•Œλ €μ€˜" &nbsp;Β·&nbsp; "μ·¨μ—…λ°•λžŒνšŒ μ–Έμ œμ•Ό?" &nbsp;Β·&nbsp; "비ꡐ과 ν”„λ‘œκ·Έλž¨ μΆ”μ²œν•΄μ€˜"</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>&nbsp;'
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","")} &nbsp;Β·&nbsp; {profile.get("track","")} &nbsp;Β·&nbsp; {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()