acesmile commited on
Commit
87b294c
·
verified ·
1 Parent(s): 9d0a70b

upload 5 files for deploy

Browse files
Files changed (5) hide show
  1. Dockerfile +20 -0
  2. building_retriever.py +351 -0
  3. config.py +16 -0
  4. main.py +223 -0
  5. requirements.txt +76 -0
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dùng môi trường Python 3.10
2
+ FROM python:3.10
3
+
4
+ # Cài đặt quyền User (Bắt buộc trên Hugging Face để bảo mật)
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ # Thiết lập thư mục làm việc
10
+ WORKDIR /app
11
+
12
+ # Copy và cài đặt các thư viện
13
+ COPY --chown=user ./requirements.txt requirements.txt
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Copy toàn bộ code của bạn vào
17
+ COPY --chown=user . /app
18
+
19
+ # Chạy FastAPI. LƯU Ý: Hugging Face BẮT BUỘC dùng cổng 7860
20
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
building_retriever.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Optional, Dict, List, Any
3
+ import json, re
4
+ import unicodedata
5
+
6
+ from qdrant_client.http import models as qdm
7
+ from qdrant_client import QdrantClient
8
+ from langchain_qdrant import QdrantVectorStore, FastEmbedSparse
9
+ from langchain_huggingface import HuggingFaceEmbeddings
10
+ from sentence_transformers import CrossEncoder
11
+ from config import QDRANT_URL, QDRANT_API_KEY, COLLECTION_NAME, EMBED_MODEL, RERANK_MODEL, GROQ_API_KEY
12
+ from groq import Groq
13
+
14
+ LLM_MODEL = "llama-3.3-70b-versatile"
15
+
16
+ # ========================== INIT MODELS ==========================
17
+
18
+ client = Groq(api_key="GROQ_API_KEY")
19
+
20
+ client_qdrant = QdrantClient(
21
+ url=QDRANT_URL,
22
+ api_key=QDRANT_API_KEY,
23
+ )
24
+
25
+ embeddings = HuggingFaceEmbeddings(
26
+ model_name=EMBED_MODEL,
27
+ encode_kwargs={"normalize_embeddings": True},
28
+ )
29
+
30
+ sparse_embeddings = FastEmbedSparse(model_name="Qdrant/bm25")
31
+
32
+ for field in ["province", "type"]:
33
+ client_qdrant.create_payload_index(
34
+ collection_name=COLLECTION_NAME,
35
+ field_name=field,
36
+ field_schema=qdm.PayloadSchemaType.KEYWORD,
37
+ )
38
+
39
+ vectorstore = QdrantVectorStore(
40
+ client=client_qdrant,
41
+ collection_name=COLLECTION_NAME,
42
+ embedding=embeddings,
43
+ sparse_embedding=sparse_embeddings,
44
+ vector_name="dense",
45
+ sparse_vector_name="sparse",
46
+ content_payload_key="content",
47
+ metadata_payload_key=None,
48
+ )
49
+
50
+
51
+ def get_provinces_from_qdrant(client, collection_name):
52
+ provinces = set()
53
+ points, _ = client.scroll(
54
+ collection_name=collection_name,
55
+ limit=1000,
56
+ with_payload=True,
57
+ )
58
+ for p in points:
59
+ payload = p.payload
60
+ if payload and "province" in payload:
61
+ provinces.add(payload["province"])
62
+ return list(provinces)
63
+
64
+
65
+ reranker = CrossEncoder(RERANK_MODEL)
66
+ PROVINCES = get_provinces_from_qdrant(client_qdrant, COLLECTION_NAME)
67
+ print("Loaded provinces:", PROVINCES)
68
+
69
+
70
+ # ====================== PRE-RETRIEVAL ======================
71
+
72
+ def chat_llm(messages: List[Dict], model=LLM_MODEL) -> str:
73
+ resp = client.chat.completions.create(
74
+ model=model,
75
+ messages=messages,
76
+ temperature=0.1,
77
+ )
78
+ return resp.choices[0].message.content.strip()
79
+
80
+
81
+ def condense_question(query: str, chat_history: List[Dict]) -> str:
82
+ """
83
+ Nếu có lịch sử hội thoại, dùng LLM để viết lại câu hỏi thành
84
+ câu độc lập (standalone), không phụ thuộc ngữ cảnh trước.
85
+ Ví dụ: "còn ăn gì nữa không?" → "ăn gì ở Hà Nội ngoài phở?"
86
+ """
87
+ if not chat_history:
88
+ return query
89
+
90
+ # Chỉ lấy 6 lượt gần nhất để tránh context quá dài
91
+ recent = chat_history[-6:]
92
+ history_text = "\n".join([
93
+ f"{'User' if m['role'] == 'user' else 'Bot'}: {m['content'][:200]}"
94
+ for m in recent
95
+ ])
96
+
97
+ prompt = f"""Dựa vào lịch sử hội thoại bên dưới, hãy viết lại câu hỏi cuối thành câu hỏi độc lập, đầy đủ nghĩa mà không cần đọc lịch sử.
98
+
99
+ Lịch sử:
100
+ {history_text}
101
+
102
+ Câu hỏi hiện tại: "{query}"
103
+
104
+ Yêu cầu:
105
+ - Nếu câu hỏi đã rõ ràng, giữ nguyên
106
+ - Nếu câu hỏi tham chiếu đến thứ đã đề cập trước (ở đó, nơi đó, còn gì nữa...), hãy bổ sung đầy đủ
107
+ - Chỉ trả về câu hỏi đã viết lại, không giải thích
108
+ - Giữ nguyên tiếng Việt"""
109
+
110
+ return chat_llm([{"role": "user", "content": prompt}])
111
+
112
+
113
+ def normalize(text: str) -> str:
114
+ text = text.lower()
115
+ text = unicodedata.normalize("NFD", text)
116
+ text = "".join(c for c in text if unicodedata.category(c) != "Mn")
117
+ return text
118
+
119
+
120
+ def detect_province(query: str, provinces: List[str]) -> Optional[str]:
121
+ q = normalize(query)
122
+ sorted_provinces = sorted(provinces, key=lambda p: len(p), reverse=True)
123
+ for p in sorted_provinces:
124
+ name = normalize(p.replace("_", " "))
125
+ pattern = r'(?<![a-z])' + re.escape(name) + r'(?![a-z])'
126
+ if re.search(pattern, q):
127
+ return p
128
+ return None
129
+
130
+
131
+ def _extract_json(text: str) -> Dict[str, Any]:
132
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
133
+ matches = re.findall(r"\{[^{}]*\}", text)
134
+ for m in matches:
135
+ try:
136
+ return json.loads(m)
137
+ except:
138
+ continue
139
+ return {"type": None}
140
+
141
+
142
+ def route_query_llm(query: str) -> Dict:
143
+ province = detect_province(query, PROVINCES)
144
+
145
+ prompt = f"""Bạn là bộ phân loại intent cho chatbot du lịch Việt Nam.
146
+
147
+ YÊU CẦU:
148
+ - Trả về JSON
149
+ - KHÔNG dùng tiếng Anh ngoài các giá trị enum
150
+ - KHÔNG giải thích
151
+
152
+ {{"type": "destination|food|transportation|accommodation|pricing|schedule|general|null"}}
153
+
154
+ Câu truy vấn: "{query}"
155
+ """
156
+ text = chat_llm([{"role": "user", "content": prompt}])
157
+ data = _extract_json(text)
158
+ detected_type = data.get("type")
159
+
160
+ must_clauses = []
161
+ should_clauses = []
162
+
163
+ if province:
164
+ must_clauses.append(
165
+ qdm.FieldCondition(key="province", match=qdm.MatchValue(value=province))
166
+ )
167
+
168
+ if detected_type and detected_type not in ("null", "general", None):
169
+ should_clauses.append(
170
+ qdm.FieldCondition(key="type", match=qdm.MatchValue(value=detected_type))
171
+ )
172
+ should_clauses.append(
173
+ qdm.FieldCondition(key="type", match=qdm.MatchValue(value="general"))
174
+ )
175
+
176
+ qdrant_filter = None
177
+ if must_clauses or should_clauses:
178
+ qdrant_filter = qdm.Filter(
179
+ must=must_clauses if must_clauses else None,
180
+ should=should_clauses if should_clauses else None,
181
+ )
182
+
183
+ return {
184
+ "province": province,
185
+ "type": detected_type,
186
+ "filter": qdrant_filter,
187
+ }
188
+
189
+
190
+ # ====================== RETRIEVAL ======================
191
+
192
+ def retrieve_mmr(query: str, k=25, fetch_k=60, qdrant_filter=None):
193
+ docs = vectorstore.max_marginal_relevance_search(
194
+ query=query,
195
+ k=k,
196
+ fetch_k=fetch_k,
197
+ filter=qdrant_filter,
198
+ )
199
+ # Fallback nếu filter quá chặt
200
+ if len(docs) < 3 and qdrant_filter is not None:
201
+ print("⚠️ Fallback no-filter")
202
+ docs = vectorstore.max_marginal_relevance_search(
203
+ query=query, k=k, fetch_k=fetch_k
204
+ )
205
+ return docs
206
+
207
+
208
+ # ====================== POST-RETRIEVAL ======================
209
+
210
+ def rerank_bge(query: str, docs, top_n=7):
211
+ if not docs:
212
+ return []
213
+ pairs = [[query, d.page_content] for d in docs]
214
+ scores = reranker.predict(pairs)
215
+ ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
216
+ return [doc for doc, _ in ranked[:top_n]]
217
+
218
+
219
+ def build_context(docs, max_chars=4500) -> str:
220
+ result, total = [], 0
221
+ for i, d in enumerate(docs, 1):
222
+ text = d.page_content
223
+ if total + len(text) > max_chars:
224
+ break
225
+ result.append(f"[CHUNK {i}]\n{text}")
226
+ total += len(text)
227
+ return "\n\n".join(result)
228
+
229
+
230
+ # ====================== GENERATION ======================
231
+
232
+ def generate_answer(
233
+ query: str,
234
+ context: str,
235
+ chat_history: List[Dict],
236
+ ):
237
+ """
238
+ Sinh câu trả lời có stream.
239
+ chat_history: list các dict {"role": "user"|"assistant", "content": "..."}
240
+ """
241
+ system_prompt = (
242
+ "Bạn là trợ lý du lịch chuyên nghiệp, am hiểu sâu sắc về du lịch Việt Nam.\n"
243
+ "Hãy trả lời một cách thân thiện, chính xác, chi tiết và nhiệt tình. "
244
+ "Sử dụng emoji phù hợp 🏖️🍲☕🏞️.\n"
245
+ "Khi người dùng hỏi tiếp theo dựa trên cuộc trò chuyện, hãy nhớ ngữ cảnh trước."
246
+ )
247
+
248
+ rag_prompt = f"""Dưới đây là tài liệu tham khảo:
249
+
250
+ <context>
251
+ {context}
252
+ </context>
253
+
254
+ QUY TẮC:
255
+ 1. TUYỆT ĐỐI CHỈ DÙNG thông tin trong <context>. KHÔNG bịa thêm.
256
+ 2. Liệt kê rõ ràng từng ý, có địa chỉ/giá/giờ nếu context có.
257
+ 3. Nếu <context> không đủ thông tin, hãy nói: "Tôi chưa có đủ thông tin về điều này, bạn có thể hỏi cụ thể hơn không?"
258
+
259
+ Câu hỏi: {query}"""
260
+
261
+ # Xây dựng messages: system + lịch sử (tối đa 6 lượt) + câu hỏi mới
262
+ messages = [{"role": "system", "content": system_prompt}]
263
+
264
+ # Thêm lịch sử hội thoại (chỉ giữ 6 lượt gần nhất tránh vượt context window)
265
+ for msg in chat_history[-6:]:
266
+ messages.append({"role": msg["role"], "content": msg["content"][:600]})
267
+
268
+ # Câu hỏi hiện tại kèm context
269
+ messages.append({"role": "user", "content": rag_prompt})
270
+
271
+ stream = client.chat.completions.create(
272
+ model=LLM_MODEL,
273
+ messages=messages,
274
+ temperature=0.15,
275
+ stream=True,
276
+ )
277
+
278
+ for chunk in stream:
279
+ delta = chunk.choices[0].delta.content
280
+ if delta:
281
+ yield delta
282
+
283
+
284
+ # ====================== FULL PIPELINE ======================
285
+
286
+ def rag_pipeline(query: str, chat_history: List[Dict] = None):
287
+ """
288
+ Args:
289
+ query: Câu hỏi hiện tại của user
290
+ chat_history: List [{"role": "user"|"assistant", "content": "..."}]
291
+ Truyền vào từ session_state của Streamlit
292
+ Yields:
293
+ str — từng chunk text để stream ra UI
294
+ """
295
+ if chat_history is None:
296
+ chat_history = []
297
+
298
+ print(f"\n👤 User: {query}")
299
+
300
+ # 1. Condense: viết lại câu hỏi thành standalone nếu có lịch sử
301
+ standalone = condense_question(query, chat_history)
302
+ print(f"📝 Standalone: {standalone}")
303
+
304
+ # 2. Routing
305
+ route_info = route_query_llm(standalone)
306
+ print(f"🗺️ Route: province={route_info['province']} | type={route_info['type']}")
307
+
308
+ # 3. Retrieve
309
+ docs = retrieve_mmr(
310
+ query=standalone,
311
+ k=25,
312
+ fetch_k=60,
313
+ qdrant_filter=route_info["filter"],
314
+ )
315
+ print(f"📚 Retrieved: {len(docs)}")
316
+
317
+ # 4. Rerank
318
+ reranked = rerank_bge(standalone, docs, top_n=7)
319
+ print(f"🔝 Reranked: {len(reranked)}")
320
+
321
+ # 5. Build context + generate
322
+ ctx = build_context(reranked)
323
+ yield from generate_answer(query, ctx, chat_history)
324
+
325
+
326
+ # ====================== CLI ======================
327
+
328
+ if __name__ == "__main__":
329
+ print("🤖 Travel Chatbot ready! (gõ 'exit' để thoát)\n")
330
+
331
+ history: List[Dict] = []
332
+
333
+ while True:
334
+ query = input("👤 Bạn: ").strip()
335
+ if query.lower() in ["exit", "quit", "q"]:
336
+ print("👋 Tạm biệt!")
337
+ break
338
+
339
+ print("🤖 Chatbot: ", end="", flush=True)
340
+ answer_chunks = []
341
+
342
+ for chunk in rag_pipeline(query, chat_history=history):
343
+ print(chunk, end="", flush=True)
344
+ answer_chunks.append(chunk)
345
+
346
+ full_answer = "".join(answer_chunks)
347
+ print("\n" + "-" * 50)
348
+
349
+ # Cập nhật lịch sử
350
+ history.append({"role": "user", "content": query})
351
+ history.append({"role": "assistant", "content": full_answer})
config.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+ QDRANT_URL = os.getenv("QDRANT_URL")
7
+ QDRANT_API_KEY = os.getenv("QDRANT_API_KEY")
8
+ COLLECTION_NAME = os.getenv("COLLECTION_NAME")
9
+ EMBED_MODEL = os.getenv("EMBED_MODEL")
10
+ RERANK_MODEL = os.getenv("RERANK_MODEL")
11
+ HF_TOKEN = os.getenv("HF_TOKEN")
12
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
13
+
14
+
15
+
16
+
main.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import json
3
+ import uuid
4
+ import os
5
+ from datetime import datetime
6
+ from typing import List, Dict, Optional
7
+
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import StreamingResponse
11
+ from pydantic import BaseModel
12
+
13
+ from RAG_pipeline.building_retriever import rag_pipeline
14
+
15
+ # ── App ───────────────────────────────────────────────────────────────────────
16
+ app = FastAPI(
17
+ title="Vietnam Tourism Chatbot API",
18
+ version="1.0.0",
19
+ )
20
+
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"], # production: đổi thành domain FE cụ thể
24
+ allow_methods=["*"],
25
+ allow_headers=["*"],
26
+ )
27
+
28
+ # ── In-memory session store ───────────────────────────────────────────────────
29
+ # Key: session_id → Value: { "history": [...], "created_at": ..., "title": ... }
30
+ SESSIONS: Dict[str, dict] = {}
31
+
32
+ SESSIONS_DIR = "chat_sessions"
33
+ os.makedirs(SESSIONS_DIR, exist_ok=True)
34
+
35
+
36
+ # ══════════════════════════════════════════════════════════════════════════════
37
+ # Schemas
38
+ # ══════════════════════════════════════════════════════════════════════════════
39
+
40
+ class ChatRequest(BaseModel):
41
+ session_id: Optional[str] = None # None → tạo session mới
42
+ query: str
43
+
44
+ class SessionCreateResponse(BaseModel):
45
+ session_id: str
46
+ created_at: str
47
+
48
+ class SessionInfo(BaseModel):
49
+ session_id: str
50
+ title: str
51
+ created_at: str
52
+ updated_at: str
53
+ message_count: int
54
+
55
+ class Message(BaseModel):
56
+ role: str # "user" | "assistant"
57
+ content: str
58
+
59
+
60
+ # ══════════════════════════════════════════════════════════════════════════════
61
+ # Session helpers
62
+ # ══════════════════════════════════════════════════════════════════════════════
63
+
64
+ def _get_or_create_session(session_id: Optional[str]) -> str:
65
+ if session_id and session_id in SESSIONS:
66
+ return session_id
67
+
68
+ # Tạo mới
69
+ sid = str(uuid.uuid4())[:8]
70
+ SESSIONS[sid] = {
71
+ "title": "Phiên chat mới",
72
+ "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
73
+ "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
74
+ "history": [], # [{"role": "user"|"assistant", "content": "..."}]
75
+ }
76
+ _persist_session(sid)
77
+ return sid
78
+
79
+
80
+ def _persist_session(sid: str):
81
+ path = os.path.join(SESSIONS_DIR, f"{sid}.json")
82
+ data = {"id": sid, **SESSIONS[sid]}
83
+ with open(path, "w", encoding="utf-8") as f:
84
+ json.dump(data, f, ensure_ascii=False, indent=2)
85
+
86
+
87
+ def _load_sessions_from_disk():
88
+ """Load tất cả phiên đã lưu khi server khởi động."""
89
+ for fname in os.listdir(SESSIONS_DIR):
90
+ if not fname.endswith(".json"):
91
+ continue
92
+ try:
93
+ with open(os.path.join(SESSIONS_DIR, fname), encoding="utf-8") as f:
94
+ data = json.load(f)
95
+ sid = data.pop("id")
96
+ SESSIONS[sid] = data
97
+ except Exception:
98
+ continue
99
+
100
+
101
+ # Load sessions khi startup
102
+ @app.on_event("startup")
103
+ async def startup():
104
+ _load_sessions_from_disk()
105
+ print(f"✅ Loaded {len(SESSIONS)} sessions from disk")
106
+
107
+
108
+ # ══════════════════════════════════════════════════════════════════════════════
109
+ # Endpoints
110
+ # ══════════════════════════════════════════════════════════════════════════════
111
+
112
+ @app.get("/")
113
+ def health():
114
+ return {"status": "ok", "service": "Vietnam Tourism Chatbot"}
115
+
116
+
117
+ # ── Session management ────────────────────────────────────────────────────────
118
+
119
+ @app.post("/sessions", response_model=SessionCreateResponse)
120
+ def create_session():
121
+ """Tạo phiên chat mới."""
122
+ sid = _get_or_create_session(None)
123
+ return {"session_id": sid, "created_at": SESSIONS[sid]["created_at"]}
124
+
125
+
126
+ @app.get("/sessions", response_model=List[SessionInfo])
127
+ def list_sessions():
128
+ """Lấy danh sách tất cả phiên, sort mới nhất lên đầu."""
129
+ result = []
130
+ for sid, s in SESSIONS.items():
131
+ result.append(SessionInfo(
132
+ session_id= sid,
133
+ title= s.get("title", "Phiên chat mới"),
134
+ created_at= s.get("created_at", ""),
135
+ updated_at= s.get("updated_at", ""),
136
+ message_count= len(s.get("history", [])),
137
+ ))
138
+ return sorted(result, key=lambda x: x.updated_at, reverse=True)
139
+
140
+
141
+ @app.get("/sessions/{session_id}/messages", response_model=List[Message])
142
+ def get_messages(session_id: str):
143
+ """Lấy toàn bộ lịch sử chat của một phiên."""
144
+ if session_id not in SESSIONS:
145
+ raise HTTPException(status_code=404, detail="Session không tồn tại")
146
+ return SESSIONS[session_id].get("history", [])
147
+
148
+
149
+ @app.delete("/sessions/{session_id}")
150
+ def delete_session(session_id: str):
151
+ """Xóa phiên chat."""
152
+ if session_id not in SESSIONS:
153
+ raise HTTPException(status_code=404, detail="Session không tồn tại")
154
+ SESSIONS.pop(session_id)
155
+ path = os.path.join(SESSIONS_DIR, f"{session_id}.json")
156
+ if os.path.exists(path):
157
+ os.remove(path)
158
+ return {"deleted": session_id}
159
+
160
+
161
+ # ── Chat (streaming SSE) ──────────────────────────────────────────────────────
162
+
163
+ @app.post("/chat")
164
+ def chat(req: ChatRequest):
165
+ """
166
+ Gửi câu hỏi, nhận câu trả lời dạng SSE stream.
167
+
168
+ Client đọc từng event:
169
+ data: {"token": "Xin", "done": false}
170
+ data: {"token": " chào", "done": false}
171
+ ...
172
+ data: {"token": "", "done": true, "session_id": "abc12345"}
173
+ """
174
+ sid = _get_or_create_session(req.session_id)
175
+ session = SESSIONS[sid]
176
+ history: List[Dict] = session.get("history", [])
177
+
178
+ def event_stream():
179
+ full_answer_parts = []
180
+
181
+ try:
182
+ for token in rag_pipeline(query=req.query, chat_history=history):
183
+ full_answer_parts.append(token)
184
+ payload = json.dumps({"token": token, "done": False}, ensure_ascii=False)
185
+ yield f"data: {payload}\n\n"
186
+
187
+ except Exception as e:
188
+ err = json.dumps({"error": str(e), "done": True})
189
+ yield f"data: {err}\n\n"
190
+ return
191
+
192
+ # Stream xong → cập nhật history + auto-title + persist
193
+ full_answer = "".join(full_answer_parts)
194
+
195
+ history.append({"role": "user", "content": req.query})
196
+ history.append({"role": "assistant", "content": full_answer})
197
+ session["history"] = history
198
+ session["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
199
+
200
+ # Auto-title từ câu hỏi đầu tiên
201
+ if session["title"] == "Phiên chat mới":
202
+ title = req.query.strip()
203
+ session["title"] = title[:45] + "…" if len(title) > 45 else title
204
+
205
+ _persist_session(sid)
206
+
207
+ # Done event
208
+ done_payload = json.dumps({
209
+ "token": "",
210
+ "done": True,
211
+ "session_id": sid,
212
+ "title": session["title"],
213
+ }, ensure_ascii=False)
214
+ yield f"data: {done_payload}\n\n"
215
+
216
+ return StreamingResponse(
217
+ event_stream(),
218
+ media_type="text/event-stream",
219
+ headers={
220
+ "Cache-Control": "no-cache",
221
+ "X-Accel-Buffering": "no", # tắt nginx buffer nếu có
222
+ },
223
+ )
requirements.txt ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ asttokens==3.0.0
2
+ attrs==25.3.0
3
+ beautifulsoup4==4.14.2
4
+ certifi==2025.8.3
5
+ cffi==2.0.0
6
+ chardet==5.2.0pi
7
+ charset-normalizer==3.4.3
8
+ chromedriver-autoinstaller==0.6.4
9
+ colorama==0.4.6
10
+ comm==0.2.3
11
+ cssselect==1.3.0
12
+ debugpy==1.8.17
13
+ decorator==5.2.1
14
+ executing==2.2.1
15
+ h11==0.16.0
16
+ idna==3.10
17
+ ipykernel==6.30.1
18
+ ipython==9.6.0
19
+ ipython_pygments_lexers==1.1.1
20
+ jedi==0.19.2
21
+ jupyter_client==8.6.3
22
+ jupyter_core==5.8.1
23
+ lxml==6.0.2
24
+ lxml_html_clean==0.4.2
25
+ matplotlib-inline==0.1.7
26
+ nest-asyncio==1.6.0
27
+ outcome==1.3.0.post0
28
+ packaging==25.0
29
+ parso==0.8.5
30
+ platformdirs==4.4.0
31
+ prompt_toolkit==3.0.52
32
+ psutil==7.1.0
33
+ pure_eval==0.2.3
34
+ pycparser==2.23
35
+ Pygments==2.19.2
36
+ PySocks==1.7.1
37
+ python-dateutil==2.9.0.post0
38
+ python-dotenv==1.1.1
39
+ pywin32==311
40
+ pyzmq==27.1.0
41
+ readability-lxml==0.8.4.1
42
+ requests==2.32.5
43
+ selenium==4.35.0
44
+ six==1.17.0
45
+ sniffio==1.3.1
46
+ sortedcontainers==2.4.0
47
+ soupsieve==2.8
48
+ stack-data==0.6.3
49
+ tornado==6.5.2
50
+ traitlets==5.14.3
51
+ trio==0.30.0
52
+ trio-websocket==0.12.2
53
+ typing_extensions==4.14.1
54
+ urllib3==2.5.0
55
+ wcwidth==0.2.14
56
+ webdriver-manager==4.0.2
57
+ websocket-client==1.8.0
58
+ wsproto==1.2.0
59
+ markdownify
60
+ langchain
61
+ langchain-text-splitters
62
+ unsloth
63
+ unsloth-zoo
64
+ torch
65
+ transformers
66
+ datasets
67
+ trl
68
+ accelerate
69
+ bitsandbytes
70
+ peft
71
+ sentencepiece
72
+ matplotlib
73
+ llmcompressor
74
+ torch
75
+
76
+