Download app.py from SupantBT/Beltbot: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/spaces/SupantBT/Beltbot/resolve/main/app.py
- Command line
-
hf download hf://spaces/SupantBT/Beltbot/app.py
-
curl -L -o app.py https://huggingface.co/spaces/SupantBT/Beltbot/resolve/main/app.py
14.6 kB
| """ | |
| app.py — FastAPI สำหรับ Hugging Face Spaces (Docker, พอร์ต 7860) | |
| ครอบ chat_interaction() ของ chatbot_v5 เดิม ไม่แก้ gate / dialogue | |
| หน้าเว็บ Next.js บน Vercel เรียกผ่าน /api/chat ของตัวเอง (proxy) ซึ่งแนบ PROXY_SECRET และ IP จริงของผู้ใช้ | |
| Endpoints | |
| GET /health สถานะ (บอทโหลดเสร็จหรือยัง) + เวอร์ชันแบบสั้น — ใช้กับ uptime check | |
| GET /health/detail เวอร์ชันข้อมูล/โมเดลเต็ม (ต้องมี X-Proxy-Secret เมื่อตั้ง PROXY_SECRET) | |
| POST /api/chat multipart: text, image (ไม่บังคับ), session_id (ไม่บังคับ), | |
| resume (ไม่บังคับ — JSON ประวัติจากเบราว์เซอร์ ใช้เมื่อ session หาย) | |
| POST /api/session/reset form: session_id → เริ่มบทสนทนาใหม่ | |
| GET /media/{token} รูป drawing ที่บอทส่งกลับ (เฉพาะไฟล์ใต้ MEDIA_ROOTS) | |
| รันในเครื่อง: uvicorn app:app --port 7860 | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import hmac | |
| import logging | |
| import os | |
| import threading | |
| import time | |
| from contextlib import asynccontextmanager | |
| from typing import Optional | |
| import json | |
| from fastapi import Depends, FastAPI, File, Form, Request, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from starlette.concurrency import run_in_threadpool | |
| from core import metrics as M | |
| from webapi import versions as V | |
| from webapi.bot import BotAdapter, MediaRegistry, SessionStore, to_web_messages | |
| from webapi.image_guard import ImageRejected, load_image, read_limited | |
| from webapi.ratelimit import MESSAGES_TH, RateLimiter | |
| from webapi.settings import Settings, settings as default_settings | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") | |
| log = logging.getLogger("beltbot") | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| class ApiError(Exception): | |
| def __init__(self, status: int, code: str, message_th: str, retry_after: int = 0): | |
| self.status, self.code, self.message_th, self.retry_after = status, code, message_th, retry_after | |
| def create_app(cfg: Settings = default_settings, bot: Optional[BotAdapter] = None) -> FastAPI: | |
| bot = bot or BotAdapter(cfg.bot_module) | |
| sessions = SessionStore(cfg.session_ttl_s, cfg.max_sessions) | |
| limiter = RateLimiter(cfg.rl_per_minute, cfg.rl_per_hour, cfg.rl_images_per_hour, cfg.global_per_day) | |
| media = MediaRegistry(cfg.media_roots, BASE_DIR) | |
| gate = asyncio.Semaphore(max(1, cfg.max_concurrent)) | |
| async def lifespan(app: FastAPI): | |
| if cfg.warm_on_start and not bot.ready: | |
| threading.Thread(target=_safe_load, args=(bot,), daemon=True).start() | |
| task = asyncio.create_task(_janitor(sessions, limiter)) | |
| yield | |
| task.cancel() | |
| app = FastAPI(title="Movex / Modutech assistant", lifespan=lifespan, docs_url=None, redoc_url=None) | |
| app.state.bot, app.state.sessions, app.state.limiter, app.state.media = bot, sessions, limiter, media | |
| if cfg.allowed_origins: | |
| app.add_middleware(CORSMiddleware, allow_origins=cfg.allowed_origins, | |
| allow_methods=["GET", "POST"], allow_headers=["*"]) | |
| async def _api_error(_: Request, e: ApiError): | |
| headers = {"Retry-After": str(e.retry_after)} if e.retry_after else None | |
| return JSONResponse({"error": e.code, "message_th": e.message_th}, status_code=e.status, headers=headers) | |
| # ── การยืนยันว่ามาจาก proxy ของเรา + หา IP จริง ── | |
| def client_ip(request: Request) -> str: | |
| if cfg.proxy_secret: | |
| sent = request.headers.get("x-proxy-secret", "") | |
| if not hmac.compare_digest(sent.encode(), cfg.proxy_secret.encode()): | |
| raise ApiError(403, "forbidden", "ไม่อนุญาต") | |
| ip = request.headers.get("x-client-ip", "").strip() | |
| if ip: | |
| return ip | |
| fwd = request.headers.get("x-forwarded-for", "") | |
| if fwd: | |
| return fwd.split(",")[0].strip() | |
| return request.client.host if request.client else "unknown" | |
| def _status() -> str: | |
| return "ok" if bot.ready else ("error" if bot.load_error else "loading") | |
| async def health(): | |
| # versions (28 ต.ค.): ฟิลด์เพิ่ม — แคตตาล็อกตัวไหน · ล็อกโมเดลกี่ตัว · ข้อมูลตรง DATA_MANIFEST ไหม | |
| return {"status": _status(), "bot_ready": bot.ready, "sessions": len(sessions), | |
| "versions": V.summary(bot.versions)} | |
| async def health_detail(request: Request): | |
| # ชื่อ collection · hash · commit ของโมเดล — ไม่ใช่ความลับร้ายแรง แต่ไม่ต้องเปิดให้ทุกคนเห็น | |
| if cfg.proxy_secret: | |
| sent = request.headers.get("x-proxy-secret", "") | |
| if not hmac.compare_digest(sent.encode(), cfg.proxy_secret.encode()): | |
| raise ApiError(403, "forbidden", "ไม่อนุญาต") | |
| return {"status": _status(), **(bot.versions or {})} | |
| # ขนาดสูงสุดของ resume — 8 เทิร์น × 1200 ตัวอักษร × 2 ฝ่าย บวกเผื่อ | |
| MAX_RESUME_BYTES = 48 * 1024 | |
| async def chat(text: str = Form(""), session_id: str = Form(""), resume: str = Form(""), | |
| image: Optional[UploadFile] = File(None), ip: str = Depends(client_ip)): | |
| # METRIC src=api (28 ต.ค.): ทุกคำขอ รวมที่ถูกปฏิเสธก่อนถึงบอท (rate limit · warming_up · busy · รูปเสีย) | |
| # ไม่มี IP ไม่มีข้อความ — code · สถานะ HTTP · มีรูปไหม · เวลา | |
| t0 = time.perf_counter() | |
| code, http = "ok", 200 | |
| try: | |
| return await _chat(text, session_id, resume, image, ip) | |
| except ApiError as e: | |
| code, http = e.code, e.status | |
| raise | |
| except Exception: | |
| code, http = "exception", 500 | |
| raise | |
| finally: | |
| M.emit({"src": "api", "code": code, "http": http, | |
| "image": image is not None and bool(image.filename or image.size), | |
| "ms": round((time.perf_counter() - t0) * 1000)}) | |
| async def _chat(text: str, session_id: str, resume: str, image: Optional[UploadFile], ip: str): | |
| text = (text or "").strip() | |
| has_image = image is not None and bool(image.filename or image.size) | |
| if not text and not has_image: | |
| raise ApiError(400, "empty", "พิมพ์คำถามหรือแนบรูปก่อนส่งครับ") | |
| if len(text) > cfg.max_text_chars: | |
| raise ApiError(400, "text_too_long", f"ข้อความยาวเกิน {cfg.max_text_chars} ตัวอักษรครับ รบกวนแบ่งถาม") | |
| if not bot.ready: | |
| if bot.load_error: | |
| raise ApiError(503, "bot_error", "ระบบขัดข้องชั่วคราวครับ รบกวนติดต่อฝ่ายขายโดยตรง") | |
| raise ApiError(503, "warming_up", "ระบบกำลังเริ่มต้น (โหลดโมเดล) รบกวนรอสักครู่แล้วส่งใหม่ครับ", 15) | |
| verdict = limiter.check(ip, has_image) | |
| if not verdict.ok: | |
| raise ApiError(429, verdict.reason, MESSAGES_TH[verdict.reason], verdict.retry_after_s) | |
| pil = None | |
| if has_image: | |
| try: | |
| raw = await read_limited(image, cfg.max_image_bytes) | |
| pil = (await run_in_threadpool(load_image, raw, cfg.max_image_bytes, | |
| cfg.max_image_pixels, cfg.image_max_side)).image | |
| except ImageRejected as e: | |
| raise ApiError(400, f"image_{e.code}", e.message_th) | |
| s, _new = sessions.get_or_create(session_id) | |
| # ── กู้บทสนทนา ── | |
| # ทำเฉพาะเมื่อ session เพิ่งถูกสร้าง เพราะถ้า session ยังอยู่ ของฝั่งเซิร์ฟเวอร์ | |
| # ใหม่กว่าของในเบราว์เซอร์เสมอ เอา resume มาทับจะย้อนบทสนทนากลับไป | |
| if _new and resume and s.turns == 0: | |
| if len(resume.encode("utf-8", "ignore")) > MAX_RESUME_BYTES: | |
| raise ApiError(400, "resume_too_large", "ประวัติที่ส่งมายาวเกินครับ กด “แชตใหม่” แล้วถามอีกครั้ง") | |
| try: | |
| payload = json.loads(resume) | |
| except (json.JSONDecodeError, TypeError, ValueError): | |
| payload = None | |
| if isinstance(payload, dict): | |
| try: | |
| info = await run_in_threadpool(bot.seed_session, s, payload) | |
| log.info("resume session=%s ข้อความ %d สถานะ %s", | |
| s.id[:8], info["messages"], "กู้ได้" if info["state"] else "ไม่มี") | |
| except Exception: | |
| log.exception("กู้บทสนทนาไม่สำเร็จ session=%s — เริ่มใหม่แทน", s.id) | |
| s.history, s.bot_session_id = [], "" | |
| if s.turns >= cfg.max_turns: | |
| raise ApiError(409, "session_full", "บทสนทนานี้ยาวมากแล้วครับ กด “เริ่มแชตใหม่” เพื่อถามต่อ") | |
| if not s.lock.acquire(blocking=False): | |
| raise ApiError(409, "busy_session", "กำลังตอบข้อความก่อนหน้าอยู่ครับ รอสักครู่") | |
| try: | |
| try: | |
| await asyncio.wait_for(gate.acquire(), timeout=cfg.queue_wait_s) | |
| except asyncio.TimeoutError: | |
| raise ApiError(503, "busy", "ตอนนี้มีผู้ใช้งานพร้อมกันหลายคนครับ รบกวนส่งใหม่อีกครั้ง", 10) | |
| t0 = time.perf_counter() | |
| try: | |
| new, decision = await run_in_threadpool(bot.turn, s, text, pil) | |
| except Exception: | |
| log.exception("บอทตอบไม่สำเร็จ session=%s", s.id) | |
| raise ApiError(500, "bot_failed", "ขออภัยครับ ระบบตอบไม่สำเร็จ รบกวนลองใหม่อีกครั้ง") | |
| finally: | |
| gate.release() | |
| ms = round((time.perf_counter() - t0) * 1000) | |
| log.info("turn session=%s ip=%s image=%s decision=%s %dms", s.id[:8], _mask_ip(ip), | |
| has_image, decision or "-", ms) | |
| finally: | |
| s.lock.release() | |
| msgs = to_web_messages(new, media, cfg.scrub_internal_ids) | |
| if not msgs: | |
| msgs = [{"type": "text", "text": "ขออภัยครับ ระบบยังไม่มีคำตอบสำหรับคำถามนี้ รบกวนถามใหม่อีกครั้ง"}] | |
| # state ส่งกลับให้เบราว์เซอร์เก็บ ไว้กู้บทสนทนาเมื่อ session นี้หาย | |
| # ไม่มีข้อมูลส่วนบุคคล มีแค่รหัสรุ่นกับ slot ที่ลูกค้าบอกมาเอง | |
| # suggestions (27 ต.ค.): ปุ่มตอบเร็วของคำถามที่ค้าง — ฟิลด์เพิ่ม ไม่เปลี่ยนฟิลด์เดิม | |
| return {"session_id": s.id, "messages": msgs, | |
| "turns_left": max(0, cfg.max_turns - s.turns), | |
| "state": bot.current_state(s), | |
| "suggestions": bot.suggestions(s)} | |
| async def reset(session_id: str = Form(""), _ip: str = Depends(client_ip)): | |
| if session_id: | |
| sessions.drop(session_id) | |
| s, _ = sessions.get_or_create(None) | |
| return {"session_id": s.id} | |
| async def media_file(token: str, _ip: str = Depends(client_ip)): | |
| path = media.resolve(token) | |
| if not path or not os.path.isfile(path): | |
| raise ApiError(404, "not_found", "ไม่พบไฟล์") | |
| return FileResponse(path, headers={"Cache-Control": "public, max-age=86400"}) | |
| return app | |
| def _safe_load(bot: BotAdapter) -> None: | |
| try: | |
| bot.load() | |
| except Exception: | |
| pass # log แล้วใน bot.load — /health จะแสดง error | |
| async def _janitor(sessions: SessionStore, limiter: RateLimiter) -> None: | |
| while True: | |
| await asyncio.sleep(300) | |
| sessions.sweep() | |
| limiter.sweep() | |
| def _mask_ip(ip: str) -> str: | |
| """ไม่เก็บ IP เต็มใน log""" | |
| if "." in ip: | |
| return ".".join(ip.split(".")[:2] + ["x", "x"]) | |
| return ip[:9] + "…" if ":" in ip else ip | |
| app = create_app() | |