Spaces:
Running on Zero
Running on Zero
| # -*- coding: utf-8 -*- | |
| """ | |
| Sentiment Mini API — เวอร์ชัน Gradio SDK (ฟรีบน Hugging Face Spaces) — แก้ปัญหา address already in use | |
| ========================================================================================================== | |
| รอบก่อนหน้าเราลอง `gr.mount_gradio_app()` + เรียก `uvicorn.run()` เอง ซึ่งไปชนกับกลไกภายในที่ HF Spaces | |
| (Gradio SDK) ใช้จัดการพอร์ต 7860 เอง ทำให้ bind ไม่ได้ ("address already in use") | |
| รอบนี้แก้โดยใช้วิธีที่ Gradio "การันตี" ว่าใช้ได้บน HF Spaces เสมอ คือเรียก demo.launch() แบบมาตรฐาน | |
| แล้วค่อย "แซะ" custom REST route (/api/sentiment/analyze, /webhook) ต่อท้ายเข้าไปใน FastAPI app | |
| ที่ Gradio สร้างขึ้นเองหลังจาก launch() (ผ่าน demo.app) — เป็นเทคนิคที่ Gradio รองรับอย่างเป็นทางการ | |
| สำหรับกรณีที่ต้องการเพิ่ม route กำหนดเองบน Gradio Space | |
| ผลลัพธ์: | |
| - GET / -> หน้าเว็บ Gradio Demo (ทดลองพิมพ์ข้อความดูผล sentiment) | |
| - GET /api/health -> health check แบบ JSON (ย้ายมาจาก "/" เดิม เพราะ "/" ถูก Gradio UI ใช้) | |
| - POST /api/sentiment/analyze -> REST endpoint เดิมทุกประการ ยิง POST ตรงได้เหมือน Docker เวอร์ชัน | |
| - POST /webhook -> LINE webhook เดิมทุกประการ | |
| """ | |
| import os | |
| import time | |
| import hmac | |
| import hashlib | |
| import base64 | |
| import json | |
| import logging | |
| import threading | |
| from collections import deque | |
| from datetime import datetime, timezone | |
| from urllib.parse import parse_qs | |
| import requests | |
| import gradio as gr | |
| from fastapi import Request | |
| from fastapi.responses import Response | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.pipeline import Pipeline | |
| from sklearn.preprocessing import LabelEncoder | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.metrics import accuracy_score, f1_score | |
| from collections import Counter | |
| # XGBoost เป็น optional — ถ้าติดตั้งไม่ได้/ไม่มี ก็ยังใช้ logreg กับ rf ได้ | |
| try: | |
| from xgboost import XGBClassifier | |
| HAS_XGBOOST = True | |
| except Exception: | |
| HAS_XGBOOST = False | |
| # --------------------------------------------------------------------------- | |
| # รองรับ ZeroGPU hardware ของ HF Spaces | |
| # --------------------------------------------------------------------------- | |
| try: | |
| import spaces # ไลบรารีนี้มีเฉพาะบน HF Spaces เท่านั้น | |
| def _dummy_gpu_probe(): | |
| """ฟังก์ชันหลอกไว้ให้ ZeroGPU ตรวจเจอ ไม่ได้ถูกเรียกใช้จริง""" | |
| return "ok" | |
| except Exception: | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # ตั้งค่าเบื้องต้น | |
| # --------------------------------------------------------------------------- | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger("sentiment-mini-api") | |
| LINE_CHANNEL_SECRET = os.environ.get("LINE_CHANNEL_SECRET", "") | |
| LINE_ACCESS_TOKEN = os.environ.get("LINE_ACCESS_TOKEN", "") | |
| LINE_REPLY_URL = "https://api.line.me/v2/bot/message/reply" | |
| LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push" | |
| LINE_AUTO_REPLY = os.environ.get("LINE_AUTO_REPLY", "0").lower() in ("1", "true", "yes") | |
| DASHBOARD_API_KEY = os.environ.get("DASHBOARD_API_KEY", "") | |
| # --------------------------------------------------------------------------- | |
| # roster แอดมิน — เพราะ LINE API ไม่มีทางดึงรายชื่อสมาชิกกลุ่มอัตโนมัติได้ (ข้อจำกัดด้าน privacy ของ LINE) | |
| # ต้องตั้งค่าล่วงหน้าเป็น "LINE User ID : ชื่อที่จะแสดง" คั่นด้วย , เรียงตามลำดับที่ต้องการหมุนเวียนคิว | |
| # ตัวอย่าง: LINE_ADMIN_ROSTER = "U1234abcd:Patty,U5678efgh:Orange,U9abcijkl:เปรี้ยว,Uxyzmnop:Jumjum" | |
| # วิธีหา User ID ของแต่ละคน: ให้แอดมินทักบอทด้วยคำว่า "รหัสแอดมิน" บอทจะตอบ User ID ของตัวเองกลับมา | |
| # --------------------------------------------------------------------------- | |
| LINE_ADMIN_ROSTER_RAW = os.environ.get("LINE_ADMIN_ROSTER", "") | |
| # กลุ่ม LINE ที่จะโพสการ์ดเสนอ Case เข้าไป (เอา Group ID มาจาก log ตอนบอทถูกเชิญเข้ากลุ่ม หรือข้อความในกลุ่ม) | |
| # ถ้าไม่ตั้งค่าไว้ จะส่งเป็นข้อความส่วนตัว (push) ไปหาแอดมินที่ถูกเสนอเคสโดยตรงแทน | |
| LINE_ADMIN_GROUP_ID = os.environ.get("LINE_ADMIN_GROUP_ID", "") | |
| def _parse_admin_roster(raw): | |
| roster = [] | |
| for pair in raw.split(","): | |
| pair = pair.strip() | |
| if not pair or ":" not in pair: | |
| continue | |
| uid, name = pair.split(":", 1) | |
| uid, name = uid.strip(), name.strip() | |
| if uid and name: | |
| roster.append({"line_user_id": uid, "name": name}) | |
| return roster | |
| ADMIN_ROSTER = _parse_admin_roster(LINE_ADMIN_ROSTER_RAW) # ลำดับหมุนเวียน = ลำดับใน env var | |
| ADMIN_NAME_BY_ID = {a["line_user_id"]: a["name"] for a in ADMIN_ROSTER} | |
| ADMIN_ID_BY_NAME = {a["name"]: a["line_user_id"] for a in ADMIN_ROSTER} | |
| ADMIN_ROSTER_NAMES = [a["name"] for a in ADMIN_ROSTER] # ลำดับชื่อ ใช้โชว์เป็นโซ่หมุนเวียนในการ์ด | |
| def push_line_messages(target_id, messages): | |
| """ส่ง push message (ข้อความ/การ์ด) ไปยัง user/group id ที่ระบุ ผ่าน LINE Push API""" | |
| if not LINE_ACCESS_TOKEN: | |
| logger.warning("ไม่ได้ตั้งค่า LINE_ACCESS_TOKEN จึงส่ง push message ไม่ได้") | |
| return False | |
| if not target_id: | |
| logger.warning("push_line_messages: ไม่มี target_id ให้ส่ง") | |
| return False | |
| headers = {"Content-Type": "application/json", "Authorization": f"Bearer {LINE_ACCESS_TOKEN}"} | |
| payload = {"to": target_id, "messages": messages} | |
| try: | |
| resp = requests.post( | |
| LINE_PUSH_URL, headers=headers, | |
| data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), timeout=10, | |
| ) | |
| if resp.status_code != 200: | |
| logger.warning("LINE push API คืนสถานะผิดพลาด: %s %s", resp.status_code, resp.text) | |
| return False | |
| return True | |
| except requests.RequestException as e: | |
| logger.error("เกิดข้อผิดพลาดขณะเรียก LINE push API: %s", e) | |
| return False | |
| def reply_line_messages(reply_token, messages): | |
| """ตอบกลับด้วย reply token (ใช้ตอบ postback event ได้ด้วย ไม่ใช่แค่ข้อความ)""" | |
| if not LINE_ACCESS_TOKEN or not reply_token: | |
| return False | |
| headers = {"Content-Type": "application/json", "Authorization": f"Bearer {LINE_ACCESS_TOKEN}"} | |
| payload = {"replyToken": reply_token, "messages": messages} | |
| try: | |
| resp = requests.post( | |
| LINE_REPLY_URL, headers=headers, | |
| data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), timeout=10, | |
| ) | |
| if resp.status_code != 200: | |
| logger.warning("LINE reply API (messages) คืนสถานะผิดพลาด: %s %s", resp.status_code, resp.text) | |
| return False | |
| return True | |
| except requests.RequestException as e: | |
| logger.error("เกิดข้อผิดพลาดขณะเรียก LINE reply API: %s", e) | |
| return False | |
| def _rotation_chain_text(current_admin_name): | |
| """สร้างข้อความโซ่หมุนเวียน เช่น 'Patty → [Orange] → เปรี้ยว → Jumjum' ไฮไลต์คนที่กำลังถูกเสนอด้วยวงเล็บ""" | |
| if not ADMIN_ROSTER_NAMES: | |
| return "(ยังไม่ได้ตั้งค่า LINE_ADMIN_ROSTER)" | |
| parts = [f"[{n}]" if n == current_admin_name else n for n in ADMIN_ROSTER_NAMES] | |
| return " → ".join(parts) | |
| def build_case_offer_flex(case, target_admin_name): | |
| """ | |
| สร้าง LINE Flex Message การ์ดเสนอ Case ให้แอดมิน มีปุ่ม: รับ Case นี้ / ดูประวัติลูกค้า / ข้ามรอบ / ส่งให้คนอื่น | |
| postback data เข้ารหัสแบบ query string ง่าย ๆ: "action=accept&case_id=case-12" | |
| """ | |
| case_id = case["case_id"] | |
| is_repeat = case.get("message_count", 1) > 1 or case.get("is_returning_customer") | |
| header_text = "🔁 ลูกค้าเดิม — Case ใหม่เข้าคิว" if is_repeat else "🆕 ลูกค้าใหม่ — Case เข้าคิว" | |
| chain_text = _rotation_chain_text(target_admin_name) | |
| def _pb(action): | |
| return f"action={action}&case_id={case_id}" | |
| return { | |
| "type": "flex", | |
| "altText": f"{header_text}: {case['last_text'][:30]}", | |
| "contents": { | |
| "type": "bubble", | |
| "header": { | |
| "type": "box", "layout": "vertical", "backgroundColor": "#7B5FE8", "paddingAll": "16px", | |
| "contents": [ | |
| {"type": "text", "text": header_text, "color": "#FFFFFF", "weight": "bold", "size": "sm"}, | |
| ], | |
| }, | |
| "body": { | |
| "type": "box", "layout": "vertical", "spacing": "sm", "paddingAll": "16px", | |
| "contents": [ | |
| {"type": "text", "text": "ข้อความ:", "size": "xs", "color": "#999999"}, | |
| {"type": "text", "text": case["last_text"][:80], "wrap": True, "size": "sm"}, | |
| {"type": "text", "text": f"Sentiment: {case.get('sentiment', '-')}", "size": "xs", "color": "#999999", "margin": "md"}, | |
| {"type": "separator", "margin": "md"}, | |
| {"type": "text", "text": "ลำดับหมุนเวียน:", "size": "xs", "color": "#999999", "margin": "md"}, | |
| {"type": "text", "text": chain_text, "size": "xs", "wrap": True, "color": "#7B5FE8"}, | |
| {"type": "text", "text": f"ID: {case['user_id'][:16]}...", "size": "xxs", "color": "#CCCCCC", "margin": "md"}, | |
| ], | |
| }, | |
| "footer": { | |
| "type": "box", "layout": "vertical", "spacing": "sm", "paddingAll": "12px", | |
| "contents": [ | |
| {"type": "button", "style": "primary", "color": "#4A63E7", "height": "sm", | |
| "action": {"type": "postback", "label": "🤚 รับ Case นี้", "data": _pb("accept"), "displayText": "รับ Case นี้"}}, | |
| {"type": "button", "style": "secondary", "height": "sm", | |
| "action": {"type": "postback", "label": "👁 ดูประวัติลูกค้า", "data": _pb("history"), "displayText": "ดูประวัติลูกค้า"}}, | |
| {"type": "button", "style": "secondary", "height": "sm", | |
| "action": {"type": "postback", "label": "⏭ ข้ามรอบ (เลื่อนคิว)", "data": _pb("skip"), "displayText": "ข้ามรอบ"}}, | |
| {"type": "button", "style": "secondary", "height": "sm", | |
| "action": {"type": "postback", "label": "🔄 ส่งให้คนอื่น", "data": _pb("reassign_menu"), "displayText": "ส่งให้คนอื่น"}}, | |
| ], | |
| }, | |
| }, | |
| } | |
| def build_reassign_picker_flex(case, exclude_name=None): | |
| """การ์ดให้เลือกว่าจะส่ง Case ให้แอดมินคนไหนแทน (กดปุ่ม 'ส่งให้คนอื่น' แล้วจะเห็นเมนูนี้)""" | |
| case_id = case["case_id"] | |
| buttons = [] | |
| for name in ADMIN_ROSTER_NAMES: | |
| if name == exclude_name: | |
| continue | |
| buttons.append({ | |
| "type": "button", "style": "secondary", "height": "sm", | |
| "action": { | |
| "type": "postback", "label": name, | |
| "data": f"action=reassign_to&case_id={case_id}&target={name}", | |
| "displayText": f"ส่งให้ {name}", | |
| }, | |
| }) | |
| if not buttons: | |
| buttons = [{"type": "text", "text": "ไม่มีแอดมินคนอื่นในระบบ", "size": "sm", "color": "#999999"}] | |
| return { | |
| "type": "flex", | |
| "altText": "เลือกแอดมินที่จะส่ง Case ให้", | |
| "contents": { | |
| "type": "bubble", | |
| "body": { | |
| "type": "box", "layout": "vertical", "spacing": "sm", "paddingAll": "16px", | |
| "contents": [{"type": "text", "text": "ส่ง Case นี้ให้ใคร?", "weight": "bold"}] + buttons, | |
| }, | |
| }, | |
| } | |
| def notify_case_offer(case, admin_name): | |
| """ส่งการ์ดเสนอ Case เข้ากลุ่มแอดมิน (หรือ DM หาแอดมินคนนั้นถ้าไม่ได้ตั้งค่ากลุ่มไว้)""" | |
| flex = build_case_offer_flex(case, admin_name) | |
| target = LINE_ADMIN_GROUP_ID or ADMIN_ID_BY_NAME.get(admin_name) | |
| if not target: | |
| logger.warning("notify_case_offer: ไม่มีปลายทางจะส่ง (ไม่มี LINE_ADMIN_GROUP_ID และหา user id ของ %s ไม่เจอ)", admin_name) | |
| return | |
| push_line_messages(target, [flex]) | |
| def notify_group_text(text): | |
| """ส่งข้อความสั้น ๆ เข้ากลุ่มแอดมิน (เช่น แจ้งว่าใครรับเคสไปแล้ว) — ถ้าไม่ได้ตั้งกลุ่มไว้จะข้าม""" | |
| if not LINE_ADMIN_GROUP_ID: | |
| return | |
| push_line_messages(LINE_ADMIN_GROUP_ID, [{"type": "text", "text": text}]) | |
| # --------------------------------------------------------------------------- | |
| # คลังเก็บข้อความลูกค้า (in-memory) — ไว้ให้ Lovable ดึงไปทำกราฟ | |
| # --------------------------------------------------------------------------- | |
| MESSAGE_LOG = deque(maxlen=5000) | |
| LOG_LOCK = threading.Lock() | |
| WEBHOOK_MODEL = os.environ.get("WEBHOOK_MODEL", "rule").strip().lower() | |
| # ตัวโมเดลจริงที่ webhook ใช้วิเคราะห์แบบเรียลไทม์อาจไม่เท่ากับ WEBHOOK_MODEL เสมอไป | |
| # ถ้าตั้งเป็นโมเดล ML (logreg/rf/xgboost) แล้วยังเทรนโมเดลไม่สำเร็จ ระบบจะ fallback ไปใช้ rule ชั่วคราว | |
| # ดูสถานะจริงได้ที่ GET /api/webhook-model/status | |
| # --------------------------------------------------------------------------- | |
| # ระบบคิว FIFO — จัดลำดับลูกค้าให้แอดมินรับสายทีละคน + เลื่อนอัตโนมัติถ้าไม่กดรับ | |
| # --------------------------------------------------------------------------- | |
| # แนวคิด: | |
| # - ลูกค้าใหม่ที่ทักเข้ามา (ยังไม่มีเคสค้างอยู่) จะถูกสร้างเป็น "เคส" ต่อท้ายคิว (FIFO) | |
| # - เคสที่อยู่หัวคิวจะถูก "เสนอ" (offer) ให้แอดมินที่ว่างอยู่ทีละคนเท่านั้น ไม่ใช่โชว์ให้ทุกคนพร้อมกัน | |
| # - ถ้าแอดมินไม่กดรับภายใน QUEUE_OFFER_TIMEOUT_SECONDS วินาที ระบบจะเลื่อนไปเสนอให้แอดมินคนถัดไปอัตโนมัติ | |
| # - แอดมินต้องประกาศตัวว่า "ออนไลน์" ก่อน (POST /api/staff/online) ถึงจะได้รับการเสนอเคส | |
| # | |
| # หมายเหตุ: "หน้าจอให้เลือกรับ" ต้องเป็นหน้า Dashboard ฝั่งแอดมิน (เช่นบน Lovable) ที่ poll | |
| # GET /api/queue/mine เป็นระยะ ๆ เพื่อเช็คว่ามีเคสถูกเสนอมาให้ตัวเองหรือยัง — ไม่ใช่หน้าจอ LINE | |
| # แอปจริงเพราะเราเพิ่มปุ่มกำหนดเองในแอป LINE ของผู้ใช้ทั่วไปไม่ได้ | |
| QUEUE_OFFER_TIMEOUT_SECONDS = int(os.environ.get("QUEUE_OFFER_TIMEOUT_SECONDS", "30")) | |
| QUEUE_LOCK = threading.Lock() | |
| CASES = {} # case_id -> เคสทั้งหมด (รวมที่ปิดแล้วด้วย ไว้ทำสถิติย้อนหลัง) | |
| QUEUE_ORDER = deque() # case_id ที่ยังรอ/กำลังเสนออยู่ เรียงตามลำดับ FIFO (ไม่รวมเคสที่ accepted/done แล้ว) | |
| ONLINE_ADMINS = list(ADMIN_ROSTER_NAMES) # ค่าเริ่มต้น = แอดมินทุกคนใน roster ถือว่าออนไลน์ (ปรับได้ผ่าน /api/staff/online-offline หรือคำสั่งใน LINE) | |
| _CASE_SEQ = 0 # ตัวนับสร้าง case_id | |
| def _next_case_id(): | |
| global _CASE_SEQ | |
| _CASE_SEQ += 1 | |
| return f"case-{_CASE_SEQ}" | |
| def _busy_admins(): | |
| """แอดมินที่ไม่ว่างตอนนี้ = กำลังถูกเสนอเคส (offered) หรือกำลังคุยกับลูกค้าอยู่ (accepted)""" | |
| busy = set() | |
| for c in CASES.values(): | |
| if c["status"] == "offered" and c["offered_to"]: | |
| busy.add(c["offered_to"]) | |
| elif c["status"] == "accepted" and c["assigned_admin"]: | |
| busy.add(c["assigned_admin"]) | |
| return busy | |
| def _pick_admin_for_case(c, free_admins): | |
| """เลือกแอดมินที่จะเสนอเคสให้ — เลี่ยงคนที่เคยเสนอไปแล้วก่อน (กันเสนอซ้ำคนเดิมทันทีตอน timeout)""" | |
| tried = set(c.get("offer_history", [])) | |
| untried = [a for a in free_admins if a not in tried] | |
| if untried: | |
| return untried[0] | |
| # ถ้าเสนอครบทุกคนที่ออนไลน์แล้ว 1 รอบ ให้เริ่มวนใหม่จากคนแรกที่ว่าง | |
| return free_admins[0] if free_admins else None | |
| def _try_assign_offers(): | |
| """ | |
| ไล่จากหัวคิว จับคู่เคสที่ยัง 'waiting' เข้ากับแอดมินที่ว่าง ต้องเรียกภายใต้ QUEUE_LOCK เท่านั้น | |
| คืนค่า list ของ (case_dict_copy, admin_name) ที่เพิ่งถูกเสนอใหม่ — ผู้เรียกต้องเอาไปยิง LINE push | |
| "หลังปล่อยล็อก" เพื่อไม่ให้ network call ค้างล็อกไว้ | |
| """ | |
| newly_offered = [] | |
| busy = _busy_admins() | |
| free_admins = [a for a in ONLINE_ADMINS if a not in busy] | |
| for case_id in list(QUEUE_ORDER): | |
| if not free_admins: | |
| break | |
| c = CASES.get(case_id) | |
| if c is None or c["status"] != "waiting": | |
| continue | |
| admin = _pick_admin_for_case(c, free_admins) | |
| if admin is None: | |
| continue | |
| c["status"] = "offered" | |
| c["offered_to"] = admin | |
| c["offer_started_at"] = time.time() | |
| c.setdefault("offer_history", []).append(admin) | |
| free_admins.remove(admin) | |
| logger.info("QUEUE OFFER | case=%s -> admin=%s", case_id, admin) | |
| newly_offered.append((dict(c), admin)) | |
| return newly_offered | |
| def _notify_all(newly_offered): | |
| """ยิง LINE push ให้ทุกเคสที่เพิ่งถูกเสนอ — เรียก "หลัง" ปล่อย QUEUE_LOCK เสมอ""" | |
| for case_copy, admin in newly_offered: | |
| notify_case_offer(case_copy, admin) | |
| def enqueue_customer_message(user_id, text, sentiment_label="Neutral"): | |
| """ | |
| เรียกทุกครั้งที่มีข้อความลูกค้าเข้ามาทาง webhook | |
| ถ้าลูกค้ารายนี้มีเคสค้างอยู่แล้ว (waiting/offered/accepted) จะอัปเดตข้อความล่าสุดในเคสเดิม | |
| ไม่สร้างเคสใหม่ซ้อน — ถ้ายังไม่มีเคสค้าง จะสร้างเคสใหม่ต่อท้ายคิว (FIFO) | |
| """ | |
| with QUEUE_LOCK: | |
| existing = next( | |
| (c for c in CASES.values() | |
| if c["user_id"] == user_id and c["status"] in ("waiting", "offered", "accepted")), | |
| None, | |
| ) | |
| is_returning = any(c["user_id"] == user_id and c["status"] == "done" for c in CASES.values()) | |
| if existing: | |
| existing["last_text"] = text | |
| existing["sentiment"] = sentiment_label | |
| existing["message_count"] += 1 | |
| existing["updated_at"] = time.time() | |
| newly_offered = [] | |
| else: | |
| case_id = _next_case_id() | |
| CASES[case_id] = { | |
| "case_id": case_id, | |
| "user_id": user_id or "unknown", | |
| "last_text": text, | |
| "sentiment": sentiment_label, | |
| "message_count": 1, | |
| "is_returning_customer": is_returning, | |
| "status": "waiting", # waiting -> offered -> accepted -> done | |
| "offered_to": None, | |
| "offer_started_at": None, | |
| "offer_history": [], | |
| "assigned_admin": None, | |
| "created_at": time.time(), | |
| "updated_at": time.time(), | |
| "claimed_at": None, | |
| "closed_at": None, | |
| "reassign_count": 0, | |
| } | |
| QUEUE_ORDER.append(case_id) | |
| logger.info("QUEUE NEW CASE | case=%s user=%s", case_id, user_id) | |
| existing = CASES[case_id] | |
| newly_offered = _try_assign_offers() | |
| _notify_all(newly_offered) | |
| return existing["case_id"] | |
| def _queue_monitor_loop(): | |
| """เธรดพื้นหลัง: เดินตรวจทุก 2 วินาที ว่ามีเคสที่เสนอไปแล้วแต่แอดมินไม่กดรับเกินเวลาหรือไม่ ถ้าเกิน -> เลื่อนให้คนถัดไป""" | |
| while True: | |
| time.sleep(2) | |
| newly_offered = [] | |
| with QUEUE_LOCK: | |
| now = time.time() | |
| changed = False | |
| for case_id in list(QUEUE_ORDER): | |
| c = CASES.get(case_id) | |
| if not c or c["status"] != "offered": | |
| continue | |
| if now - c["offer_started_at"] > QUEUE_OFFER_TIMEOUT_SECONDS: | |
| prev_admin = c["offered_to"] | |
| logger.info( | |
| "QUEUE TIMEOUT | case=%s admin=%s ไม่กดรับใน %ds -> เลื่อนคิวให้คนถัดไป", | |
| case_id, prev_admin, QUEUE_OFFER_TIMEOUT_SECONDS, | |
| ) | |
| c["status"] = "waiting" | |
| c["offered_to"] = None | |
| c["offer_started_at"] = None | |
| c["reassign_count"] += 1 | |
| changed = True | |
| if changed: | |
| newly_offered = _try_assign_offers() | |
| _notify_all(newly_offered) | |
| def _case_public(c): | |
| """ตัดฟิลด์ภายใน (offer_history) ออก เหลือแต่ข้อมูลที่ frontend ควรเห็น""" | |
| return { | |
| "case_id": c["case_id"], | |
| "user_id": c["user_id"], | |
| "last_text": c["last_text"], | |
| "sentiment": c.get("sentiment", "-"), | |
| "message_count": c["message_count"], | |
| "status": c["status"], | |
| "offered_to": c["offered_to"], | |
| "assigned_admin": c["assigned_admin"], | |
| "created_at": datetime.fromtimestamp(c["created_at"], tz=timezone.utc).isoformat(), | |
| "waiting_seconds": round(time.time() - c["created_at"]), | |
| "reassign_count": c["reassign_count"], | |
| } | |
| # --------------------------------------------------------------------------- | |
| # ฟังก์ชันกลาง accept / skip(decline) / done / reassign | |
| # ใช้ร่วมกันทั้งจาก REST endpoint (สำหรับ dashboard ในอนาคต) และจากปุ่มใน LINE (postback) | |
| # แต่ละฟังก์ชันคืนค่า (ok: bool, message: str, case_public_or_None, newly_offered_list) | |
| # ผู้เรียกต้อง _notify_all(newly_offered_list) เอง "หลัง" ได้ผลลัพธ์กลับมา (ฟังก์ชันนี้ไม่ยิง push เอง) | |
| # --------------------------------------------------------------------------- | |
| def do_accept_case(case_id, admin_name): | |
| with QUEUE_LOCK: | |
| c = CASES.get(case_id) | |
| if c is None: | |
| return False, f"ไม่พบเคส {case_id}", None, [] | |
| if c["status"] != "offered" or c["offered_to"] != admin_name: | |
| return False, "เคสนี้ไม่ได้ถูกเสนอให้คุณ (อาจถูกเลื่อนให้คนอื่นไปแล้ว)", None, [] | |
| c["status"] = "accepted" | |
| c["assigned_admin"] = admin_name | |
| c["claimed_at"] = time.time() | |
| c["offered_to"] = None | |
| if case_id in QUEUE_ORDER: | |
| QUEUE_ORDER.remove(case_id) | |
| logger.info("QUEUE ACCEPT | case=%s admin=%s", case_id, admin_name) | |
| result = _case_public(c) | |
| return True, f"{admin_name} รับเคสนี้แล้ว", result, [] | |
| def do_skip_case(case_id, admin_name): | |
| """ข้ามรอบ/ปฏิเสธ — เลื่อนให้คนถัดไปทันที ไม่ต้องรอ timeout""" | |
| with QUEUE_LOCK: | |
| c = CASES.get(case_id) | |
| if c is None: | |
| return False, f"ไม่พบเคส {case_id}", None, [] | |
| if c["status"] != "offered" or c["offered_to"] != admin_name: | |
| return False, "เคสนี้ไม่ได้ถูกเสนอให้คุณ", None, [] | |
| c["status"] = "waiting" | |
| c["offered_to"] = None | |
| c["offer_started_at"] = None | |
| c["reassign_count"] += 1 | |
| logger.info("QUEUE SKIP | case=%s admin=%s ข้ามรอบ -> เลื่อนให้คนถัดไป", case_id, admin_name) | |
| newly_offered = _try_assign_offers() | |
| return True, f"{admin_name} ข้ามรอบเคสนี้ เลื่อนให้คนถัดไปแล้ว", None, newly_offered | |
| def do_done_case(case_id, admin_name=None): | |
| with QUEUE_LOCK: | |
| c = CASES.get(case_id) | |
| if c is None: | |
| return False, f"ไม่พบเคส {case_id}", None, [] | |
| c["status"] = "done" | |
| c["closed_at"] = time.time() | |
| if case_id in QUEUE_ORDER: | |
| QUEUE_ORDER.remove(case_id) | |
| who = admin_name or c.get("assigned_admin") | |
| logger.info("QUEUE DONE | case=%s admin=%s ปิดเคส", case_id, who) | |
| newly_offered = _try_assign_offers() | |
| return True, "ปิดเคสเรียบร้อย", None, newly_offered | |
| def do_reassign_case(case_id, target_admin_name, by_admin_name=None): | |
| """ | |
| ส่ง Case ให้แอดมินที่ระบุโดยตรง (มนุษย์เลือกเอง) — บังคับเสนอให้คนนี้ทันที | |
| ไม่เช็คว่าเป้าหมาย "ว่าง" อยู่หรือไม่ เพราะถือเป็นการตัดสินใจของมนุษย์ที่ override ระบบอัตโนมัติ | |
| """ | |
| if target_admin_name not in ADMIN_ROSTER_NAMES: | |
| return False, f"ไม่พบแอดมินชื่อ {target_admin_name} ใน roster", None, [] | |
| with QUEUE_LOCK: | |
| c = CASES.get(case_id) | |
| if c is None: | |
| return False, f"ไม่พบเคส {case_id}", None, [] | |
| if c["status"] not in ("waiting", "offered"): | |
| return False, "เคสนี้มีคนรับไปแล้วหรือปิดไปแล้ว ส่งต่อไม่ได้", None, [] | |
| c["status"] = "offered" | |
| c["offered_to"] = target_admin_name | |
| c["offer_started_at"] = time.time() | |
| c.setdefault("offer_history", []).append(target_admin_name) | |
| c["reassign_count"] += 1 | |
| if case_id not in QUEUE_ORDER: | |
| QUEUE_ORDER.append(case_id) | |
| logger.info("QUEUE MANUAL REASSIGN | case=%s -> admin=%s (โดย %s)", case_id, target_admin_name, by_admin_name) | |
| result = dict(c) | |
| return True, f"ส่งเคสให้ {target_admin_name} แล้ว", result, [(result, target_admin_name)] | |
| def customer_history_text(user_id, limit=5): | |
| """สรุปประวัติการติดต่อของลูกค้ารายนี้ (จาก MESSAGE_LOG) เป็นข้อความอ่านง่าย สำหรับตอบกลับปุ่ม 'ดูประวัติลูกค้า'""" | |
| with LOG_LOCK: | |
| items = [m for m in MESSAGE_LOG if m.get("user_id") == user_id] | |
| items = list(reversed(items))[:limit] | |
| if not items: | |
| return "ยังไม่มีประวัติการติดต่อก่อนหน้าของลูกค้ารายนี้" | |
| lines = [f"📋 ประวัติ {len(items)} ข้อความล่าสุดของลูกค้า:"] | |
| for m in items: | |
| ts = m.get("timestamp", "")[:16].replace("T", " ") | |
| lines.append(f"• [{ts}] {m.get('sentiment', '-')}: {m.get('text', '')[:50]}") | |
| return "\n".join(lines) | |
| def log_customer_message(user_id, text, label, confidence, probabilities, model): | |
| """บันทึกข้อความลูกค้า 1 รายการพร้อมผลวิเคราะห์เต็ม ๆ ลงคลังในหน่วยความจำ""" | |
| entry = { | |
| "timestamp": datetime.now(timezone.utc).isoformat(), | |
| "user_id": user_id or "unknown", | |
| "text": text, | |
| "sentiment": label, | |
| "confidence": confidence, | |
| "probabilities": probabilities, | |
| "model": model, | |
| } | |
| with LOG_LOCK: | |
| MESSAGE_LOG.append(entry) | |
| return entry | |
| def check_api_key(request): | |
| if not DASHBOARD_API_KEY: | |
| return True | |
| return request.headers.get("X-API-Key", "") == DASHBOARD_API_KEY | |
| CORS_HEADERS = { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |
| "Access-Control-Allow-Headers": "*", | |
| } | |
| def json_response(payload, status=200): | |
| body = json.dumps(payload, ensure_ascii=False) | |
| return Response(content=body, status_code=status, media_type="application/json", headers=CORS_HEADERS) | |
| # --------------------------------------------------------------------------- | |
| # หัวใจของ Sentiment แบบ Rule-based | |
| # --------------------------------------------------------------------------- | |
| POS_W = { | |
| "ดี", "ดีมาก", "ดีเยี่ยม", "ประทับใจ", "คุ้ม", "คุ้มค่า", "ชอบ", "รัก", | |
| "สุดยอด", "เยี่ยม", "เจ๋ง", "พอใจ", "แนะนำ", "น่ารัก", "สวย", "อร่อย", | |
| "รวดเร็ว", "เร็ว", "สะดวก", "ประทับใจมาก", "เกินคาด", "ยอดเยี่ยม", | |
| "โอเค", "โอเคเลย", "ขอบคุณ", "บริการดี", "คุณภาพดี", "ราคาดี", | |
| "good", "great", "excellent", "amazing", "love", "awesome", "nice", | |
| "perfect", "happy", "satisfied", "fantastic", "wonderful", "impressed", | |
| "worth", "recommend", "fast", "friendly", "helpful", | |
| } | |
| NEG_W = { | |
| "แย่", "ผิดหวัง", "ช้า", "แย่มาก", "ห่วย", "เสีย", "ไม่ดี", "ไม่ชอบ", | |
| "ไม่พอใจ", "ไม่คุ้ม", "แพง", "รอนาน", "ล่าช้า", "งง", "บริการแย่", | |
| "เลว", "แย่สุด", "หงุดหงิด", "โกรธ", "เสียใจ", "ผิดพลาด", "บกพร่อง", | |
| "ปัญหา", "ร้องเรียน", "คุณภาพแย่", "ไม่แนะนำ", "เสียเวลา", "หลอกลวง", | |
| "bad", "terrible", "awful", "disappointed", "poor", "slow", "worst", | |
| "horrible", "hate", "angry", "broken", "waste", "problem", "complaint", | |
| "rude", "late", "expensive", "useless", | |
| } | |
| def quick_sentiment(text): | |
| if not text or not isinstance(text, str): | |
| return "Neutral", 0.5 | |
| text_lower = text.lower() | |
| pos_count = sum(1 for w in POS_W if w.lower() in text_lower) | |
| neg_count = sum(1 for w in NEG_W if w.lower() in text_lower) | |
| total = pos_count + neg_count | |
| if total == 0: | |
| return "Neutral", 0.5 | |
| if pos_count > neg_count: | |
| label, confidence = "Positive", pos_count / total | |
| elif neg_count > pos_count: | |
| label, confidence = "Negative", neg_count / total | |
| else: | |
| label, confidence = "Neutral", 0.5 | |
| confidence = max(0.5, min(confidence, 0.99)) | |
| return label, round(confidence, 2) | |
| def rule_probabilities(label, confidence): | |
| remaining = round((1 - confidence) / 2, 2) | |
| probs = {"Positive": remaining, "Negative": remaining, "Neutral": remaining} | |
| probs[label] = confidence | |
| return probs | |
| # --------------------------------------------------------------------------- | |
| # โหมด TF-IDF + Logistic Regression | |
| # --------------------------------------------------------------------------- | |
| def analyze_with_tfidf(texts, true_labels=None): | |
| if true_labels is None or any(l is None for l in true_labels): | |
| labels_for_train = [quick_sentiment(t)[0] for t in texts] | |
| else: | |
| labels_for_train = true_labels | |
| unique_labels = set(labels_for_train) | |
| if len(unique_labels) < 2 or len(texts) < 2: | |
| results = [] | |
| for t in texts: | |
| label, conf = quick_sentiment(t) | |
| results.append((label, conf, rule_probabilities(label, conf))) | |
| return results | |
| pipeline = Pipeline([ | |
| ("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=1)), | |
| ("clf", LogisticRegression(max_iter=1000)), | |
| ]) | |
| pipeline.fit(texts, labels_for_train) | |
| pred_labels = pipeline.predict(texts) | |
| pred_probas = pipeline.predict_proba(texts) | |
| classes = pipeline.classes_ | |
| results = [] | |
| for label, proba_row in zip(pred_labels, pred_probas): | |
| probs = {cls: round(float(p), 4) for cls, p in zip(classes, proba_row)} | |
| for cls in ["Positive", "Negative", "Neutral"]: | |
| probs.setdefault(cls, 0.0) | |
| confidence = round(float(max(proba_row)), 4) | |
| results.append((label, confidence, probs)) | |
| return results | |
| def run_batch_analysis(data, model_type): | |
| start_time = time.time() | |
| texts = [str(item["review_text"]) for item in data] | |
| if model_type == "tfidf": | |
| has_all_labels = all("sentiment" in item and item.get("sentiment") for item in data) | |
| true_labels = [item.get("sentiment") for item in data] if has_all_labels else None | |
| analysis_results = analyze_with_tfidf(texts, true_labels) | |
| model_used = "tfidf" | |
| else: | |
| analysis_results = [] | |
| for t in texts: | |
| label, conf = quick_sentiment(t) | |
| analysis_results.append((label, conf, rule_probabilities(label, conf))) | |
| model_used = "rule" | |
| summary_counts = {"Positive": 0, "Negative": 0, "Neutral": 0} | |
| predictions = [] | |
| for idx, (text, (label, confidence, probabilities)) in enumerate(zip(texts, analysis_results)): | |
| summary_counts[label] = summary_counts.get(label, 0) + 1 | |
| predictions.append({ | |
| "review_id": idx + 1, | |
| "review_text": text, | |
| "predicted_sentiment": label, | |
| "confidence": confidence, | |
| "probabilities": probabilities, | |
| }) | |
| total = len(texts) | |
| sentiment_summary = { | |
| "Positive": summary_counts["Positive"], | |
| "Negative": summary_counts["Negative"], | |
| "Neutral": summary_counts["Neutral"], | |
| "positive_pct": round(summary_counts["Positive"] / total * 100, 2), | |
| "negative_pct": round(summary_counts["Negative"] / total * 100, 2), | |
| "neutral_pct": round(summary_counts["Neutral"] / total * 100, 2), | |
| } | |
| return { | |
| "total_messages": total, | |
| "processing_time": round(time.time() - start_time, 4), | |
| "model_used": model_used, | |
| "sentiment_summary": sentiment_summary, | |
| "predictions": predictions, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # โหมดวิเคราะห์ขั้นสูง — หลายโมเดล ML + เปรียบเทียบ + back-test + keyword analysis | |
| # --------------------------------------------------------------------------- | |
| AVAILABLE_ML_MODELS = ["logreg", "rf"] + (["xgboost"] if HAS_XGBOOST else []) | |
| MODEL_DISPLAY_NAMES = { | |
| "logreg": "Logistic Regression", | |
| "rf": "Random Forest", | |
| "xgboost": "XGBoost", | |
| } | |
| def _make_estimator(name): | |
| if name == "logreg": | |
| return LogisticRegression(max_iter=1000) | |
| if name == "rf": | |
| return RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=1) | |
| if name == "xgboost": | |
| return XGBClassifier( | |
| n_estimators=100, max_depth=4, learning_rate=0.3, | |
| random_state=42, verbosity=0, n_jobs=1, eval_metric="mlogloss", | |
| ) | |
| raise ValueError(f"unknown model: {name}") | |
| def _make_pipeline(name): | |
| return Pipeline([ | |
| ("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=1)), | |
| ("clf", _make_estimator(name)), | |
| ]) | |
| # --------------------------------------------------------------------------- | |
| # โมเดล ML สำหรับ webhook แบบเรียลไทม์ — เทรนครั้งเดียวตอน startup แล้วเก็บไว้ใช้ทำนายซ้ำ | |
| # --------------------------------------------------------------------------- | |
| # ปัญหาของการ "เทรนใหม่ทุกครั้งที่มีข้อความเข้า" คือ (1) ช้ามาก และ (2) ข้อความเดี่ยว ๆ 1 แถว | |
| # ไม่มีความหมายพอจะเทรนอะไรได้เลย ทางแก้คือเทรนโมเดลไว้ล่วงหน้า "ครั้งเดียว" จากชุดข้อมูลระดับหนึ่ง | |
| # แล้วใช้โมเดลที่เทรนเสร็จแล้วนั้น "ทำนาย" ข้อความใหม่ที่เข้ามาแต่ละข้อความ (ไม่ต้องเทรนซ้ำ) | |
| # | |
| # แหล่งข้อมูลที่ใช้เทรน เรียงลำดับความสำคัญ: | |
| # 1) ไฟล์ data/training_data.csv ถ้ามี (ต้องมีคอลัมน์ review_text, sentiment) — ใช้ข้อมูลจริงของธุรกิจ | |
| # 2) ถ้าไม่มีไฟล์ ใช้ชุดข้อมูลตัวอย่างในตัว (SEED_TRAINING_DATA) ด้านล่าง เพื่อให้ใช้งานได้ทันที | |
| # โดยไม่ต้องรอเตรียมข้อมูลเอง (แม่นน้อยกว่าข้อมูลจริงของธุรกิจ แต่ใช้เดโม/เริ่มต้นได้) | |
| # อัปโหลดข้อมูลจริงมาเทรนทับได้ทุกเมื่อผ่าน POST /api/webhook-model/train | |
| SEED_TRAINING_DATA = [ | |
| {"review_text": "สินค้าดีมาก บริการประทับใจ ส่งเร็วทันใจ", "sentiment": "Positive"}, | |
| {"review_text": "ขอบคุณค่ะ ประทับใจมากเลย จะกลับมาซื้อซ้ำแน่นอน", "sentiment": "Positive"}, | |
| {"review_text": "คุณภาพดีมาก คุ้มค่ากับราคาที่จ่ายไป", "sentiment": "Positive"}, | |
| {"review_text": "พนักงานน่ารัก บริการดีเยี่ยม ชอบมากค่ะ", "sentiment": "Positive"}, | |
| {"review_text": "ของถึงไวมาก แพ็คมาดี ประทับใจสุด ๆ", "sentiment": "Positive"}, | |
| {"review_text": "ราคาดี ของดี แนะนำเลยค่ะ", "sentiment": "Positive"}, | |
| {"review_text": "สอบถามราคาสินค้าตัวนี้หน่อยครับ", "sentiment": "Neutral"}, | |
| {"review_text": "มีสีอื่นไหมคะ อยากดูตัวเลือกเพิ่ม", "sentiment": "Neutral"}, | |
| {"review_text": "จัดส่งกี่วันถึงคะ อยู่ต่างจังหวัด", "sentiment": "Neutral"}, | |
| {"review_text": "รับชำระเงินแบบไหนบ้างครับ", "sentiment": "Neutral"}, | |
| {"review_text": "ของถึงแล้วค่ะ ยังไม่ได้แกะดูเลย", "sentiment": "Neutral"}, | |
| {"review_text": "ขอเบอร์โทรติดต่อร้านหน่อยครับ", "sentiment": "Neutral"}, | |
| {"review_text": "แย่มาก รอนานมาก ของยังไม่มาเลย", "sentiment": "Negative"}, | |
| {"review_text": "ผิดหวังมาก สินค้าไม่ตรงปกที่สั่งไว้เลย", "sentiment": "Negative"}, | |
| {"review_text": "บริการแย่ พนักงานพูดจาไม่ดี ไม่ประทับใจ", "sentiment": "Negative"}, | |
| {"review_text": "ของชำรุดตั้งแต่แกะกล่อง เสียใจมากค่ะ", "sentiment": "Negative"}, | |
| {"review_text": "แพงเกินไป ไม่คุ้มค่าเลยกับคุณภาพที่ได้", "sentiment": "Negative"}, | |
| {"review_text": "ส่งช้ามาก รอเป็นอาทิตย์ยังไม่ถึงเลย หงุดหงิดมาก", "sentiment": "Negative"}, | |
| {"review_text": "good service and fast delivery, very impressed", "sentiment": "Positive"}, | |
| {"review_text": "great quality product, highly recommend to everyone", "sentiment": "Positive"}, | |
| {"review_text": "what is the price of this item please", "sentiment": "Neutral"}, | |
| {"review_text": "how long does shipping usually take", "sentiment": "Neutral"}, | |
| {"review_text": "terrible experience, product broken on arrival", "sentiment": "Negative"}, | |
| {"review_text": "very disappointed, waited too long for delivery", "sentiment": "Negative"}, | |
| ] | |
| TRAINING_DATA_CSV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "training_data.csv") | |
| WEBHOOK_MODEL_LOCK = threading.Lock() | |
| WEBHOOK_TRAINED_PIPELINE = None # Pipeline ที่เทรนเสร็จแล้ว (TF-IDF + classifier) | |
| WEBHOOK_LABEL_ENCODER = None | |
| WEBHOOK_MODEL_STATUS = { | |
| "requested_model": WEBHOOK_MODEL, | |
| "active_model": "rule", # โมเดลที่ "ใช้งานจริง" ตอนนี้ (อาจ fallback เป็น rule ถ้าเทรนไม่สำเร็จ) | |
| "trained": False, | |
| "trained_at": None, | |
| "data_source": None, # "uploaded_csv" หรือ "seed_dataset" | |
| "rows_used": 0, | |
| "error": None, | |
| } | |
| def _load_training_rows(): | |
| """โหลดข้อมูลเทรนจากไฟล์ data/training_data.csv ถ้ามี ไม่งั้นใช้ SEED_TRAINING_DATA ในตัว""" | |
| if os.path.exists(TRAINING_DATA_CSV_PATH): | |
| try: | |
| import csv as _csv | |
| with open(TRAINING_DATA_CSV_PATH, encoding="utf-8-sig") as f: | |
| rows = [dict(r) for r in _csv.DictReader(f)] | |
| rows = [r for r in rows if r.get("review_text") and r.get("sentiment")] | |
| if len(rows) >= 8: | |
| return rows, "uploaded_csv" | |
| except Exception as e: | |
| logger.warning("อ่าน data/training_data.csv ไม่สำเร็จ (%s) จะใช้ seed dataset แทน", e) | |
| return list(SEED_TRAINING_DATA), "seed_dataset" | |
| def train_webhook_model(rows=None, source_label=None): | |
| """ | |
| เทรนโมเดลสำหรับ webhook ตาม WEBHOOK_MODEL ที่ตั้งไว้ (logreg/rf/xgboost) | |
| เรียกตอน startup อัตโนมัติ และเรียกซ้ำได้ทุกเมื่อผ่าน POST /api/webhook-model/train | |
| """ | |
| global WEBHOOK_TRAINED_PIPELINE, WEBHOOK_LABEL_ENCODER | |
| if WEBHOOK_MODEL == "rule": | |
| with WEBHOOK_MODEL_LOCK: | |
| WEBHOOK_MODEL_STATUS.update({"active_model": "rule", "trained": False, "data_source": None, "rows_used": 0, "error": None}) | |
| return | |
| if WEBHOOK_MODEL not in AVAILABLE_ML_MODELS: | |
| logger.warning( | |
| "WEBHOOK_MODEL='%s' ไม่รองรับ (ต้องเป็น rule หรือ %s) -> ใช้ rule แทนไปก่อน", | |
| WEBHOOK_MODEL, "/".join(AVAILABLE_ML_MODELS), | |
| ) | |
| with WEBHOOK_MODEL_LOCK: | |
| WEBHOOK_MODEL_STATUS.update({ | |
| "active_model": "rule", "trained": False, | |
| "error": f"WEBHOOK_MODEL='{WEBHOOK_MODEL}' ไม่รองรับในเครื่องนี้ (เช่น ขอ xgboost แต่ติดตั้งไม่สำเร็จ)", | |
| }) | |
| return | |
| if rows is None: | |
| rows, source_label = _load_training_rows() | |
| texts = [str(r["review_text"]) for r in rows] | |
| labels = [str(r["sentiment"]) for r in rows] | |
| if len(set(labels)) < 2 or len(texts) < 8: | |
| logger.warning("ข้อมูลเทรน webhook model มีไม่พอ (ต้อง >= 8 แถว, 2 คลาสขึ้นไป) -> ใช้ rule แทนไปก่อน") | |
| with WEBHOOK_MODEL_LOCK: | |
| WEBHOOK_MODEL_STATUS.update({"active_model": "rule", "trained": False, "error": "ข้อมูลเทรนไม่พอ"}) | |
| return | |
| try: | |
| le = LabelEncoder() | |
| le.fit(["Positive", "Negative", "Neutral"]) | |
| y_enc = le.transform(labels) | |
| pipe = _make_pipeline(WEBHOOK_MODEL) | |
| pipe.fit(texts, y_enc) | |
| with WEBHOOK_MODEL_LOCK: | |
| WEBHOOK_TRAINED_PIPELINE = pipe | |
| WEBHOOK_LABEL_ENCODER = le | |
| WEBHOOK_MODEL_STATUS.update({ | |
| "active_model": WEBHOOK_MODEL, | |
| "trained": True, | |
| "trained_at": datetime.now(timezone.utc).isoformat(), | |
| "data_source": source_label, | |
| "rows_used": len(texts), | |
| "error": None, | |
| }) | |
| logger.info( | |
| "WEBHOOK MODEL TRAINED | model=%s rows=%d source=%s", | |
| WEBHOOK_MODEL, len(texts), source_label, | |
| ) | |
| except Exception as e: | |
| logger.error("เทรน webhook model (%s) ไม่สำเร็จ: %s -> ใช้ rule แทนไปก่อน", WEBHOOK_MODEL, e) | |
| with WEBHOOK_MODEL_LOCK: | |
| WEBHOOK_MODEL_STATUS.update({"active_model": "rule", "trained": False, "error": str(e)}) | |
| def predict_webhook_sentiment(text): | |
| """ | |
| ทำนาย sentiment ของข้อความเดี่ยว ๆ ที่เข้ามาทาง webhook | |
| คืนค่า (label, confidence, probabilities, model_used) — model_used อาจเป็น "rule" ถ้า fallback | |
| """ | |
| with WEBHOOK_MODEL_LOCK: | |
| pipe = WEBHOOK_TRAINED_PIPELINE | |
| le = WEBHOOK_LABEL_ENCODER | |
| active = WEBHOOK_MODEL_STATUS["active_model"] | |
| if active == "rule" or pipe is None or le is None: | |
| label, confidence = quick_sentiment(text) | |
| return label, confidence, rule_probabilities(label, confidence), "rule" | |
| try: | |
| pred_enc = pipe.predict([text])[0] | |
| label = str(le.inverse_transform([pred_enc])[0]) | |
| probs_arr = pipe.predict_proba([text])[0] | |
| classes = le.inverse_transform(pipe.named_steps["clf"].classes_) | |
| probabilities = {str(c): round(float(p), 4) for c, p in zip(classes, probs_arr)} | |
| for cls in ["Positive", "Negative", "Neutral"]: | |
| probabilities.setdefault(cls, 0.0) | |
| confidence = round(float(max(probs_arr)), 4) | |
| return label, confidence, probabilities, active | |
| except Exception as e: | |
| logger.error("predict_webhook_sentiment ทำนายด้วย %s ไม่สำเร็จ: %s -> ใช้ rule แทนข้อความนี้", active, e) | |
| label, confidence = quick_sentiment(text) | |
| return label, confidence, rule_probabilities(label, confidence), "rule" | |
| # เทรนโมเดล webhook ตอน startup (ถ้า WEBHOOK_MODEL เป็น rule จะไม่ทำอะไร คืนทันที) | |
| train_webhook_model() | |
| def switch_webhook_model(new_model): | |
| """ | |
| เปลี่ยนโมเดลที่ webhook ใช้แบบ runtime — ไม่ต้องแก้ env var + restart Space | |
| คืนค่า (ok: bool, status_dict_or_error_message) | |
| """ | |
| global WEBHOOK_MODEL | |
| new_model = (new_model or "").strip().lower() | |
| valid = ["rule"] + AVAILABLE_ML_MODELS | |
| if new_model not in valid: | |
| return False, f"model '{new_model}' ไม่รองรับ เลือกจาก: {', '.join(valid)}" | |
| WEBHOOK_MODEL = new_model | |
| with WEBHOOK_MODEL_LOCK: | |
| WEBHOOK_MODEL_STATUS["requested_model"] = WEBHOOK_MODEL | |
| train_webhook_model() # เทรนใหม่ทันทีตามโมเดลที่เพิ่งเปลี่ยน (หรือข้ามถ้าเป็น rule) | |
| with WEBHOOK_MODEL_LOCK: | |
| return True, dict(WEBHOOK_MODEL_STATUS) | |
| def compare_webhook_models(): | |
| """ | |
| เทรน + ประเมินทุกโมเดล (rule เป็น baseline + logreg/rf/xgboost) บนชุดข้อมูลเทรนของ webhook | |
| ใช้ train/test split เดียวกันทุกโมเดล เพื่อเทียบกันแบบยุติธรรม คืนตารางเปรียบเทียบเรียงจากแม่นสุด | |
| """ | |
| rows, source_label = _load_training_rows() | |
| texts = [str(r["review_text"]) for r in rows] | |
| labels = [str(r["sentiment"]) for r in rows] | |
| if len(set(labels)) < 2 or len(texts) < 8: | |
| return {"error": "ข้อมูลเทรนไม่พอสำหรับเปรียบเทียบ (ต้องมีอย่างน้อย 8 แถว และ 2 คลาสขึ้นไป)"} | |
| le = LabelEncoder() | |
| le.fit(["Positive", "Negative", "Neutral"]) | |
| y_enc = le.transform(labels) | |
| idx = list(range(len(texts))) | |
| if _can_stratify_split(y_enc): | |
| idx_train, idx_test = train_test_split(idx, test_size=0.3, stratify=y_enc, random_state=42) | |
| eval_basis = "holdout_30pct" | |
| else: | |
| idx_train, idx_test = idx, idx | |
| eval_basis = "in_sample" | |
| X_train = [texts[i] for i in idx_train] | |
| y_train = [y_enc[i] for i in idx_train] | |
| X_test = [texts[i] for i in idx_test] | |
| y_test_labels = le.inverse_transform([y_enc[i] for i in idx_test]) | |
| results = [] | |
| # rule-based เป็น baseline เทียบเคียง (ไม่ต้องเทรน) | |
| rule_pred = [quick_sentiment(t)[0] for t in X_test] | |
| results.append({"model": "rule", "display_name": "Rule-based (Baseline)", **_compute_metrics(y_test_labels, rule_pred)}) | |
| for name in AVAILABLE_ML_MODELS: | |
| try: | |
| pipe = _make_pipeline(name) | |
| pipe.fit(X_train, y_train) | |
| pred_labels = le.inverse_transform(pipe.predict(X_test)) | |
| results.append({"model": name, "display_name": MODEL_DISPLAY_NAMES.get(name, name), **_compute_metrics(y_test_labels, pred_labels)}) | |
| except Exception as e: | |
| results.append({"model": name, "display_name": MODEL_DISPLAY_NAMES.get(name, name), "error": str(e)}) | |
| ok = [r for r in results if "error" not in r] | |
| ok.sort(key=lambda r: r["f1_macro"], reverse=True) | |
| return { | |
| "data_source": source_label, | |
| "rows_used": len(texts), | |
| "eval_basis": eval_basis, | |
| "best_model": ok[0]["model"] if ok else None, | |
| "currently_active": WEBHOOK_MODEL_STATUS["active_model"], | |
| "comparison": ok + [r for r in results if "error" in r], | |
| } | |
| def _compute_metrics(y_true, y_pred): | |
| return { | |
| "accuracy": round(float(accuracy_score(y_true, y_pred)), 4), | |
| "f1_macro": round(float(f1_score(y_true, y_pred, average="macro", zero_division=0)), 4), | |
| "f1_weighted": round(float(f1_score(y_true, y_pred, average="weighted", zero_division=0)), 4), | |
| } | |
| def _can_stratify_split(y_enc): | |
| c = Counter(y_enc) | |
| return len(y_enc) >= 8 and len(c) >= 2 and min(c.values()) >= 2 | |
| def _keyword_analysis(items, top_k=10): | |
| known_words = POS_W | NEG_W | |
| result = {} | |
| for cls in ["Positive", "Negative", "Neutral"]: | |
| counter = Counter() | |
| texts = [it["text"] for it in items if it["label"] == cls] | |
| for t in texts: | |
| tl = t.lower() | |
| found = set() | |
| for w in known_words: | |
| if w.lower() in tl: | |
| found.add(w) | |
| for tok in t.split(): | |
| tok = tok.strip() | |
| if len(tok) >= 2: | |
| found.add(tok) | |
| for f in found: | |
| counter[f] += 1 | |
| top = counter.most_common(top_k) | |
| result[cls] = [{"keyword": k, "count": c} for k, c in top] | |
| return result | |
| def run_advanced_analysis(data, model_type): | |
| start_time = time.time() | |
| texts = [str(item["review_text"]) for item in data] | |
| has_labels = all("sentiment" in it and it.get("sentiment") for it in data) | |
| if has_labels: | |
| train_labels = [str(it["sentiment"]) for it in data] | |
| label_source = "provided" | |
| else: | |
| train_labels = [quick_sentiment(t)[0] for t in texts] | |
| label_source = "rule_pseudo" | |
| if len(set(train_labels)) < 2 or len(texts) < 4: | |
| fallback = run_batch_analysis(data, "rule") | |
| fallback["model_used"] = model_type | |
| fallback["note"] = "ข้อมูลไม่พอสำหรับเทรนโมเดล ML (ต้องมีอย่างน้อย 2 คลาสและ 4 แถว) จึงถอยไปใช้ rule-based" | |
| return fallback | |
| le = LabelEncoder() | |
| y_enc = le.fit_transform(train_labels) | |
| if model_type == "all": | |
| models_to_run = list(AVAILABLE_ML_MODELS) | |
| else: | |
| models_to_run = [model_type] | |
| do_split = _can_stratify_split(y_enc) | |
| if do_split: | |
| idx = list(range(len(texts))) | |
| idx_train, idx_test = train_test_split( | |
| idx, test_size=0.3, stratify=y_enc, random_state=42 | |
| ) | |
| X_train = [texts[i] for i in idx_train] | |
| y_train = [y_enc[i] for i in idx_train] | |
| X_test = [texts[i] for i in idx_test] | |
| y_test = [y_enc[i] for i in idx_test] | |
| eval_basis = "holdout_30pct" | |
| else: | |
| X_train, y_train = texts, list(y_enc) | |
| X_test, y_test = texts, list(y_enc) | |
| eval_basis = "in_sample" | |
| model_results = {} | |
| for name in models_to_run: | |
| try: | |
| pipe = _make_pipeline(name) | |
| pipe.fit(X_train, y_train) | |
| y_pred_test = pipe.predict(X_test) | |
| metrics = _compute_metrics(y_test, y_pred_test) | |
| metrics["display_name"] = MODEL_DISPLAY_NAMES.get(name, name) | |
| model_results[name] = metrics | |
| except Exception as e: | |
| model_results[name] = {"error": str(e), "display_name": MODEL_DISPLAY_NAMES.get(name, name)} | |
| ok_models = {k: v for k, v in model_results.items() if "error" not in v} | |
| if ok_models: | |
| best_model = max(ok_models, key=lambda k: ok_models[k]["f1_macro"]) | |
| else: | |
| fallback = run_batch_analysis(data, "rule") | |
| fallback["model_used"] = model_type | |
| fallback["note"] = "ทุกโมเดล ML เทรนไม่สำเร็จ จึงถอยไปใช้ rule-based" | |
| return fallback | |
| final_model_name = best_model | |
| final_pipe = _make_pipeline(final_model_name) | |
| final_pipe.fit(texts, y_enc) | |
| pred_enc = final_pipe.predict(texts) | |
| pred_labels = le.inverse_transform(pred_enc) | |
| try: | |
| pred_probas = final_pipe.predict_proba(texts) | |
| classes = le.inverse_transform(final_pipe.named_steps["clf"].classes_) | |
| except Exception: | |
| pred_probas = None | |
| classes = None | |
| summary_counts = {"Positive": 0, "Negative": 0, "Neutral": 0} | |
| predictions = [] | |
| items_for_kw = [] | |
| for idx_i, text in enumerate(texts): | |
| label = str(pred_labels[idx_i]) | |
| summary_counts[label] = summary_counts.get(label, 0) + 1 | |
| if pred_probas is not None and classes is not None: | |
| probs = {str(c): round(float(p), 4) for c, p in zip(classes, pred_probas[idx_i])} | |
| for c in ["Positive", "Negative", "Neutral"]: | |
| probs.setdefault(c, 0.0) | |
| confidence = round(float(max(pred_probas[idx_i])), 4) | |
| else: | |
| probs, confidence = {}, None | |
| predictions.append({ | |
| "review_id": idx_i + 1, | |
| "review_text": text, | |
| "predicted_sentiment": label, | |
| "confidence": confidence, | |
| "probabilities": probs, | |
| }) | |
| items_for_kw.append({"text": text, "label": label}) | |
| total = len(texts) | |
| sentiment_summary = { | |
| "Positive": summary_counts["Positive"], | |
| "Negative": summary_counts["Negative"], | |
| "Neutral": summary_counts["Neutral"], | |
| "positive_pct": round(summary_counts["Positive"] / total * 100, 2), | |
| "negative_pct": round(summary_counts["Negative"] / total * 100, 2), | |
| "neutral_pct": round(summary_counts["Neutral"] / total * 100, 2), | |
| } | |
| comparison = sorted( | |
| [ | |
| {"model": k, "display_name": v.get("display_name", k), | |
| "accuracy": v.get("accuracy"), "f1_macro": v.get("f1_macro"), | |
| "f1_weighted": v.get("f1_weighted")} | |
| for k, v in ok_models.items() | |
| ], | |
| key=lambda x: (x["f1_macro"] if x["f1_macro"] is not None else -1), | |
| reverse=True, | |
| ) | |
| response = { | |
| "total_messages": total, | |
| "processing_time": round(time.time() - start_time, 4), | |
| "model_used": model_type, | |
| "label_source": label_source, | |
| "eval_basis": eval_basis, | |
| "best_model": best_model, | |
| "model_results": model_results, | |
| "comparison": comparison, | |
| "sentiment_summary": sentiment_summary, | |
| "predictions": predictions, | |
| "keyword_analysis": _keyword_analysis(items_for_kw), | |
| } | |
| if best_model in ok_models: | |
| response["back_test"] = { | |
| "model": best_model, | |
| "display_name": MODEL_DISPLAY_NAMES.get(best_model, best_model), | |
| "basis": eval_basis, | |
| "label_source": label_source, | |
| **{k: ok_models[best_model][k] for k in ("accuracy", "f1_macro", "f1_weighted")}, | |
| } | |
| return response | |
| def verify_line_signature(body_bytes, signature_header): | |
| """ตรวจสอบลายเซ็น HMAC-SHA256 จาก LINE ถ้าไม่ตั้ง LINE_CHANNEL_SECRET จะข้ามการตรวจสอบ""" | |
| if not LINE_CHANNEL_SECRET: | |
| return True | |
| if not signature_header: | |
| return False | |
| hash_digest = hmac.new(LINE_CHANNEL_SECRET.encode("utf-8"), body_bytes, hashlib.sha256).digest() | |
| expected_signature = base64.b64encode(hash_digest).decode("utf-8") | |
| return hmac.compare_digest(expected_signature, signature_header) | |
| def reply_to_line(reply_token, message_text): | |
| if not LINE_ACCESS_TOKEN: | |
| logger.warning("ไม่ได้ตั้งค่า LINE_ACCESS_TOKEN จึงไม่สามารถส่งข้อความตอบกลับได้") | |
| return | |
| headers = {"Content-Type": "application/json", "Authorization": f"Bearer {LINE_ACCESS_TOKEN}"} | |
| payload = {"replyToken": reply_token, "messages": [{"type": "text", "text": message_text}]} | |
| try: | |
| resp = requests.post( | |
| LINE_REPLY_URL, headers=headers, | |
| data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), timeout=10, | |
| ) | |
| if resp.status_code != 200: | |
| logger.warning("LINE reply API คืนสถานะผิดพลาด: %s %s", resp.status_code, resp.text) | |
| except requests.RequestException as e: | |
| logger.error("เกิดข้อผิดพลาดขณะเรียก LINE reply API: %s", e) | |
| def sentiment_emoji(label): | |
| return {"Positive": "😊", "Negative": "😞", "Neutral": "😐"}.get(label, "🤔") | |
| # --------------------------------------------------------------------------- | |
| # Gradio UI | |
| # --------------------------------------------------------------------------- | |
| def gradio_predict(text, model_choice): | |
| if not text or not text.strip(): | |
| return "กรุณาพิมพ์ข้อความก่อนครับ", {} | |
| model_type = "tfidf" if model_choice == "tfidf" else "rule" | |
| result = run_batch_analysis([{"review_text": text}], model_type) | |
| pred = result["predictions"][0] | |
| label_text = f"{sentiment_emoji(pred['predicted_sentiment'])} {pred['predicted_sentiment']} (ความมั่นใจ {pred['confidence']*100:.0f}%)" | |
| return label_text, pred["probabilities"] | |
| def gradio_compare_models(): | |
| """เรียก compare_webhook_models() แล้วแปลงเป็นตาราง [[โมเดล, accuracy, f1_macro, f1_weighted]] ให้ gr.Dataframe แสดง""" | |
| result = compare_webhook_models() | |
| if "error" in result: | |
| return [[result["error"], "-", "-", "-"]], f"⚠️ {result['error']}" | |
| rows = [] | |
| for r in result["comparison"]: | |
| if "error" in r: | |
| rows.append([r["display_name"], "เทรนไม่สำเร็จ", "-", "-"]) | |
| continue | |
| star = " 🏆" if r["model"] == result["best_model"] else "" | |
| rows.append([ | |
| r["display_name"] + star, | |
| f"{r['accuracy']*100:.1f}%", | |
| f"{r['f1_macro']*100:.1f}%", | |
| f"{r['f1_weighted']*100:.1f}%", | |
| ]) | |
| summary = ( | |
| f"ข้อมูลที่ใช้: {result['rows_used']} แถว (แหล่ง: {result['data_source']}) · " | |
| f"วิธีวัดผล: {result['eval_basis']} · " | |
| f"กำลังใช้งานจริงตอนนี้: {result['currently_active']}" | |
| ) | |
| return rows, summary | |
| def gradio_switch_model(model_choice): | |
| ok, result = switch_webhook_model(model_choice) | |
| if not ok: | |
| return f"⚠️ {result}" | |
| if result.get("error"): | |
| return f"⚠️ เปลี่ยนไม่สำเร็จ: {result['error']} (ยัง fallback เป็น rule อยู่)" | |
| return f"✅ เปลี่ยน webhook ให้ใช้ {result['active_model']} แล้ว (เทรนจาก {result['rows_used']} แถว)" | |
| with gr.Blocks(title="Sentiment Mini API — Demo") as demo: | |
| gr.Markdown("## 🇹🇭 Sentiment Mini API") | |
| gr.Markdown( | |
| "ระบบจริงเรียกผ่าน REST API ที่ `POST /api/sentiment/analyze` และ `POST /webhook` " | |
| "(ดูรายละเอียดที่ `GET /api/health`)" | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("ทดลองวิเคราะห์"): | |
| with gr.Row(): | |
| text_input = gr.Textbox(label="พิมพ์ข้อความรีวิว", placeholder="เช่น สินค้าดีมาก จัดส่งเร็ว ประทับใจ") | |
| model_dropdown = gr.Dropdown(choices=["rule", "tfidf"], value="rule", label="โมเดล") | |
| analyze_btn = gr.Button("วิเคราะห์", variant="primary") | |
| result_label = gr.Textbox(label="ผลลัพธ์", interactive=False) | |
| result_probs = gr.JSON(label="Probabilities") | |
| analyze_btn.click( | |
| fn=gradio_predict, | |
| inputs=[text_input, model_dropdown], | |
| outputs=[result_label, result_probs], | |
| ) | |
| with gr.Tab("เปรียบเทียบโมเดล Webhook"): | |
| gr.Markdown( | |
| "เทรน + วัดผล **rule / logreg / rf / xgboost** บนชุดข้อมูลเทรนของ webhook เดียวกัน " | |
| "(แบ่ง train/test ชุดเดียวกันทุกโมเดล เทียบกันแบบยุติธรรม) แล้วเลือกตัวที่ดีที่สุดมาใช้งานจริงได้ทันที" | |
| ) | |
| compare_btn = gr.Button("🔍 รันเปรียบเทียบทุกโมเดล", variant="primary") | |
| compare_summary = gr.Textbox(label="สรุป", interactive=False) | |
| compare_table = gr.Dataframe( | |
| headers=["โมเดล", "Accuracy", "F1 (macro)", "F1 (weighted)"], | |
| label="ผลเปรียบเทียบ (เรียงจากแม่นสุด)", | |
| ) | |
| compare_btn.click(fn=gradio_compare_models, outputs=[compare_table, compare_summary]) | |
| gr.Markdown("---") | |
| gr.Markdown("**เลือกโมเดลที่จะใช้งานจริงกับ webhook** (มีผลทันที ไม่ต้อง restart Space)") | |
| with gr.Row(): | |
| switch_dropdown = gr.Dropdown( | |
| choices=["rule"] + AVAILABLE_ML_MODELS, value=WEBHOOK_MODEL, label="ใช้โมเดลนี้กับ webhook", | |
| ) | |
| switch_btn = gr.Button("✅ ใช้งานโมเดลนี้") | |
| switch_result = gr.Textbox(label="ผลการเปลี่ยน", interactive=False) | |
| switch_btn.click(fn=gradio_switch_model, inputs=[switch_dropdown], outputs=[switch_result]) | |
| demo.queue() | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=int(os.environ.get("PORT", 7860)), | |
| prevent_thread_lock=True, | |
| ssr_mode=False, | |
| show_error=True, | |
| ) | |
| app = demo.app | |
| # เริ่มเธรดพื้นหลังไว้คอยตรวจ timeout ของคิว (รันตลอดอายุของโปรเซส) | |
| threading.Thread(target=_queue_monitor_loop, daemon=True).start() | |
| async def cors_preflight(): | |
| return Response(status_code=204, headers=CORS_HEADERS) | |
| async def health_check(): | |
| return json_response({ | |
| "status": "ok", | |
| "service": "Sentiment Mini API", | |
| "endpoints": { | |
| "GET /": "หน้าเว็บทดลองพิมพ์ข้อความดูผล sentiment (Gradio UI)", | |
| "GET /api/health": "Health check", | |
| "POST /api/sentiment/analyze": "วิเคราะห์ sentiment (model: rule/tfidf/logreg/rf/xgboost/all)", | |
| "POST /webhook": "LINE Messaging API webhook (บันทึกข้อความลูกค้า)", | |
| "GET /api/messages": "ดึงรายการข้อความลูกค้าล่าสุด (ให้ Lovable ทำกราฟ)", | |
| "GET /api/stats": "สรุป sentiment + timeline รายวัน (ให้ Lovable ทำกราฟ)", | |
| "POST /api/reset": "ล้างข้อมูลที่เก็บไว้ (ไว้ทดสอบ)", | |
| "POST /api/staff/online": "แอดมินประกาศตัวออนไลน์ พร้อมรับเคส", | |
| "POST /api/staff/offline": "แอดมินออกจากระบบ (ปล่อยเคสที่ถูกเสนอไว้คืนคิว)", | |
| "GET /api/staff/roster": "ดูรายชื่อแอดมินใน roster + ใครออนไลน์อยู่บ้าง", | |
| "GET /api/queue": "ดูคิวทั้งหมด (สำหรับหน้าจอมอนิเตอร์)", | |
| "GET /api/queue/mine": "เช็คว่ามีเคสถูกเสนอมาให้แอดมินคนนี้หรือยัง (ให้ frontend poll)", | |
| "POST /api/queue/accept": "แอดมินกดรับเคสที่ถูกเสนอมา", | |
| "POST /api/queue/decline": "แอดมินกดปฏิเสธ เลื่อนให้คนถัดไปทันที", | |
| "POST /api/queue/done": "ปิดเคส (จบการสนทนา) แอดมินว่างรับเคสถัดไป", | |
| "POST /api/queue/reassign": "ส่งเคสให้แอดมินที่ระบุโดยตรง", | |
| "GET /api/webhook-model/status": "ดูว่า webhook ใช้โมเดลอะไรวิเคราะห์อยู่จริงตอนนี้", | |
| "POST /api/webhook-model/train": "เทรน/เทรนใหม่โมเดลที่ webhook ใช้ จาก CSV ที่อัปโหลด", | |
| "GET /api/webhook-model/compare": "เปรียบเทียบทุกโมเดลบนข้อมูลเทรนชุดเดียวกัน (ดูว่าตัวไหนแม่นสุด)", | |
| "POST /api/webhook-model/switch": "สลับโมเดลที่ webhook ใช้แบบทันที ไม่ต้อง restart Space", | |
| }, | |
| "available_models": ["rule", "tfidf"] + AVAILABLE_ML_MODELS + ["all"], | |
| "webhook_model": dict(WEBHOOK_MODEL_STATUS), | |
| }) | |
| async def cors_preflight_webhook_model(): | |
| return Response(status_code=204, headers=CORS_HEADERS) | |
| async def webhook_model_compare(request: Request): | |
| """ | |
| เทรน + เปรียบเทียบทุกโมเดล (rule/logreg/rf/xgboost) บนชุดข้อมูลเทรนของ webhook ในคำเรียกเดียว | |
| ใช้ดูว่าตัวไหนแม่นที่สุดก่อนตัดสินใจ POST /api/webhook-model/switch | |
| """ | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| result = compare_webhook_models() | |
| if "error" in result: | |
| return json_response(result, 400) | |
| return json_response(result) | |
| async def webhook_model_switch(request: Request): | |
| """ | |
| สลับโมเดลที่ webhook ใช้แบบทันที ไม่ต้องแก้ Variable + restart Space | |
| body: {"model": "logreg" | "rf" | "xgboost" | "rule"} | |
| """ | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = {} | |
| model = (body or {}).get("model", "") | |
| ok, result = switch_webhook_model(model) | |
| if not ok: | |
| return json_response({"error": result}, 400) | |
| return json_response({"status": "switched", **result}) | |
| async def webhook_model_status(request: Request): | |
| """ดูว่าตอนนี้ webhook ใช้โมเดลอะไรวิเคราะห์ข้อความลูกค้าจริง ๆ (อาจต่างจาก WEBHOOK_MODEL ที่ตั้งไว้ ถ้า fallback)""" | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| with WEBHOOK_MODEL_LOCK: | |
| status = dict(WEBHOOK_MODEL_STATUS) | |
| status["requested_model"] = WEBHOOK_MODEL | |
| status["available_ml_models"] = AVAILABLE_ML_MODELS | |
| return json_response(status) | |
| async def webhook_model_train(request: Request): | |
| """ | |
| เทรน/เทรนใหม่โมเดลที่ webhook ใช้วิเคราะห์ข้อความลูกค้าแบบเรียลไทม์ | |
| body: {"csv": "<เนื้อหา CSV มีคอลัมน์ review_text,sentiment>"} — ไม่ส่ง csv มา = เทรนใหม่จาก seed dataset เดิม | |
| ใช้ได้เฉพาะเมื่อ WEBHOOK_MODEL เป็นโมเดล ML (logreg/rf/xgboost) เท่านั้น ถ้าเป็น "rule" จะแจ้งว่าไม่ต้องเทรน | |
| """ | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| if WEBHOOK_MODEL == "rule": | |
| return json_response({"error": "WEBHOOK_MODEL ตั้งเป็น 'rule' อยู่ ไม่ต้องเทรนโมเดลใด ๆ"}, 400) | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = {} | |
| csv_text = (body or {}).get("csv") | |
| if csv_text: | |
| import csv as _csv | |
| import io as _io | |
| try: | |
| rows = [dict(r) for r in _csv.DictReader(_io.StringIO(csv_text))] | |
| rows = [r for r in rows if r.get("review_text") and r.get("sentiment")] | |
| except Exception as e: | |
| return json_response({"error": f"อ่าน CSV ไม่สำเร็จ: {e}"}, 400) | |
| if len(rows) < 8: | |
| return json_response({"error": "ต้องมีข้อมูลอย่างน้อย 8 แถวที่มีทั้ง review_text และ sentiment"}, 400) | |
| train_webhook_model(rows=rows, source_label="uploaded_csv") | |
| else: | |
| train_webhook_model() # ไม่ส่ง csv มา -> โหลดใหม่ตามลำดับเดิม (ไฟล์ data/training_data.csv ถ้ามี ไม่งั้น seed) | |
| with WEBHOOK_MODEL_LOCK: | |
| status = dict(WEBHOOK_MODEL_STATUS) | |
| if status.get("error"): | |
| return json_response({"status": "failed", **status}, 400) | |
| return json_response({"status": "trained", **status}) | |
| async def analyze_sentiment(request: Request): | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = None | |
| if not body or "data" not in body: | |
| return json_response({ | |
| "error": "รูปแบบข้อมูลไม่ถูกต้อง กรุณาส่ง JSON ที่มี key 'data' เป็น list ของข้อความ" | |
| }, 400) | |
| data = body.get("data") | |
| model_type = body.get("model", "rule") | |
| if not isinstance(data, list) or len(data) == 0: | |
| return json_response({"error": "กรุณาส่ง 'data' เป็น list ที่มีอย่างน้อย 1 รายการ"}, 400) | |
| for idx, item in enumerate(data): | |
| if not isinstance(item, dict) or "review_text" not in item or not str(item.get("review_text", "")).strip(): | |
| return json_response({ | |
| "error": f"ไม่พบคอลัมน์ 'review_text' หรือข้อความว่างเปล่าในรายการลำดับที่ {idx + 1} กรุณาตรวจสอบข้อมูล" | |
| }, 400) | |
| valid_models = ["rule", "tfidf", "all"] + AVAILABLE_ML_MODELS | |
| if model_type not in valid_models: | |
| return json_response({ | |
| "error": f"model '{model_type}' ไม่รองรับ กรุณาเลือกจาก: {', '.join(valid_models)}" | |
| }, 400) | |
| if model_type in ("rule", "tfidf"): | |
| result = run_batch_analysis(data, model_type) | |
| else: | |
| result = run_advanced_analysis(data, model_type) | |
| return json_response(result) | |
| async def line_webhook(request: Request): | |
| """ | |
| POST /webhook — รับ event จาก LINE รองรับ 2 ประเภท: | |
| 1) type=message (ข้อความจากลูกค้า): วิเคราะห์ sentiment, บันทึก log, เข้าคิว FIFO ให้แอดมิน | |
| - ถ้าข้อความคือ "รหัสแอดมิน" (มาจากแชท 1:1 ของแอดมินกับบอท) จะตอบ LINE User ID ของผู้ส่งกลับ | |
| ไว้ให้เอาไปตั้งค่าใน LINE_ADMIN_ROSTER | |
| 2) type=postback (แอดมินกดปุ่มในการ์ดที่บอทโพสต์): รับ Case / ข้ามรอบ / ดูประวัติ / ส่งให้คนอื่น | |
| """ | |
| raw_body = await request.body() | |
| signature = request.headers.get("X-Line-Signature", "") | |
| logger.info( | |
| "WEBHOOK HIT | body_len=%d | has_signature=%s | LINE_CHANNEL_SECRET_set=%s", | |
| len(raw_body), bool(signature), bool(LINE_CHANNEL_SECRET), | |
| ) | |
| if not verify_line_signature(raw_body, signature): | |
| logger.warning("ลายเซ็น LINE ไม่ถูกต้อง ปฏิเสธ request นี้ | raw_body(preview)=%r", raw_body[:200]) | |
| return json_response({"error": "ลายเซ็นไม่ถูกต้อง (invalid signature)"}, 403) | |
| try: | |
| payload = json.loads(raw_body.decode("utf-8")) if raw_body else {} | |
| except json.JSONDecodeError: | |
| payload = {} | |
| logger.info("WEBHOOK payload events count = %d", len(payload.get("events", []))) | |
| logged = 0 | |
| events = payload.get("events", []) | |
| for event in events: | |
| etype = event.get("type") | |
| # ---------- ปุ่มใน LINE ที่แอดมินกด ---------- | |
| if etype == "postback": | |
| await _handle_postback_event(event) | |
| continue | |
| if etype != "message": | |
| continue | |
| message = event.get("message", {}) | |
| if message.get("type") != "text": | |
| continue | |
| text = message.get("text", "") | |
| reply_token = event.get("replyToken") | |
| user_id = event.get("source", {}).get("userId") | |
| # ---------- คำสั่งพิเศษ: แอดมินขอ User ID ตัวเอง ---------- | |
| if text.strip() == "รหัสแอดมิน": | |
| name_hint = ADMIN_NAME_BY_ID.get(user_id, "(ยังไม่อยู่ใน roster)") | |
| reply_line_messages(reply_token, [{ | |
| "type": "text", | |
| "text": f"🆔 LINE User ID ของคุณคือ:\n{user_id}\n\nสถานะ: {name_hint}\n\nเอา ID นี้ไปใส่ในตัวแปร LINE_ADMIN_ROSTER รูปแบบ Uxxxx:ชื่อ", | |
| }]) | |
| continue | |
| label, confidence, probabilities, model_used = predict_webhook_sentiment(text) | |
| # DEBUG: log เนื้อข้อความ + ผลวิเคราะห์ลง container log ของ HF Space | |
| logger.info( | |
| "WEBHOOK MSG | user=%s | text=%r | sentiment=%s (%.0f%%) | model=%s", | |
| user_id or "unknown", text, label, confidence * 100, model_used, | |
| ) | |
| log_customer_message(user_id, text, label, confidence, probabilities, model_used) | |
| logged += 1 | |
| # เข้าคิว FIFO ให้แอดมินรับเคสนี้ (ถ้ามีเคสค้างของ user เดิมอยู่แล้วจะอัปเดตแทนสร้างซ้ำ) | |
| enqueue_customer_message(user_id, text, sentiment_label=label) | |
| if LINE_AUTO_REPLY and reply_token: | |
| reply_text = f"{sentiment_emoji(label)} Sentiment: {label} | ความมั่นใจ {int(confidence * 100)}%" | |
| reply_to_line(reply_token, reply_text) | |
| return json_response({"status": "received", "logged": logged}, 200) | |
| async def _handle_postback_event(event): | |
| """ประมวลผลปุ่มที่แอดมินกดในการ์ด Flex Message (accept / skip / history / reassign_menu / reassign_to)""" | |
| data = event.get("postback", {}).get("data", "") | |
| params = parse_qs(data) | |
| action = params.get("action", [""])[0] | |
| case_id = params.get("case_id", [""])[0] | |
| reply_token = event.get("replyToken") | |
| actor_id = event.get("source", {}).get("userId") | |
| actor_name = ADMIN_NAME_BY_ID.get(actor_id) | |
| logger.info("QUEUE POSTBACK | action=%s case=%s actor=%s(%s)", action, case_id, actor_name, actor_id) | |
| if actor_name is None: | |
| reply_line_messages(reply_token, [{ | |
| "type": "text", | |
| "text": "⚠️ ไม่พบชื่อคุณใน roster แอดมิน — พิมพ์คำว่า \"รหัสแอดมิน\" เพื่อดู User ID แล้วแจ้งผู้ดูแลระบบเพิ่มชื่อคุณเข้า LINE_ADMIN_ROSTER", | |
| }]) | |
| return | |
| if action == "accept": | |
| ok, msg, case, _ = do_accept_case(case_id, actor_name) | |
| reply_line_messages(reply_token, [{"type": "text", "text": ("✅ " + msg) if ok else ("⚠️ " + msg)}]) | |
| if ok: | |
| notify_group_text(f"✅ {actor_name} รับเคส {case_id} แล้ว กำลังดูแลลูกค้าอยู่") | |
| elif action == "skip": | |
| ok, msg, _, newly_offered = do_skip_case(case_id, actor_name) | |
| reply_line_messages(reply_token, [{"type": "text", "text": ("⏭ " + msg) if ok else ("⚠️ " + msg)}]) | |
| _notify_all(newly_offered) | |
| elif action == "history": | |
| with QUEUE_LOCK: | |
| c = CASES.get(case_id) | |
| uid = c["user_id"] if c else None | |
| text = customer_history_text(uid) if uid else "ไม่พบข้อมูลเคสนี้" | |
| reply_line_messages(reply_token, [{"type": "text", "text": text}]) | |
| elif action == "reassign_menu": | |
| with QUEUE_LOCK: | |
| c = CASES.get(case_id) | |
| case_copy = dict(c) if c else None | |
| if case_copy is None: | |
| reply_line_messages(reply_token, [{"type": "text", "text": "ไม่พบเคสนี้แล้ว"}]) | |
| else: | |
| flex = build_reassign_picker_flex(case_copy, exclude_name=actor_name) | |
| reply_line_messages(reply_token, [flex]) | |
| elif action == "reassign_to": | |
| target = params.get("target", [""])[0] | |
| ok, msg, _, newly_offered = do_reassign_case(case_id, target, by_admin_name=actor_name) | |
| reply_line_messages(reply_token, [{"type": "text", "text": ("🔄 " + msg) if ok else ("⚠️ " + msg)}]) | |
| _notify_all(newly_offered) | |
| else: | |
| logger.warning("QUEUE POSTBACK | ไม่รู้จัก action=%r", action) | |
| # --------------------------------------------------------------------------- | |
| # Endpoint สำหรับ Lovable AI ดึงข้อมูลไปทำกราฟ | |
| # --------------------------------------------------------------------------- | |
| async def cors_preflight_data(): | |
| return Response(status_code=204, headers=CORS_HEADERS) | |
| async def get_messages(request: Request): | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| with LOG_LOCK: | |
| items = list(MESSAGE_LOG) | |
| items = list(reversed(items)) | |
| want_all = request.query_params.get("all", "").lower() in ("1", "true", "yes") | |
| if not want_all: | |
| try: | |
| limit = int(request.query_params.get("limit", 1000)) | |
| except ValueError: | |
| limit = 1000 | |
| limit = max(1, min(limit, 5000)) | |
| items = items[:limit] | |
| return json_response({ | |
| "total_stored": len(MESSAGE_LOG), | |
| "returned": len(items), | |
| "messages": items, | |
| }) | |
| async def get_stats(request: Request): | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| with LOG_LOCK: | |
| items = list(MESSAGE_LOG) | |
| total = len(items) | |
| counts = {"Positive": 0, "Negative": 0, "Neutral": 0} | |
| models_used = {} | |
| timeline = {} | |
| for it in items: | |
| label = it.get("sentiment", "Neutral") | |
| counts[label] = counts.get(label, 0) + 1 | |
| m = it.get("model", "unknown") | |
| models_used[m] = models_used.get(m, 0) + 1 | |
| day = it.get("timestamp", "")[:10] | |
| if day: | |
| bucket = timeline.setdefault(day, {"Positive": 0, "Negative": 0, "Neutral": 0}) | |
| bucket[label] = bucket.get(label, 0) + 1 | |
| def pct(n): | |
| return round(n / total * 100, 2) if total else 0.0 | |
| timeline_list = [ | |
| {"date": day, **timeline[day]} | |
| for day in sorted(timeline.keys()) | |
| ] | |
| return json_response({ | |
| "total_messages": total, | |
| "models_used": models_used, | |
| "sentiment_summary": { | |
| "Positive": counts["Positive"], | |
| "Negative": counts["Negative"], | |
| "Neutral": counts["Neutral"], | |
| "positive_pct": pct(counts["Positive"]), | |
| "negative_pct": pct(counts["Negative"]), | |
| "neutral_pct": pct(counts["Neutral"]), | |
| }, | |
| "timeline": timeline_list, | |
| }) | |
| async def reset_messages(request: Request): | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| with LOG_LOCK: | |
| MESSAGE_LOG.clear() | |
| return json_response({"status": "cleared"}) | |
| # --------------------------------------------------------------------------- | |
| # ระบบคิว FIFO — endpoint สำหรับหน้าจอแอดมิน (Lovable) | |
| # --------------------------------------------------------------------------- | |
| async def cors_preflight_queue(): | |
| return Response(status_code=204, headers=CORS_HEADERS) | |
| async def staff_online(request: Request): | |
| """ | |
| แอดมินประกาศตัวว่าออนไลน์ พร้อมรับเคส | |
| body: {"admin": "ชื่อแอดมิน"} | |
| เรียกซ้ำได้ปลอดภัย (idempotent) — ถ้าออนไลน์อยู่แล้วจะไม่เพิ่มซ้ำ | |
| """ | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = None | |
| admin = (body or {}).get("admin", "").strip() | |
| if not admin: | |
| return json_response({"error": "กรุณาส่ง 'admin' เป็นชื่อแอดมิน"}, 400) | |
| with QUEUE_LOCK: | |
| if admin not in ONLINE_ADMINS: | |
| ONLINE_ADMINS.append(admin) | |
| logger.info("STAFF ONLINE | admin=%s", admin) | |
| newly_offered = _try_assign_offers() | |
| _notify_all(newly_offered) | |
| return json_response({"status": "online", "admin": admin, "online_admins": list(ONLINE_ADMINS)}) | |
| async def staff_offline(request: Request): | |
| """ | |
| แอดมินออกจากระบบ — ถ้ามีเคสที่ถูกเสนอให้อยู่ (ยังไม่กดรับ) จะถูกปล่อยคืนคิวทันที | |
| ให้แอดมินคนอื่นได้รับแทน โดยไม่ต้องรอ timeout | |
| body: {"admin": "ชื่อแอดมิน"} | |
| """ | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = None | |
| admin = (body or {}).get("admin", "").strip() | |
| if not admin: | |
| return json_response({"error": "กรุณาส่ง 'admin' เป็นชื่อแอดมิน"}, 400) | |
| with QUEUE_LOCK: | |
| if admin in ONLINE_ADMINS: | |
| ONLINE_ADMINS.remove(admin) | |
| for c in CASES.values(): | |
| if c["status"] == "offered" and c["offered_to"] == admin: | |
| c["status"] = "waiting" | |
| c["offered_to"] = None | |
| c["offer_started_at"] = None | |
| logger.info("STAFF OFFLINE | admin=%s ปล่อยเคส=%s คืนคิว", admin, c["case_id"]) | |
| newly_offered = _try_assign_offers() | |
| _notify_all(newly_offered) | |
| return json_response({"status": "offline", "admin": admin}) | |
| async def get_queue(request: Request): | |
| """ | |
| ดูคิวทั้งหมดที่ยังไม่ปิด (waiting/offered/accepted) เรียงตามลำดับ FIFO | |
| ใช้ทำหน้าจอมอนิเตอร์ภาพรวมคิว (ไม่ใช่หน้าจอส่วนตัวของแอดมินคนใดคนหนึ่ง) | |
| """ | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| with QUEUE_LOCK: | |
| items = [_case_public(CASES[cid]) for cid in QUEUE_ORDER if cid in CASES] | |
| online = list(ONLINE_ADMINS) | |
| return json_response({ | |
| "total_waiting": len(items), | |
| "online_admins": online, | |
| "offer_timeout_seconds": QUEUE_OFFER_TIMEOUT_SECONDS, | |
| "queue": items, | |
| }) | |
| async def get_my_offer(request: Request): | |
| """ | |
| GET /api/queue/mine?admin=ชื่อแอดมิน | |
| ให้หน้าจอแอดมิน poll endpoint นี้เป็นระยะ (เช่นทุก 2-3 วิ) เพื่อเช็คว่ามีเคสถูกเสนอมาให้ตัวเองหรือยัง | |
| ถ้ามี -> โชว์ popup "ลูกค้าใหม่ กดรับ / ปฏิเสธ" ในหน้าจอ | |
| """ | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| admin = request.query_params.get("admin", "").strip() | |
| if not admin: | |
| return json_response({"error": "กรุณาระบุ query parameter 'admin'"}, 400) | |
| with QUEUE_LOCK: | |
| offered = next( | |
| (c for c in CASES.values() if c["status"] == "offered" and c["offered_to"] == admin), | |
| None, | |
| ) | |
| active = next( | |
| (c for c in CASES.values() if c["status"] == "accepted" and c["assigned_admin"] == admin), | |
| None, | |
| ) | |
| is_online = admin in ONLINE_ADMINS | |
| return json_response({ | |
| "admin": admin, | |
| "is_online": is_online, | |
| "offered_case": _case_public(offered) if offered else None, | |
| "active_case": _case_public(active) if active else None, | |
| }) | |
| async def queue_accept(request: Request): | |
| """แอดมินกดรับเคสที่ถูกเสนอมาให้ตัวเอง (ใช้ตรรกะเดียวกับปุ่มใน LINE) body: {"case_id": "...", "admin": "..."}""" | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = None | |
| case_id = (body or {}).get("case_id", "") | |
| admin = (body or {}).get("admin", "").strip() | |
| if not case_id or not admin: | |
| return json_response({"error": "กรุณาส่ง 'case_id' และ 'admin'"}, 400) | |
| ok, msg, case, _ = do_accept_case(case_id, admin) | |
| if not ok: | |
| status = 404 if "ไม่พบ" in msg else 409 | |
| return json_response({"error": msg}, status) | |
| notify_group_text(f"✅ {admin} รับเคส {case_id} แล้ว กำลังดูแลลูกค้าอยู่") | |
| return json_response({"status": "accepted", "case": case}) | |
| async def queue_decline(request: Request): | |
| """แอดมินกดปฏิเสธเคสที่ถูกเสนอมา (ไม่อยากรอให้ timeout) — เลื่อนให้คนถัดไปทันที body: {"case_id": "...", "admin": "..."}""" | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = None | |
| case_id = (body or {}).get("case_id", "") | |
| admin = (body or {}).get("admin", "").strip() | |
| if not case_id or not admin: | |
| return json_response({"error": "กรุณาส่ง 'case_id' และ 'admin'"}, 400) | |
| ok, msg, _, newly_offered = do_skip_case(case_id, admin) | |
| if not ok: | |
| status = 404 if "ไม่พบ" in msg else 409 | |
| return json_response({"error": msg}, status) | |
| _notify_all(newly_offered) | |
| return json_response({"status": "declined", "case_id": case_id}) | |
| async def queue_done(request: Request): | |
| """แอดมินปิดเคส (จบการสนทนากับลูกค้ารายนี้แล้ว) — ทำให้แอดมินว่างรับเคสถัดไปในคิวได้ทันที body: {"case_id": "...", "admin": "..."}""" | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = None | |
| case_id = (body or {}).get("case_id", "") | |
| admin = (body or {}).get("admin", "").strip() | |
| if not case_id: | |
| return json_response({"error": "กรุณาส่ง 'case_id'"}, 400) | |
| ok, msg, _, newly_offered = do_done_case(case_id, admin) | |
| if not ok: | |
| return json_response({"error": msg}, 404) | |
| _notify_all(newly_offered) | |
| return json_response({"status": "done", "case_id": case_id}) | |
| async def queue_reassign(request: Request): | |
| """ | |
| ส่ง Case ให้แอดมินที่ระบุโดยตรง (เทียบเท่าปุ่ม 'ส่งให้คนอื่น' ใน LINE) | |
| body: {"case_id": "...", "target_admin": "...", "by_admin": "..."} | |
| """ | |
| try: | |
| body = await request.json() | |
| except Exception: | |
| body = None | |
| case_id = (body or {}).get("case_id", "") | |
| target_admin = (body or {}).get("target_admin", "").strip() | |
| by_admin = (body or {}).get("by_admin", "").strip() | |
| if not case_id or not target_admin: | |
| return json_response({"error": "กรุณาส่ง 'case_id' และ 'target_admin'"}, 400) | |
| ok, msg, case, newly_offered = do_reassign_case(case_id, target_admin, by_admin_name=by_admin) | |
| if not ok: | |
| status = 404 if "ไม่พบ" in msg else 400 | |
| return json_response({"error": msg}, status) | |
| _notify_all(newly_offered) | |
| return json_response({"status": "reassigned", "case": _case_public(case)}) | |
| async def get_roster(request: Request): | |
| """ดูรายชื่อแอดมินทั้งหมดใน roster + ใครออนไลน์อยู่บ้าง (เอาไว้ debug การตั้งค่า LINE_ADMIN_ROSTER)""" | |
| if not check_api_key(request): | |
| return json_response({"error": "unauthorized: ต้องแนบ header X-API-Key"}, 401) | |
| with QUEUE_LOCK: | |
| online = list(ONLINE_ADMINS) | |
| return json_response({ | |
| "roster": ADMIN_ROSTER_NAMES, | |
| "online_admins": online, | |
| "admin_group_configured": bool(LINE_ADMIN_GROUP_ID), | |
| }) | |
| demo.block_thread() | |