# 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"""
율하 SW미래영재컴퓨터학원 — 예약·추천·Q&A
초/중/고 맞춤형 AI·코딩 — Python · C · 아두이노 · 웹개발 · 영상편집 · ITQ/GTQ · AI/데이터 · 경진대회
엔드포인트: {API_BASE} · 모델: {MODEL_QA}
""") with gr.Tabs(): # 1) 예약·추천 with gr.Tab("예약 · 추천"): with gr.Row(): # Left: Recommendation with gr.Column(scale=5): with gr.Group(elem_classes=["toss-card", "toss-input"]): gr.Markdown("### 맞춤 추천") grade = gr.Radio(["초등", "중등", "고등", "일반"], value="중등", label="학년") focus = gr.CheckboxGroup( ["파이썬", "C", "아두이노", "웹개발", "영상편집", "코딩자격증(ITQ/GTQ)", "AI/데이터", "경진대회"], label="관심 분야", value=["파이썬"] ) level = gr.Radio(["입문", "기초", "중급", "심화"], value="기초", label="수준") goals = gr.Textbox(label="목표(예: 대회 입상/자격증/포트폴리오/수행평가 등)", lines=2) with gr.Row(): weeks = gr.Slider(2, 16, value=8, step=1, label="기간(주)") hpw = gr.Slider(1, 6, value=2, step=1, label="주당 시간(시간)") constraints = gr.Textbox(label="제약(시간/예산/진도 등)", lines=1, placeholder="예: 평일 저녁만 가능") with gr.Row(): r_temp = gr.Slider(0.0, 1.0, value=0.2, step=0.05, label="temperature") r_max = gr.Slider(64, 1024, value=320, step=32, label="max_tokens") btn_rec = gr.Button("학습 추천 받기", elem_classes=["toss-primary"]) with gr.Group(elem_classes=["toss-card"]): rec_out = gr.Textbox(label="추천 결과", lines=18) # Right: Booking with gr.Column(scale=4): with gr.Group(elem_classes=["toss-card", "toss-input"]): gr.Markdown("### 예약") name = gr.Textbox(label="학생 이름", placeholder="예: 홍길동") contact = gr.Textbox(label="연락처", placeholder="전화/카카오/이메일") b_grade = gr.Dropdown(["초등", "중등", "고등", "일반"], value="중등", label="학년") b_focus = gr.CheckboxGroup( ["파이썬", "C", "아두이노", "웹개발", "영상편집", "코딩자격증(ITQ/GTQ)", "AI/데이터", "경진대회"], label="관심 분야", value=["파이썬"] ) b_level = gr.Dropdown(["입문", "기초", "중급", "심화"], value="기초", label="레벨") slot = gr.Dropdown(choices=available_slots(), label="예약 슬롯(월~토, 15/17/19시)") notes = gr.Textbox(label="비고(선호 주제/특이사항)", lines=2) with gr.Row(): btn_refresh = gr.Button("슬롯 새로고침") btn_book = gr.Button("예약 확정", elem_classes=["toss-primary"]) with gr.Group(elem_classes=["toss-card"]): gr.Markdown("### 현재 예약 현황") table = gr.Dataframe( headers=["슬롯", "이름", "학년", "관심 분야", "레벨", "연락처", "비고", "예약시각"], value=reservation_rows(load_reservations()), interactive=False, ) res_msg = gr.Markdown("", elem_classes=["toss-note"]) # Recommend btn_rec.click( fn=recommend_plan, inputs=[grade, focus, level, goals, weeks, hpw, constraints, r_temp, r_max], outputs=[rec_out], ) # Refresh slots btn_refresh.click(fn=refresh_slots_update, outputs=[slot]) # Booking btn_book.click( fn=submit_booking, inputs=[name, b_grade, b_focus, b_level, contact, slot, notes], outputs=[res_msg, table, slot], ) # 2) 질문하기 (채팅) with gr.Tab("질문하기 (채팅)"): chat_history = gr.State([]) # list of (user, assistant) with gr.Row(): with gr.Column(scale=5): chat = gr.Chatbot(label="대화", type="tuples", height=540, show_label=True) with gr.Column(scale=4): with gr.Group(elem_classes=["toss-card", "toss-input"]): q = gr.Textbox( label="질문 입력", placeholder="예) 초등 파이썬 첫 수업 커리큘럼을 4주로 짜줘", lines=6, ) with gr.Row(): temp = gr.Slider(0.0, 1.0, value=0.2, step=0.05, label="temperature") max_tok = gr.Slider(64, 1024, value=256, step=32, label="max_tokens") with gr.Row(): btn_send = gr.Button("답변 생성", size="lg", elem_classes=["toss-primary"]) btn_clear = gr.Button("새 대화", size="lg") with gr.Group(elem_classes=["toss-card"]): gr.Markdown("**빠른 입력(샘플 질문)**") with gr.Row(): b1 = gr.Button("초등 파이썬 4주 커리큘럼") b2 = gr.Button("중등 아두이노 프로젝트 아이디어 5개") with gr.Row(): b3 = gr.Button("고등 AI 경진대회 대비 로드맵") b4 = gr.Button("코딩자격증(ITQ/GTQ) 단기 합격 전략") b1.click(lambda: "초등 파이썬 첫 수업부터 4주 커리큘럼을 주차별 목표/교재/과제로 정리해줘.", outputs=q) b2.click(lambda: "중학생 수준에서 가능한 아두이노 프로젝트 아이디어 5개를 난이도/부품/학습목표와 함께 표로 정리해줘.", outputs=q) b3.click(lambda: "고등학생 기준 AI/데이터 경진대회 대비 로드맵을 8주 플랜으로 상세히 만들어줘.", outputs=q) b4.click(lambda: "ITQ(한글/엑셀)과 GTQ 포토샵 단기 합격 전략을 주차별 학습계획과 기출 포인트로 요약해줘.", outputs=q) btn_send.click(fn=chat_qa, inputs=[q, chat_history, temp, max_tok], outputs=[chat, q, chat_history]) q.submit(fn=chat_qa, inputs=[q, chat_history, temp, max_tok], outputs=[chat, q, chat_history]) btn_clear.click(fn=reset_chat, outputs=[chat, chat_history]) gr.Markdown('
• 대화 기록은 위 채팅 영역에 순서대로 누적됩니다.' '
• 과금 주의: Router 사용량/토큰에 따라 비용이 발생할 수 있습니다.
') # 3) 학원 소개 with gr.Tab("학원 소개"): with gr.Group(elem_classes=["toss-card"]): gr.Markdown(""" ### 왜 율하 SW미래영재컴퓨터학원인가? - **실전 중심 커리큘럼**: 파이썬·C·아두이노·웹·영상편집까지 프로젝트로 배우는 과정 - **맞춤형 지도**: 초/중/고 학년·수준·진로에 맞춘 개별 플랜 - **실적 지향**: 코딩 자격증(ITQ/GTQ 등)·교내외 대회·포트폴리오 준비 ### 추천 트랙 1) **기초 다지기**: 파이썬 기초·문제해결·알고리즘 체험 2) **메이킹/IoT**: 아두이노·센서·간단한 자동화 프로젝트 3) **미디어/디자인**: 프리미어·포토샵·콘텐츠 제작 4) **심화/경진대회**: 데이터·AI 기초, 대회 준비 및 작품 완성 > 상담/체험 수업 문의는 '예약 · 추천' 탭에서 진행하세요. """) # 4) 상태 점검 with gr.Tab("상태 점검"): with gr.Group(elem_classes=["toss-card"]): diag_btn = gr.Button("엔드포인트 점검", elem_classes=["toss-primary"]) diag_out = gr.Textbox(label="결과", lines=16) diag_btn.click(fn=diagnose, outputs=diag_out) gr.Markdown('
환경변수: HF_TOKEN (필수), ' 'HF_ENDPOINT_URL / HF_MODEL_ID (선택)
') if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860, share=True)