Minhtan210905's picture
Upload api.py with huggingface_hub
ff78a1e verified
Raw History Blame Contribute Delete
18.5 kB
import torch
import torch.nn.functional as F
import pickle
import os
import re
import sys
import csv
import time
import json as json_module
import uuid
import asyncio
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Path
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from contextlib import asynccontextmanager
import sqlite3
if sys.stdout.encoding != 'utf-8':
sys.stdout.reconfigure(encoding='utf-8')
# ── Paths ──
VOCAB_PATH = 'data/processed/vocab.pkl'
MODEL_PATH = 'models/best/model.pt'
TOKENIZER_PATH = 'data/processed/bpe_tokenizer.json'
RAW_CSV_PATH = 'data/raw/output.csv'
DB_PATH = 'data/chatbot.sqlite'
# ── Model globals ──
model = None
word2idx = None
idx2word = None
device = None
model_info = {}
bpe_tokenizer = None
qa_database = []
qa_embeddings = None
SIMILARITY_THRESHOLD = 0.92
# ═══════════════════════════════════════
# DATABASE – Sessions + Messages
# ═══════════════════════════════════════
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("PRAGMA foreign_keys = ON")
c.execute('''
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT 'Chat mới',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
c.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
''')
# Migrate old chat_history table if it exists
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_history'")
if c.fetchone():
c.execute("SELECT COUNT(*) FROM chat_history")
count = c.fetchone()[0]
if count > 0:
sid = str(uuid.uuid4())
c.execute("INSERT INTO sessions (id, title) VALUES (?, ?)", (sid, "Chat cũ"))
c.execute("""
INSERT INTO messages (session_id, role, content, timestamp)
SELECT ?, role, content, timestamp FROM chat_history
""", (sid,))
print(f"[DB] Migrated {count} old messages to session '{sid}'")
c.execute("DROP TABLE chat_history")
conn.commit()
conn.close()
def save_message(session_id, role, content):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)",
(session_id, role, content))
c.execute("UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(session_id,))
conn.commit()
conn.close()
def create_session(title="Chat mới"):
sid = str(uuid.uuid4())
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT INTO sessions (id, title) VALUES (?, ?)", (sid, title))
conn.commit()
conn.close()
return sid
def update_session_title(session_id, title):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("UPDATE sessions SET title = ? WHERE id = ?", (title, session_id))
conn.commit()
conn.close()
# ═══════════════════════════════════════
# MODEL LOADING
# ═══════════════════════════════════════
def load_bpe_tokenizer():
global bpe_tokenizer
if os.path.exists(TOKENIZER_PATH):
from tokenizers import Tokenizer
bpe_tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
print(f"[OK] BPE Tokenizer loaded | Vocab: {bpe_tokenizer.get_vocab_size()}")
return True
else:
print(f"[WARN] Không tìm thấy BPE tokenizer: {TOKENIZER_PATH}")
return False
def tokenize_bpe(text):
if bpe_tokenizer is not None:
enc = bpe_tokenizer.encode(str(text).strip())
return enc.ids
else:
text = str(text).lower()
text = re.sub(r'([.,!?()])', r' \\1 ', text)
text = re.sub(r'\\s{2,}', ' ', text)
tokens = text.strip().split()
return [word2idx.get(w, word2idx.get('<unk>', 3)) for w in tokens]
def clean_response(text):
text = text.strip()
if not text:
return ""
text = text[0].upper() + text[1:]
import unicodedata
text = unicodedata.normalize('NFC', text)
text = re.sub(r'\\\s+([a-zA-Z]+)', r'\\\1', text)
text = re.sub(r'\s*_\s*', '_', text)
text = re.sub(r'\s*\^\s*', '^', text)
text = re.sub(r'\s*\{\s*', '{', text)
text = re.sub(r'\s*\}\s*', '}', text)
text = re.sub(r'\s+([.,!?():;])', r'\1', text)
text = re.sub(r'\(\s+', r'(', text)
text = re.sub(r'\s{2,}', ' ', text)
return text.strip()
def generate_response(message, temperature=0.5, top_k=3, top_p=0.9,
repetition_penalty=1.3, max_tokens=100):
"""Core inference function. Returns (response_text, tokens_count, elapsed_ms)."""
start = time.perf_counter()
# Greeting check
msg_lower = message.lower()
if len(msg_lower) < 25:
if any(w in msg_lower for w in ["chào", "hello", "hi ", "xin chào", "alo"]):
return "Chào bạn! Mình là trợ lý môn Toán Rời Rạc. Bạn cần hỏi gì nào?", 0, 1.0
if any(w in msg_lower for w in ["cảm ơn", "thank", "cám ơn", "tks"]):
return "Không có gì! Chúc bạn học tốt môn Toán Rời Rạc nhé!", 0, 1.0
tokens = tokenize_bpe(message)
if not tokens:
return "Mình không hiểu câu hỏi. Bạn thử hỏi lại nhé!", 0, 1.0
sos_id = word2idx.get('<sos>', 1)
sep_id = word2idx.get('<sep>', 4)
prompt = [sos_id] + tokens + [sep_id]
x = torch.tensor([prompt]).to(device)
with torch.no_grad():
out_ids = model.generate(x, max_new_tokens=max_tokens,
temperature=temperature,
top_k=top_k, top_p=top_p,
repetition_penalty=repetition_penalty)
elapsed_ms = (time.perf_counter() - start) * 1000
resp_ids = out_ids[0][len(prompt):]
if bpe_tokenizer is not None:
valid_ids = []
for i in resp_ids:
tid = i.item()
if tid == word2idx.get('<eos>', 2):
break
if tid not in [word2idx.get('<pad>', 0), word2idx.get('<sos>', 1),
word2idx.get('<unk>', 3), word2idx.get('<sep>', 4)]:
valid_ids.append(tid)
response = bpe_tokenizer.decode(valid_ids)
else:
resp_tokens = []
for i in resp_ids:
sym = idx2word.get(i.item(), '<unk>')
if sym == '<eos>':
break
if sym not in ['<pad>', '<sos>', '<unk>', '<sep>']:
resp_tokens.append(sym)
response = " ".join(resp_tokens)
response = clean_response(response)
if not response.strip():
response = "Mình chưa học tới phần này. Bạn hỏi câu khác nhé!"
return response, len(resp_ids), round(elapsed_ms, 2)
async def generate_response_stream(message, temperature=0.5, top_k=3, top_p=0.9,
repetition_penalty=1.3, max_tokens=100):
"""Streaming inference function. Yields SSE data chunks containing the accumulated full text."""
msg_lower = message.lower()
if len(msg_lower) < 25:
if any(w in msg_lower for w in ["chào", "hello", "hi ", "xin chào", "alo"]):
yield "data: " + json_module.dumps({"text": "Chào bạn! Mình là trợ lý môn Toán Rời Rạc. Bạn cần hỏi gì nào?", "done": True}, ensure_ascii=False) + "\n\n"
return
if any(w in msg_lower for w in ["cảm ơn", "thank", "cám ơn", "tks"]):
yield "data: " + json_module.dumps({"text": "Không có gì! Chúc bạn học tốt môn Toán Rời Rạc nhé!", "done": True}, ensure_ascii=False) + "\n\n"
return
tokens = tokenize_bpe(message)
if not tokens:
yield "data: " + json_module.dumps({"text": "Mình không hiểu câu hỏi. Bạn thử hỏi lại nhé!", "done": True}, ensure_ascii=False) + "\n\n"
return
sos_id = word2idx.get('<sos>', 1)
sep_id = word2idx.get('<sep>', 4)
eos_id = word2idx.get('<eos>', 2)
pad_id = word2idx.get('<pad>', 0)
unk_id = word2idx.get('<unk>', 3)
prompt = [sos_id] + tokens + [sep_id]
x = torch.tensor([prompt]).to(device)
valid_ids = []
with torch.no_grad():
for tid in model.generate_stream(x, max_new_tokens=max_tokens,
temperature=temperature, top_k=top_k,
top_p=top_p, repetition_penalty=repetition_penalty):
if tid == eos_id:
break
if tid not in [pad_id, sos_id, unk_id, sep_id]:
valid_ids.append(tid)
if bpe_tokenizer is not None:
current_text = bpe_tokenizer.decode(valid_ids)
else:
current_text = " ".join([idx2word.get(i, '<unk>') for i in valid_ids])
current_text = clean_response(current_text)
yield "data: " + json_module.dumps({"text": current_text, "done": False}, ensure_ascii=False) + "\n\n"
await asyncio.sleep(0.04)
if not valid_ids:
yield "data: " + json_module.dumps({"text": "Mình chưa học tới phần này. Bạn hỏi câu khác nhé!", "done": True}, ensure_ascii=False) + "\n\n"
else:
yield "data: " + json_module.dumps({"done": True}, ensure_ascii=False) + "\n\n"
def load_model():
global model, word2idx, idx2word, device, model_info
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from model import make_model
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if not os.path.exists(VOCAB_PATH):
print(f"[WARN] Không tìm thấy vocab: {VOCAB_PATH}")
return False
if not os.path.exists(MODEL_PATH):
print(f"[WARN] Không tìm thấy model: {MODEL_PATH}")
return False
with open(VOCAB_PATH, 'rb') as f:
vocab = pickle.load(f)
word2idx = vocab['word2idx']
idx2word = vocab['idx2word']
ckpt = torch.load(MODEL_PATH, map_location=device)
d_m = ckpt.get('d_m', 768)
n_l = ckpt.get('n_l', 12)
n_h = ckpt.get('n_h', 12)
dr = ckpt.get('dr', 0.1)
model = make_model(len(word2idx), n_layer=n_l, d_model=d_m, n_head=n_h, dropout=dr)
model.load_state_dict(ckpt['model_state_dict'])
model.to(device)
model.eval()
model_info = {
"vocab_size": len(word2idx),
"d_model": d_m,
"n_layers": n_l,
"n_heads": n_h,
"device": str(device),
}
print(f"[OK] Model loaded on {device} | d_model={d_m} | layers={n_l}")
return True
# ═══════════════════════════════════════
# FASTAPI APP
# ═══════════════════════════════════════
@asynccontextmanager
async def lifespan(app: FastAPI):
success = load_model()
load_bpe_tokenizer()
init_db()
if success:
print("[INFO] Server ready.")
else:
print("[WARN] Server khởi động KHÔNG có model.")
yield
app = FastAPI(title="Toán Rời Rạc Chatbot", version="3.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Health ──
@app.get("/api/health")
async def health_check():
return {
"status": "ok",
"model_loaded": model is not None,
"model_info": model_info,
"bpe_tokenizer": bpe_tokenizer is not None,
}
# ── Sessions ──
@app.get("/api/sessions")
async def list_sessions():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT s.id, s.title, s.created_at, s.updated_at,
(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) as msg_count
FROM sessions s ORDER BY s.updated_at DESC
""")
rows = c.fetchall()
conn.close()
return [dict(r) for r in rows]
@app.post("/api/sessions")
async def create_session_endpoint():
sid = create_session()
return {"id": sid, "title": "Chat mới"}
@app.delete("/api/sessions/{session_id}")
async def delete_session(session_id: str = Path(...)):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("PRAGMA foreign_keys = ON")
c.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
c.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.commit()
conn.close()
return {"status": "ok"}
@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str = Path(...)):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("SELECT role, content, timestamp FROM messages WHERE session_id = ? ORDER BY id ASC",
(session_id,))
rows = c.fetchall()
conn.close()
return [dict(r) for r in rows]
# ── Streaming Chat ──
class StreamChatRequest(BaseModel):
message: str
session_id: Optional[str] = None
temperature: float = 0.5
top_k: int = 3
top_p: float = 0.9
repetition_penalty: float = 1.3
max_tokens: int = 100
@app.post("/api/chat/stream")
async def chat_stream(req: StreamChatRequest):
if model is None:
raise HTTPException(status_code=503, detail="Model chưa được load.")
message = req.message.strip()
if not message:
raise HTTPException(status_code=400, detail="Tin nhắn không được để trống.")
# Create or use existing session
session_id = req.session_id
is_new_session = False
if not session_id:
session_id = create_session(message[:40])
is_new_session = True
# Save user message
save_message(session_id, "user", message)
# If new session, update title with first message
if is_new_session:
title = message[:40] + ("..." if len(message) > 40 else "")
update_session_title(session_id, title)
async def event_stream():
# Send a 1024-byte padding comment to bypass browser buffering
yield f": {' ' * 1024}\n\n"
# Send session info
yield f"data: {json_module.dumps({'type': 'session', 'session_id': session_id, 'is_new': is_new_session})}\n\n"
# Send thinking indicator
yield f"data: {json_module.dumps({'type': 'thinking'})}\n\n"
await asyncio.sleep(0.05)
# Generate response using real streaming
token_count = 0
start_time = time.perf_counter()
final_response = ""
async for chunk in generate_response_stream(
message,
temperature=req.temperature,
top_k=req.top_k,
top_p=req.top_p,
repetition_penalty=req.repetition_penalty,
max_tokens=req.max_tokens,
):
# Parse chunk JSON
data = json_module.loads(chunk[6:]) # strip 'data: '
if not data.get("done"):
final_response = data["text"]
yield f"data: {json_module.dumps({'type': 'token', 'content': final_response})}\n\n"
else:
if "text" in data:
final_response = data["text"]
yield f"data: {json_module.dumps({'type': 'token', 'content': final_response})}\n\n"
token_count += 1
elapsed_ms = (time.perf_counter() - start_time) * 1000
# Save bot response
save_message(session_id, "bot", final_response)
# Send done
yield f"data: {json_module.dumps({'type': 'done', 'tokens': token_count, 'time_ms': elapsed_ms, 'session_id': session_id})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff"
}
)
# ── Legacy non-streaming (backward compat) ──
class ChatRequest(BaseModel):
message: str
session_id: Optional[str] = None
history: Optional[list] = None
temperature: float = 0.5
top_k: int = 50
top_p: float = 0.9
repetition_penalty: float = 1.3
max_tokens: int = 100
@app.post("/api/chat")
async def chat(req: ChatRequest):
if model is None:
raise HTTPException(status_code=503, detail="Model chưa được load.")
message = req.message.strip()
if not message:
raise HTTPException(status_code=400, detail="Tin nhắn không được để trống.")
response, token_count, elapsed_ms = generate_response(
message, req.temperature, req.top_k, req.top_p,
req.repetition_penalty, req.max_tokens
)
return {"response": response, "tokens_generated": token_count, "inference_time_ms": elapsed_ms}
# ── Static files ──
web_dir = os.path.join(os.path.dirname(__file__), "web")
if os.path.isdir(web_dir):
app.mount("/assets", StaticFiles(directory=os.path.join(web_dir, "assets")), name="assets")
@app.get("/")
async def serve_frontend():
return FileResponse(
os.path.join(web_dir, "index.html"),
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("api:app", host="127.0.0.1", port=8000, reload=True)