LineOfficial / app.py
Therdpoom's picture
Rename app (6).py to app.py
de0e813 verified
Raw
History Blame Contribute Delete
100 kB
# -*- 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 เท่านั้น
@spaces.GPU
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()
@app.options("/api/sentiment/analyze")
@app.options("/webhook")
async def cors_preflight():
return Response(status_code=204, headers=CORS_HEADERS)
@app.get("/api/health")
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),
})
@app.options("/api/webhook-model/train")
@app.options("/api/webhook-model/switch")
@app.options("/api/webhook-model/compare")
async def cors_preflight_webhook_model():
return Response(status_code=204, headers=CORS_HEADERS)
@app.get("/api/webhook-model/compare")
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)
@app.post("/api/webhook-model/switch")
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})
@app.get("/api/webhook-model/status")
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)
@app.post("/api/webhook-model/train")
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})
@app.post("/api/sentiment/analyze")
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)
@app.post("/webhook")
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 ดึงข้อมูลไปทำกราฟ
# ---------------------------------------------------------------------------
@app.options("/api/messages")
@app.options("/api/stats")
@app.options("/api/reset")
async def cors_preflight_data():
return Response(status_code=204, headers=CORS_HEADERS)
@app.get("/api/messages")
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,
})
@app.get("/api/stats")
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,
})
@app.post("/api/reset")
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)
# ---------------------------------------------------------------------------
@app.options("/api/staff/online")
@app.options("/api/staff/offline")
@app.options("/api/queue/accept")
@app.options("/api/queue/decline")
@app.options("/api/queue/done")
@app.options("/api/queue/reassign")
async def cors_preflight_queue():
return Response(status_code=204, headers=CORS_HEADERS)
@app.post("/api/staff/online")
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)})
@app.post("/api/staff/offline")
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})
@app.get("/api/queue")
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,
})
@app.get("/api/queue/mine")
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,
})
@app.post("/api/queue/accept")
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})
@app.post("/api/queue/decline")
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})
@app.post("/api/queue/done")
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})
@app.post("/api/queue/reassign")
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)})
@app.get("/api/staff/roster")
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()