# app.py # ----------------------------- # HF Router Inference API (OpenAI-compatible) # Toss-like UI + "SW 미래 영재학원" theme # Tabs: 예약·추천 / 질문(채팅) / 학원 소개 / 상태 점검 # ----------------------------- import os, json, time, requests, gradio as gr from typing import List, Tuple, Dict, Any from datetime import datetime, timedelta # ========================= # Config # ========================= API_BASE = os.getenv("HF_ENDPOINT_URL") or "https://router.huggingface.co/v1" # OpenAI-compatible Router MODEL_QA = os.getenv("HF_MODEL_ID") or "Qwen/Qwen2.5-7B-Instruct" # Provider-attached model HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") TIMEOUT_S = 60 # 예약 설정 OPEN_DAYS = [0, 1, 2, 3, 4, 5] # Mon(0) ~ Sat(5), Sun(6) off OPEN_TIMES = ["15:00", "17:00", "19:00"] # 3 slots/day SLOT_DAYS_AHEAD = 14 # next 14 days RESV_FILE = "reservations.json" # local JSON store KWDAYS = ["월", "화", "수", "목", "금", "토", "일"] # ========================= # HF Router caller # ========================= def hf_chat(model: str, messages: list, temperature: float = 0.2, max_tokens: int = 256, timeout: int = TIMEOUT_S, retries: int = 3) -> str: if not HF_TOKEN: return "⚠️ HF_TOKEN이 없습니다. 환경변수에 HF_TOKEN을 설정하세요." headers = {"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"} url = f"{API_BASE}/chat/completions" payload = { "model": model, "messages": messages, "temperature": float(temperature), "max_tokens": int(max_tokens), } backoff = 1.5 for attempt in range(retries): try: r = requests.post(url, headers=headers, data=json.dumps(payload), timeout=timeout) if r.status_code in (429, 500, 502, 503, 504, 529): time.sleep(backoff ** (attempt + 1)) continue if not r.ok: return f"❌ HF Router 오류({r.status_code}): {r.text[:600]}" data = r.json() return (data["choices"][0]["message"]["content"] or "").strip() except Exception: time.sleep(backoff ** (attempt + 1)) return "❌ 요청 실패(네트워크/타임아웃). 잠시 후 다시 시도하세요." # ========================= # Reservations: storage helpers # ========================= def load_reservations() -> List[Dict[str, Any]]: if not os.path.exists(RESV_FILE): return [] try: with open(RESV_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: return [] def save_reservations(resv: List[Dict[str, Any]]) -> None: tmp = RESV_FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(resv, f, ensure_ascii=False, indent=2) os.replace(tmp, RESV_FILE) def make_slots(start: datetime | None = None, days: int = SLOT_DAYS_AHEAD) -> List[str]: now = datetime.now() base = start or now out = [] for i in range(days + 1): d = base + timedelta(days=i) if d.weekday() in OPEN_DAYS: for t in OPEN_TIMES: label = f"{d.strftime('%Y-%m-%d')} {t} ({KWDAYS[d.weekday()]})" out.append(label) return out def booked_slots() -> set: return {item["slot"] for item in load_reservations()} def available_slots() -> List[str]: bset = booked_slots() return [s for s in make_slots() if s not in bset] def reservation_rows(resv: List[Dict[str, Any]]) -> List[List[str]]: rows = [] for r in sorted(resv, key=lambda x: x.get("slot", "")): rows.append([ r.get("slot", ""), r.get("name", ""), r.get("grade", ""), r.get("focus", ""), r.get("level", ""), r.get("contact", ""), r.get("notes", ""), r.get("created_at", ""), ]) return rows # ========================= # Recommendation # ========================= def recommend_plan(grade: str, focuses: List[str], level: str, goals: str, weeks: int, hours_per_week: int, constraints: str, temperature: float, max_tokens: int) -> str: focus_str = ", ".join(focuses) if focuses else "일반" user_profile = ( f"- 학년: {grade}\n- 관심 분야: {focus_str}\n- 수준: {level}\n" f"- 목표: {goals or '미정'}\n- 기간: {weeks}주\n- 주당 시간: {hours_per_week}시간\n" f"- 제약: {constraints or '없음'}" ) system = ( "당신은 학원 상담/커리큘럼 설계 전문가입니다. " "학생과 학부모가 바로 실행 가능한 계획을 제시하세요. " "항상 주차별(Week 1~N) 로드맵, 과제/성과물, 추천 교재/도구, 평가 포인트를 짧고 명확하게." ) user_prompt = ( "아래 정보를 바탕으로 최적의 학습 추천안을 만들어 주세요.\n\n" + user_profile + "\n\n형식:\n" "1) 한 줄 요약\n" "2) 주차별 계획(주차/학습목표/활동/과제)\n" "3) 추천 교재/툴(간단 링크명만, 실제 URL 생략)\n" "4) 예상 결과물(포트폴리오/대회/자격증)\n" "5) 다음 예약 제안(체험 수업 1회 + 정규 과정)\n" "문장 짧게. 불필요한 수식어 금지." ) msgs = [ {"role": "system", "content": system}, {"role": "user", "content": user_prompt}, ] out = hf_chat(MODEL_QA, msgs, temperature=temperature, max_tokens=max_tokens) if out.startswith("❌") or out.startswith("⚠️"): rec = f"""[간단 추천안 — Fallback] 한 줄 요약: {grade} 대상 {focus_str} {level} 과정, {weeks}주, 주 {hours_per_week}시간. 주차별: - Week 1: 환경 설정/기초 문법 — 과제: 설치/HelloWorld/간단 문제 3개 - Week 2: 핵심 개념 익히기 — 과제: {('센서 제어 실습' if '아두이노' in focus_str else '미니 프로젝트 1')} - Week 3: 주제 심화 — 과제: 미니 프로젝트 2 - Week 4: 종합 프로젝트 — 과제: 발표 자료/포트폴리오 정리 추천 도구: VSCode, GitHub(코드/보고서), {('Arduino IDE' if '아두이노' in focus_str else 'Notion')} 예상 결과물: 프로젝트 1~2개, 포트폴리오 페이지 다음 예약 제안: 체험 1회(60분) 후 주 {hours_per_week}시간 정규반 등록 """ return rec.strip() return out # ========================= # Chat helpers # ========================= def history_to_messages(history: List[Tuple[str, str]], user_text: str) -> list: msgs = [ {"role": "system", "content": ( "당신은 간결하고 정확한 한국어 조수입니다. 불필요한 수식어 없이 핵심만 답하세요. " "학생(초/중/고)과 학부모가 이해하기 쉽게 단계별로 설명하고, 필요시 짧은 예시를 들어주세요." )}, ] for u, a in history: if u: msgs.append({"role": "user", "content": u}) if a: msgs.append({"role": "assistant", "content": a}) if user_text: msgs.append({"role": "user", "content": user_text}) return msgs def chat_qa(user_input: str, history: List[Tuple[str, str]], temperature: float, max_new_tokens: int): user_input = (user_input or "").strip() if not user_input: return history, "", history msgs = history_to_messages(history, user_input) answer = hf_chat(MODEL_QA, msgs, temperature=temperature, max_tokens=max_new_tokens) history = history + [(user_input, answer)] return history, "", history def reset_chat(): return [], [] # ========================= # Booking handlers # ========================= def refresh_slots_update(): return gr.update(choices=available_slots(), value=None) def submit_booking(name: str, grade: str, focuses: List[str], level: str, contact: str, slot: str, notes: str): name = (name or "").strip() contact = (contact or "").strip() slot = (slot or "").strip() focus_str = ", ".join(focuses) if focuses else "일반" if not name: return "⚠️ 이름을 입력하세요.", reservation_rows(load_reservations()), refresh_slots_update() if not contact: return "⚠️ 연락처(전화/카카오/이메일) 입력하세요.", reservation_rows(load_reservations()), refresh_slots_update() if not slot: return "⚠️ 예약 슬롯을 선택하세요.", reservation_rows(load_reservations()), refresh_slots_update() if slot in booked_slots(): return "❌ 이미 예약된 슬롯입니다. 다른 시간을 선택하세요.", reservation_rows(load_reservations()), refresh_slots_update() resv = load_reservations() item = { "name": name, "grade": grade, "focus": focus_str, "level": level, "contact": contact, "slot": slot, "notes": (notes or "").strip(), "created_at": datetime.now().strftime("%Y-%m-%d %H:%M"), } resv.append(item) save_reservations(resv) return (f"✅ 예약 완료: {slot} · {name} ({grade}, {focus_str}/{level}) — 담당자가 곧 연락드립니다.", reservation_rows(resv), refresh_slots_update()) # ========================= # Health check # ========================= def diagnose() -> str: if not HF_TOKEN: return "HF_TOKEN 없음" try: r = requests.get(f"{API_BASE}/models", headers={"Authorization": f"Bearer {HF_TOKEN}"}, timeout=20) head = f"GET /models -> {r.status_code}" body = r.text[:1000] return f"{head}\n{body}" except Exception as e: return f"요청 실패: {e}" # ========================= # UI (Toss-like) # ========================= CSS = """ :root { --primary: #0064ff; --bg: #ffffff; --card: #ffffff; --text: #0b1020; --muted: #6b7280; --stroke: #e6ecf5; --radius: 16px; } .gradio-container { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; background: var(--bg); } .toss-header { padding: 28px 24px; border-radius: 24px; background: linear-gradient(135deg, #eaf2ff 0%, #f6faff 100%); border: 1px solid #e5efff; margin-bottom: 12px; } .toss-title { font-size: 28px; font-weight: 800; color: var(--text); letter-spacing: -0.02em; margin: 0; } .toss-sub { color: var(--muted); margin-top: 6px; font-size: 14px; } .toss-card { background: var(--card); border: 1px solid var(--stroke); border-radius: var(--radius); box-shadow: 0 8px 24px rgba(15,23,42,0.06); padding: 18px; } .toss-primary { background: var(--primary) !important; color: white !important; border-radius: 12px !important; font-weight: 700 !important; } .toss-input textarea, .toss-input input, .toss-input select { border-radius: 14px !important; } .toss-note { color: var(--muted); font-size: 12px; } footer { display: none !important; } label { font-weight: 700 !important; } .gr-chatbot { border-radius: var(--radius); border: 1px solid var(--stroke); } .gr-chatbot .message { border-radius: 14px !important; } .gr-chatbot .message.user { background: #eef5ff !important; } .gr-chatbot .message.bot { background: #f7f8fb !important; } """ with gr.Blocks(title="율하 SW미래영재컴퓨터학원 — 예약·추천·Q&A (HF Router)", css=CSS, theme=gr.themes.Soft()) as demo: with gr.Column(): gr.HTML(f"""
{API_BASE} · 모델: {MODEL_QA}
HF_TOKEN (필수), '
'HF_ENDPOINT_URL / HF_MODEL_ID (선택)