""" 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)) @asynccontextmanager 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=["*"]) @app.exception_handler(ApiError) 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") @app.get("/health") async def health(): # versions (28 ต.ค.): ฟิลด์เพิ่ม — แคตตาล็อกตัวไหน · ล็อกโมเดลกี่ตัว · ข้อมูลตรง DATA_MANIFEST ไหม return {"status": _status(), "bot_ready": bot.ready, "sessions": len(sessions), "versions": V.summary(bot.versions)} @app.get("/health/detail") 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 @app.post("/api/chat") 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)} @app.post("/api/session/reset") 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} @app.get("/media/{token}") 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()