Spaces:
Running
Running
HaRin2806
commited on
Commit
·
8275526
1
Parent(s):
e467988
upload backend
Browse files- .env +5 -0
- Dockerfile +41 -0
- api/__init__.py +0 -0
- api/__pycache__/__init__.cpython-39.pyc +0 -0
- api/__pycache__/admin.cpython-39.pyc +0 -0
- api/__pycache__/auth.cpython-39.pyc +0 -0
- api/__pycache__/chat.cpython-39.pyc +0 -0
- api/__pycache__/data.cpython-39.pyc +0 -0
- api/__pycache__/feedback.cpython-39.pyc +0 -0
- api/__pycache__/history.cpython-39.pyc +0 -0
- api/admin.py +2385 -0
- api/auth.py +508 -0
- api/chat.py +399 -0
- api/data.py +58 -0
- api/feedback.py +117 -0
- api/history.py +777 -0
- app.py +144 -0
- config.py +55 -0
- core/__init__.py +0 -0
- core/data_processor.py +530 -0
- core/embedding_model.py +336 -0
- core/rag_pipeline.py +298 -0
- embed_data.log +0 -0
- models/__init__.py +0 -0
- models/admin_model.py +306 -0
- models/conversation_model.py +731 -0
- models/feedback_model.py +299 -0
- models/user_model.py +349 -0
- requirements.txt +0 -0
- scripts/__init__.py +0 -0
- scripts/embed_data.py +112 -0
- scripts/init_admin.py +107 -0
- testapi.py +17 -0
.env
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
GEMINI_API_KEY=AIzaSyBW0gv3-bwuKE05MP8y0N0lA2Y2KpyTvCs
|
2 |
+
MONGO_URI=mongodb+srv://linhha2705:[email protected]/
|
3 |
+
MONGO_DB_NAME=nutribot_db
|
4 |
+
JWT_SECRET_KEY=hathimylinh
|
5 |
+
OPENAI_API_KEY=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
|
Dockerfile
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Sử dụng bookworm thay vì bullseye vì có SQLite3 phiên bản mới hơn
|
2 |
+
FROM python:3.9-slim-bookworm
|
3 |
+
|
4 |
+
# Thiết lập working directory
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Thiết lập environment variables
|
8 |
+
ENV PYTHONPATH=/app
|
9 |
+
ENV ENVIRONMENT=production
|
10 |
+
ENV PYTHONUNBUFFERED=1
|
11 |
+
|
12 |
+
# Cài đặt system dependencies cần thiết cho ChromaDB
|
13 |
+
RUN apt-get update && apt-get install -y \
|
14 |
+
build-essential \
|
15 |
+
gcc \
|
16 |
+
g++ \
|
17 |
+
cmake \
|
18 |
+
sqlite3 \
|
19 |
+
libsqlite3-dev \
|
20 |
+
&& rm -rf /var/lib/apt/lists/*
|
21 |
+
|
22 |
+
# Copy requirements và cài đặt Python dependencies
|
23 |
+
COPY requirements.txt .
|
24 |
+
|
25 |
+
# Cài đặt pysqlite3-binary trước để override SQLite3 nếu cần
|
26 |
+
RUN pip install --no-cache-dir pysqlite3-binary
|
27 |
+
|
28 |
+
# Cài đặt các dependencies khác
|
29 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
30 |
+
|
31 |
+
# Copy source code
|
32 |
+
COPY . .
|
33 |
+
|
34 |
+
# Tạo thư mục cần thiết
|
35 |
+
RUN mkdir -p logs uploads chroma_data
|
36 |
+
|
37 |
+
# Expose port
|
38 |
+
EXPOSE 7860
|
39 |
+
|
40 |
+
# Chạy ứng dụng
|
41 |
+
CMD ["python", "app.py"]
|
api/__init__.py
ADDED
File without changes
|
api/__pycache__/__init__.cpython-39.pyc
ADDED
Binary file (122 Bytes). View file
|
|
api/__pycache__/admin.cpython-39.pyc
ADDED
Binary file (51 kB). View file
|
|
api/__pycache__/auth.cpython-39.pyc
ADDED
Binary file (10.9 kB). View file
|
|
api/__pycache__/chat.cpython-39.pyc
ADDED
Binary file (8.42 kB). View file
|
|
api/__pycache__/data.cpython-39.pyc
ADDED
Binary file (1.94 kB). View file
|
|
api/__pycache__/feedback.cpython-39.pyc
ADDED
Binary file (2.84 kB). View file
|
|
api/__pycache__/history.cpython-39.pyc
ADDED
Binary file (14.8 kB). View file
|
|
api/admin.py
ADDED
@@ -0,0 +1,2385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Blueprint, request, jsonify
|
2 |
+
import logging
|
3 |
+
import datetime
|
4 |
+
import os
|
5 |
+
from bson.objectid import ObjectId
|
6 |
+
from models.user_model import User
|
7 |
+
from models.conversation_model import get_db, Conversation
|
8 |
+
from flask_jwt_extended import jwt_required, get_jwt_identity
|
9 |
+
from functools import wraps
|
10 |
+
import json
|
11 |
+
from core.embedding_model import get_embedding_model
|
12 |
+
from werkzeug.utils import secure_filename
|
13 |
+
import google.generativeai as genai
|
14 |
+
from config import GEMINI_API_KEY
|
15 |
+
import time
|
16 |
+
import sys
|
17 |
+
from models.feedback_model import Feedback
|
18 |
+
|
19 |
+
# Thiết lập logging
|
20 |
+
logger = logging.getLogger(__name__)
|
21 |
+
|
22 |
+
# Tạo blueprint
|
23 |
+
admin_routes = Blueprint('admin', __name__)
|
24 |
+
|
25 |
+
# Configure Gemini
|
26 |
+
genai.configure(api_key=GEMINI_API_KEY)
|
27 |
+
|
28 |
+
def setup_debug_logging():
|
29 |
+
"""Thiết lập logging debug cho Gemini response"""
|
30 |
+
log_dir = "logs"
|
31 |
+
if not os.path.exists(log_dir):
|
32 |
+
os.makedirs(log_dir)
|
33 |
+
return log_dir
|
34 |
+
|
35 |
+
def save_gemini_response_to_file(response_text, parsed_data, doc_id):
|
36 |
+
"""Lưu response của Gemini vào file để debug"""
|
37 |
+
try:
|
38 |
+
log_dir = setup_debug_logging()
|
39 |
+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
40 |
+
|
41 |
+
# Tạo tên file log
|
42 |
+
log_filename = f"gemini_response_{doc_id}_{timestamp}.log"
|
43 |
+
log_path = os.path.join(log_dir, log_filename)
|
44 |
+
|
45 |
+
with open(log_path, 'w', encoding='utf-8') as f:
|
46 |
+
f.write("=" * 80 + "\n")
|
47 |
+
f.write(f"GEMINI RESPONSE DEBUG LOG\n")
|
48 |
+
f.write(f"Document ID: {doc_id}\n")
|
49 |
+
f.write(f"Timestamp: {datetime.datetime.now().isoformat()}\n")
|
50 |
+
f.write("=" * 80 + "\n\n")
|
51 |
+
|
52 |
+
f.write("RAW RESPONSE FROM GEMINI:\n")
|
53 |
+
f.write("-" * 40 + "\n")
|
54 |
+
f.write(response_text)
|
55 |
+
f.write("\n\n")
|
56 |
+
|
57 |
+
f.write("PARSED JSON DATA:\n")
|
58 |
+
f.write("-" * 40 + "\n")
|
59 |
+
f.write(json.dumps(parsed_data, indent=2, ensure_ascii=False))
|
60 |
+
f.write("\n\n")
|
61 |
+
|
62 |
+
# Debug chunks content vs summary
|
63 |
+
f.write("CHUNKS CONTENT vs SUMMARY ANALYSIS:\n")
|
64 |
+
f.write("-" * 40 + "\n")
|
65 |
+
for i, chunk in enumerate(parsed_data.get('chunks', [])[:3]): # Chỉ log 3 chunks đầu
|
66 |
+
f.write(f"CHUNK {i+1} ({chunk.get('id', 'no-id')}):\n")
|
67 |
+
f.write(f"Title: {chunk.get('title', 'no-title')}\n")
|
68 |
+
f.write(f"Summary Length: {len(chunk.get('summary', ''))}\n")
|
69 |
+
f.write(f"Content Length: {len(chunk.get('content', ''))}\n")
|
70 |
+
f.write(f"Summary: {chunk.get('summary', 'no-summary')[:200]}...\n")
|
71 |
+
f.write(f"Content: {chunk.get('content', 'no-content')[:200]}...\n")
|
72 |
+
f.write("\n")
|
73 |
+
|
74 |
+
# Debug tables
|
75 |
+
if parsed_data.get('tables'):
|
76 |
+
f.write("TABLES ANALYSIS:\n")
|
77 |
+
f.write("-" * 40 + "\n")
|
78 |
+
for i, table in enumerate(parsed_data.get('tables', [])[:2]):
|
79 |
+
f.write(f"TABLE {i+1} ({table.get('id', 'no-id')}):\n")
|
80 |
+
f.write(f"Title: {table.get('title', 'no-title')}\n")
|
81 |
+
f.write(f"Summary: {table.get('summary', 'no-summary')[:100]}...\n")
|
82 |
+
f.write(f"Content: {table.get('content', 'no-content')[:100]}...\n")
|
83 |
+
f.write("\n")
|
84 |
+
|
85 |
+
logger.info(f"Saved Gemini response debug log to: {log_path}")
|
86 |
+
return log_path
|
87 |
+
|
88 |
+
except Exception as e:
|
89 |
+
logger.error(f"Error saving debug log: {e}")
|
90 |
+
return None
|
91 |
+
|
92 |
+
def require_admin(f):
|
93 |
+
"""Decorator để kiểm tra quyền admin"""
|
94 |
+
@wraps(f)
|
95 |
+
@jwt_required()
|
96 |
+
def decorated_function(*args, **kwargs):
|
97 |
+
try:
|
98 |
+
user_id = get_jwt_identity()
|
99 |
+
user = User.find_by_id(user_id)
|
100 |
+
|
101 |
+
if not user or not user.is_admin():
|
102 |
+
return jsonify({
|
103 |
+
"success": False,
|
104 |
+
"error": "Không có quyền truy cập admin"
|
105 |
+
}), 403
|
106 |
+
|
107 |
+
request.current_user = user
|
108 |
+
return f(*args, **kwargs)
|
109 |
+
except Exception as e:
|
110 |
+
logger.error(f"Lỗi xác thực admin: {e}")
|
111 |
+
return jsonify({
|
112 |
+
"success": False,
|
113 |
+
"error": "Lỗi xác thực"
|
114 |
+
}), 500
|
115 |
+
return decorated_function
|
116 |
+
|
117 |
+
# ===== DASHBOARD ROUTES =====
|
118 |
+
|
119 |
+
@admin_routes.route('/stats/overview', methods=['GET'])
|
120 |
+
@require_admin
|
121 |
+
def get_overview_stats():
|
122 |
+
"""Lấy thống kê tổng quan cho dashboard"""
|
123 |
+
try:
|
124 |
+
db = get_db()
|
125 |
+
|
126 |
+
# Sử dụng timezone Việt Nam (UTC+7)
|
127 |
+
import pytz
|
128 |
+
vietnam_tz = pytz.timezone('Asia/Ho_Chi_Minh')
|
129 |
+
now_vietnam = datetime.datetime.now(vietnam_tz)
|
130 |
+
|
131 |
+
# Thống kê users thực tế
|
132 |
+
total_users = db.users.count_documents({}) if hasattr(db, 'users') else 0
|
133 |
+
today_start = now_vietnam.replace(hour=0, minute=0, second=0, microsecond=0)
|
134 |
+
new_users_today = db.users.count_documents({
|
135 |
+
"created_at": {"$gte": today_start.astimezone(pytz.UTC).replace(tzinfo=None)}
|
136 |
+
}) if hasattr(db, 'users') else 0
|
137 |
+
|
138 |
+
# Thống kê conversations thực tế
|
139 |
+
total_conversations = db.conversations.count_documents({})
|
140 |
+
day_ago = now_vietnam - datetime.timedelta(days=1)
|
141 |
+
recent_conversations = db.conversations.count_documents({
|
142 |
+
"updated_at": {"$gte": day_ago.astimezone(pytz.UTC).replace(tzinfo=None)}
|
143 |
+
})
|
144 |
+
|
145 |
+
# Thống kê tin nhắn thực tế
|
146 |
+
pipeline = [
|
147 |
+
{"$project": {"message_count": {"$size": "$messages"}}},
|
148 |
+
{"$group": {"_id": None, "total_messages": {"$sum": "$message_count"}}}
|
149 |
+
]
|
150 |
+
message_result = list(db.conversations.aggregate(pipeline))
|
151 |
+
total_messages = message_result[0]["total_messages"] if message_result else 0
|
152 |
+
|
153 |
+
# Thống kê admin thực tế
|
154 |
+
total_admins = db.users.count_documents({"role": "admin"}) if hasattr(db, 'users') else 0
|
155 |
+
|
156 |
+
# Thống kê theo tuần (7 ngày gần nhất) - SỬA LẠI
|
157 |
+
daily_stats = []
|
158 |
+
for i in range(7):
|
159 |
+
# Tính ngày theo timezone Việt Nam
|
160 |
+
target_date = now_vietnam.date() - datetime.timedelta(days=6-i)
|
161 |
+
day_start = vietnam_tz.localize(datetime.datetime.combine(target_date, datetime.time.min))
|
162 |
+
day_end = vietnam_tz.localize(datetime.datetime.combine(target_date, datetime.time.max))
|
163 |
+
|
164 |
+
# Convert về UTC để query MongoDB
|
165 |
+
day_start_utc = day_start.astimezone(pytz.UTC).replace(tzinfo=None)
|
166 |
+
day_end_utc = day_end.astimezone(pytz.UTC).replace(tzinfo=None)
|
167 |
+
|
168 |
+
# Đếm conversations được tạo HOẶC cập nhật trong ngày
|
169 |
+
daily_conversations_created = db.conversations.count_documents({
|
170 |
+
"created_at": {"$gte": day_start_utc, "$lte": day_end_utc}
|
171 |
+
})
|
172 |
+
|
173 |
+
daily_conversations_updated = db.conversations.count_documents({
|
174 |
+
"updated_at": {"$gte": day_start_utc, "$lte": day_end_utc},
|
175 |
+
"created_at": {"$lt": day_start_utc} # Không đếm trùng với conversations mới tạo
|
176 |
+
})
|
177 |
+
|
178 |
+
total_daily_activity = daily_conversations_created + daily_conversations_updated
|
179 |
+
|
180 |
+
daily_users = 0
|
181 |
+
if hasattr(db, 'users'):
|
182 |
+
daily_users = db.users.count_documents({
|
183 |
+
"created_at": {"$gte": day_start_utc, "$lte": day_end_utc}
|
184 |
+
})
|
185 |
+
|
186 |
+
daily_stats.append({
|
187 |
+
"date": target_date.strftime("%Y-%m-%d"),
|
188 |
+
"label": target_date.strftime("%d/%m"),
|
189 |
+
"conversations": total_daily_activity,
|
190 |
+
"users": daily_users,
|
191 |
+
"created": daily_conversations_created,
|
192 |
+
"updated": daily_conversations_updated
|
193 |
+
})
|
194 |
+
|
195 |
+
# Thống kê theo độ tuổi thực tế
|
196 |
+
age_pipeline = [
|
197 |
+
{"$group": {"_id": "$age_context", "count": {"$sum": 1}}},
|
198 |
+
{"$sort": {"_id": 1}}
|
199 |
+
]
|
200 |
+
age_stats = list(db.conversations.aggregate(age_pipeline))
|
201 |
+
|
202 |
+
return jsonify({
|
203 |
+
"success": True,
|
204 |
+
"stats": {
|
205 |
+
"users": {
|
206 |
+
"total": total_users,
|
207 |
+
"new_today": new_users_today,
|
208 |
+
"active": total_users
|
209 |
+
},
|
210 |
+
"conversations": {
|
211 |
+
"total": total_conversations,
|
212 |
+
"recent": recent_conversations
|
213 |
+
},
|
214 |
+
"messages": {
|
215 |
+
"total": total_messages,
|
216 |
+
"avg_per_conversation": round(total_messages / total_conversations, 1) if total_conversations > 0 else 0
|
217 |
+
},
|
218 |
+
"admins": {
|
219 |
+
"total": total_admins
|
220 |
+
},
|
221 |
+
"daily_stats": daily_stats,
|
222 |
+
"age_distribution": [
|
223 |
+
{
|
224 |
+
"age_group": f"{stat['_id']} tuổi" if stat['_id'] else "Không rõ",
|
225 |
+
"count": stat["count"]
|
226 |
+
}
|
227 |
+
for stat in age_stats
|
228 |
+
],
|
229 |
+
"timezone": "Asia/Ho_Chi_Minh",
|
230 |
+
"current_time": now_vietnam.isoformat()
|
231 |
+
}
|
232 |
+
})
|
233 |
+
|
234 |
+
except Exception as e:
|
235 |
+
logger.error(f"Lỗi lấy thống kê tổng quan: {str(e)}")
|
236 |
+
return jsonify({
|
237 |
+
"success": False,
|
238 |
+
"error": str(e)
|
239 |
+
}), 500
|
240 |
+
|
241 |
+
@admin_routes.route('/system-info', methods=['GET'])
|
242 |
+
@require_admin
|
243 |
+
def get_system_info():
|
244 |
+
"""Lấy thông tin hệ thống"""
|
245 |
+
try:
|
246 |
+
from core.embedding_model import get_embedding_model
|
247 |
+
|
248 |
+
# Thông tin vector database
|
249 |
+
try:
|
250 |
+
embedding_model = get_embedding_model()
|
251 |
+
vector_count = embedding_model.count()
|
252 |
+
except:
|
253 |
+
vector_count = 0
|
254 |
+
|
255 |
+
# Thông tin database
|
256 |
+
db = get_db()
|
257 |
+
collections = db.list_collection_names()
|
258 |
+
|
259 |
+
system_info = {
|
260 |
+
"database": {
|
261 |
+
"type": "MongoDB",
|
262 |
+
"collections": len(collections),
|
263 |
+
"status": "active"
|
264 |
+
},
|
265 |
+
"vector_db": {
|
266 |
+
"type": "ChromaDB",
|
267 |
+
"embeddings": vector_count,
|
268 |
+
"model": "multilingual-e5-base"
|
269 |
+
},
|
270 |
+
"ai": {
|
271 |
+
"generation_model": "Gemini 2.0 Flash",
|
272 |
+
"embedding_model": "multilingual-e5-base",
|
273 |
+
"status": "active"
|
274 |
+
}
|
275 |
+
}
|
276 |
+
|
277 |
+
return jsonify({
|
278 |
+
"success": True,
|
279 |
+
"system_info": system_info
|
280 |
+
})
|
281 |
+
|
282 |
+
except Exception as e:
|
283 |
+
logger.error(f"Lỗi lấy thông tin hệ thống: {str(e)}")
|
284 |
+
return jsonify({
|
285 |
+
"success": False,
|
286 |
+
"error": str(e)
|
287 |
+
}), 500
|
288 |
+
|
289 |
+
@admin_routes.route('/alerts', methods=['GET'])
|
290 |
+
@require_admin
|
291 |
+
def get_system_alerts():
|
292 |
+
"""Lấy cảnh báo hệ thống"""
|
293 |
+
try:
|
294 |
+
alerts = []
|
295 |
+
|
296 |
+
# Kiểm tra số lượng conversations
|
297 |
+
db = get_db()
|
298 |
+
total_conversations = db.conversations.count_documents({})
|
299 |
+
|
300 |
+
if total_conversations > 100:
|
301 |
+
alerts.append({
|
302 |
+
"type": "info",
|
303 |
+
"title": "Lượng dữ liệu cao",
|
304 |
+
"message": f"Hệ thống có {total_conversations} cuộc hội thoại",
|
305 |
+
"severity": "low"
|
306 |
+
})
|
307 |
+
|
308 |
+
# Mặc định: hệ thống hoạt động bình thường
|
309 |
+
if not alerts:
|
310 |
+
alerts.append({
|
311 |
+
"type": "info",
|
312 |
+
"title": "Hệ thống hoạt động bình thường",
|
313 |
+
"message": "Tất cả dịch vụ đang chạy ổn định",
|
314 |
+
"severity": "low"
|
315 |
+
})
|
316 |
+
|
317 |
+
return jsonify({
|
318 |
+
"success": True,
|
319 |
+
"alerts": alerts
|
320 |
+
})
|
321 |
+
|
322 |
+
except Exception as e:
|
323 |
+
logger.error(f"Lỗi lấy cảnh báo hệ thống: {str(e)}")
|
324 |
+
return jsonify({
|
325 |
+
"success": False,
|
326 |
+
"error": str(e)
|
327 |
+
}), 500
|
328 |
+
|
329 |
+
# ===== USER MANAGEMENT ROUTES =====
|
330 |
+
|
331 |
+
@admin_routes.route('/users', methods=['GET'])
|
332 |
+
@require_admin
|
333 |
+
def get_all_users():
|
334 |
+
"""Lấy danh sách người dùng với thông tin thực tế"""
|
335 |
+
try:
|
336 |
+
db = get_db()
|
337 |
+
users_collection = db.users
|
338 |
+
|
339 |
+
page = int(request.args.get('page', 1))
|
340 |
+
per_page = int(request.args.get('per_page', 20))
|
341 |
+
search = request.args.get('search', '')
|
342 |
+
gender_filter = request.args.get('gender', '')
|
343 |
+
role_filter = request.args.get('role', '')
|
344 |
+
sort_by = request.args.get('sort_by', 'created_at')
|
345 |
+
sort_order = request.args.get('sort_order', 'desc')
|
346 |
+
|
347 |
+
# Tạo query filter
|
348 |
+
query_filter = {}
|
349 |
+
if search:
|
350 |
+
query_filter["$or"] = [
|
351 |
+
{"name": {"$regex": search, "$options": "i"}},
|
352 |
+
{"email": {"$regex": search, "$options": "i"}}
|
353 |
+
]
|
354 |
+
if gender_filter:
|
355 |
+
query_filter["gender"] = gender_filter
|
356 |
+
if role_filter:
|
357 |
+
query_filter["role"] = role_filter
|
358 |
+
|
359 |
+
# Pagination
|
360 |
+
skip = (page - 1) * per_page
|
361 |
+
sort_direction = -1 if sort_order == 'desc' else 1
|
362 |
+
|
363 |
+
users_cursor = users_collection.find(query_filter).sort(sort_by, sort_direction).skip(skip).limit(per_page)
|
364 |
+
total_users = users_collection.count_documents(query_filter)
|
365 |
+
|
366 |
+
users_list = []
|
367 |
+
for user_data in users_cursor:
|
368 |
+
# Đếm conversations thực tế
|
369 |
+
conversation_count = db.conversations.count_documents({"user_id": user_data["_id"]})
|
370 |
+
|
371 |
+
# Lấy conversation mới nhất để biết last_activity
|
372 |
+
latest_conversation = db.conversations.find_one(
|
373 |
+
{"user_id": user_data["_id"]},
|
374 |
+
sort=[("updated_at", -1)]
|
375 |
+
)
|
376 |
+
|
377 |
+
# Đếm tin nhắn
|
378 |
+
user_conversations = list(db.conversations.find({"user_id": user_data["_id"]}))
|
379 |
+
total_messages = sum(len(conv.get("messages", [])) for conv in user_conversations)
|
380 |
+
|
381 |
+
users_list.append({
|
382 |
+
"id": str(user_data["_id"]),
|
383 |
+
"name": user_data.get("name", ""),
|
384 |
+
"email": user_data.get("email", ""),
|
385 |
+
"gender": user_data.get("gender", ""),
|
386 |
+
"role": user_data.get("role", "user"),
|
387 |
+
"created_at": user_data.get("created_at").isoformat() if user_data.get("created_at") else None,
|
388 |
+
"updated_at": user_data.get("updated_at").isoformat() if user_data.get("updated_at") else None,
|
389 |
+
"last_login": user_data.get("last_login").isoformat() if user_data.get("last_login") else None,
|
390 |
+
"conversation_count": conversation_count,
|
391 |
+
"message_count": total_messages,
|
392 |
+
"last_activity": latest_conversation.get("updated_at").isoformat() if latest_conversation and latest_conversation.get("updated_at") else None,
|
393 |
+
"avg_messages_per_conversation": round(total_messages / conversation_count, 1) if conversation_count > 0 else 0
|
394 |
+
})
|
395 |
+
|
396 |
+
# Thống kê tổng hợp
|
397 |
+
stats = {
|
398 |
+
"total_users": total_users,
|
399 |
+
"total_admins": users_collection.count_documents({"role": "admin"}),
|
400 |
+
"total_regular_users": users_collection.count_documents({"role": "user"}),
|
401 |
+
"active_users": users_collection.count_documents({"last_login": {"$exists": True}}),
|
402 |
+
"gender_stats": {
|
403 |
+
"male": users_collection.count_documents({"gender": "male"}),
|
404 |
+
"female": users_collection.count_documents({"gender": "female"}),
|
405 |
+
"other": users_collection.count_documents({"gender": "other"}),
|
406 |
+
"unknown": users_collection.count_documents({"gender": {"$in": [None, ""]}})
|
407 |
+
}
|
408 |
+
}
|
409 |
+
|
410 |
+
return jsonify({
|
411 |
+
"success": True,
|
412 |
+
"users": users_list,
|
413 |
+
"stats": stats,
|
414 |
+
"pagination": {
|
415 |
+
"page": page,
|
416 |
+
"per_page": per_page,
|
417 |
+
"total": total_users,
|
418 |
+
"pages": (total_users + per_page - 1) // per_page
|
419 |
+
}
|
420 |
+
})
|
421 |
+
|
422 |
+
except Exception as e:
|
423 |
+
logger.error(f"Lỗi lấy danh sách users: {str(e)}")
|
424 |
+
return jsonify({
|
425 |
+
"success": False,
|
426 |
+
"error": str(e)
|
427 |
+
}), 500
|
428 |
+
|
429 |
+
@admin_routes.route('/users/<user_id>', methods=['GET'])
|
430 |
+
@require_admin
|
431 |
+
def get_user_detail(user_id):
|
432 |
+
"""Lấy chi tiết người dùng với thông tin đầy đủ"""
|
433 |
+
try:
|
434 |
+
user = User.find_by_id(user_id)
|
435 |
+
if not user:
|
436 |
+
return jsonify({
|
437 |
+
"success": False,
|
438 |
+
"error": "Không tìm thấy người dùng"
|
439 |
+
}), 404
|
440 |
+
|
441 |
+
db = get_db()
|
442 |
+
|
443 |
+
# Lấy conversations của user
|
444 |
+
user_conversations = list(db.conversations.find(
|
445 |
+
{"user_id": ObjectId(user_id)},
|
446 |
+
{"title": 1, "created_at": 1, "updated_at": 1, "age_context": 1, "messages": 1}
|
447 |
+
).sort("updated_at", -1))
|
448 |
+
|
449 |
+
# Tính thống kê chi tiết
|
450 |
+
total_messages = sum(len(conv.get("messages", [])) for conv in user_conversations)
|
451 |
+
conversation_stats = {
|
452 |
+
"total_conversations": len(user_conversations),
|
453 |
+
"total_messages": total_messages,
|
454 |
+
"avg_messages_per_conversation": round(total_messages / len(user_conversations), 1) if user_conversations else 0,
|
455 |
+
"most_recent_conversation": user_conversations[0].get("updated_at").isoformat() if user_conversations and user_conversations[0].get("updated_at") else None,
|
456 |
+
"oldest_conversation": user_conversations[-1].get("created_at").isoformat() if user_conversations and user_conversations[-1].get("created_at") else None
|
457 |
+
}
|
458 |
+
|
459 |
+
# Thống kê theo độ tuổi
|
460 |
+
age_stats = {}
|
461 |
+
for conv in user_conversations:
|
462 |
+
age = conv.get("age_context")
|
463 |
+
if age:
|
464 |
+
age_stats[age] = age_stats.get(age, 0) + 1
|
465 |
+
|
466 |
+
user_detail = {
|
467 |
+
"id": str(user.user_id),
|
468 |
+
"name": user.name,
|
469 |
+
"email": user.email,
|
470 |
+
"gender": user.gender,
|
471 |
+
"role": user.role,
|
472 |
+
"created_at": user.created_at.isoformat() if user.created_at else None,
|
473 |
+
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
474 |
+
"last_login": user.last_login.isoformat() if user.last_login else None,
|
475 |
+
"stats": conversation_stats,
|
476 |
+
"age_usage": age_stats,
|
477 |
+
"recent_conversations": [
|
478 |
+
{
|
479 |
+
"id": str(conv["_id"]),
|
480 |
+
"title": conv.get("title", ""),
|
481 |
+
"created_at": conv.get("created_at").isoformat() if conv.get("created_at") else None,
|
482 |
+
"updated_at": conv.get("updated_at").isoformat() if conv.get("updated_at") else None,
|
483 |
+
"message_count": len(conv.get("messages", [])),
|
484 |
+
"age_context": conv.get("age_context")
|
485 |
+
}
|
486 |
+
for conv in user_conversations[:10]
|
487 |
+
]
|
488 |
+
}
|
489 |
+
|
490 |
+
return jsonify({
|
491 |
+
"success": True,
|
492 |
+
"user": user_detail
|
493 |
+
})
|
494 |
+
|
495 |
+
except Exception as e:
|
496 |
+
logger.error(f"Lỗi lấy chi tiết user: {str(e)}")
|
497 |
+
return jsonify({
|
498 |
+
"success": False,
|
499 |
+
"error": str(e)
|
500 |
+
}), 500
|
501 |
+
|
502 |
+
@admin_routes.route('/users/<user_id>', methods=['DELETE'])
|
503 |
+
@require_admin
|
504 |
+
def delete_user(user_id):
|
505 |
+
"""Xóa người dùng"""
|
506 |
+
try:
|
507 |
+
user = User.find_by_id(user_id)
|
508 |
+
if not user:
|
509 |
+
return jsonify({
|
510 |
+
"success": False,
|
511 |
+
"error": "Không tìm thấy người dùng"
|
512 |
+
}), 404
|
513 |
+
|
514 |
+
# Xóa tất cả conversations của user
|
515 |
+
db = get_db()
|
516 |
+
conversations_deleted = db.conversations.delete_many({"user_id": ObjectId(user_id)})
|
517 |
+
|
518 |
+
# Xóa user
|
519 |
+
user_deleted = user.delete()
|
520 |
+
|
521 |
+
if user_deleted:
|
522 |
+
return jsonify({
|
523 |
+
"success": True,
|
524 |
+
"message": f"Đã xóa người dùng và {conversations_deleted.deleted_count} cuộc hội thoại"
|
525 |
+
})
|
526 |
+
else:
|
527 |
+
return jsonify({
|
528 |
+
"success": False,
|
529 |
+
"error": "Không thể xóa người dùng"
|
530 |
+
}), 500
|
531 |
+
|
532 |
+
except Exception as e:
|
533 |
+
logger.error(f"Lỗi xóa user: {str(e)}")
|
534 |
+
return jsonify({
|
535 |
+
"success": False,
|
536 |
+
"error": str(e)
|
537 |
+
}), 500
|
538 |
+
|
539 |
+
@admin_routes.route('/users/bulk-delete', methods=['POST'])
|
540 |
+
@require_admin
|
541 |
+
def bulk_delete_users():
|
542 |
+
"""Xóa nhiều người dùng"""
|
543 |
+
try:
|
544 |
+
data = request.json
|
545 |
+
user_ids = data.get('user_ids', [])
|
546 |
+
|
547 |
+
if not user_ids:
|
548 |
+
return jsonify({
|
549 |
+
"success": False,
|
550 |
+
"error": "Không có user nào được chọn"
|
551 |
+
}), 400
|
552 |
+
|
553 |
+
deleted_count = 0
|
554 |
+
failed_ids = []
|
555 |
+
|
556 |
+
db = get_db()
|
557 |
+
|
558 |
+
for user_id in user_ids:
|
559 |
+
try:
|
560 |
+
# Xóa conversations của user
|
561 |
+
db.conversations.delete_many({"user_id": ObjectId(user_id)})
|
562 |
+
|
563 |
+
# Xóa user
|
564 |
+
user = User.find_by_id(user_id)
|
565 |
+
if user and user.delete():
|
566 |
+
deleted_count += 1
|
567 |
+
else:
|
568 |
+
failed_ids.append(user_id)
|
569 |
+
except Exception as e:
|
570 |
+
logger.error(f"Lỗi xóa user {user_id}: {e}")
|
571 |
+
failed_ids.append(user_id)
|
572 |
+
|
573 |
+
return jsonify({
|
574 |
+
"success": True,
|
575 |
+
"message": f"Đã xóa {deleted_count}/{len(user_ids)} người dùng",
|
576 |
+
"deleted_count": deleted_count,
|
577 |
+
"failed_ids": failed_ids
|
578 |
+
})
|
579 |
+
|
580 |
+
except Exception as e:
|
581 |
+
logger.error(f"Lỗi xóa bulk users: {str(e)}")
|
582 |
+
return jsonify({
|
583 |
+
"success": False,
|
584 |
+
"error": str(e)
|
585 |
+
}), 500
|
586 |
+
|
587 |
+
# ===== CONVERSATION MANAGEMENT ROUTES =====
|
588 |
+
@admin_routes.route('/conversations', methods=['GET'])
|
589 |
+
@require_admin
|
590 |
+
def get_all_conversations():
|
591 |
+
"""Lấy danh sách cuộc hội thoại - Fixed ObjectId serialization"""
|
592 |
+
try:
|
593 |
+
db = get_db()
|
594 |
+
|
595 |
+
page = int(request.args.get('page', 1))
|
596 |
+
per_page = int(request.args.get('per_page', 20))
|
597 |
+
search = request.args.get('search', '')
|
598 |
+
age_filter = request.args.get('age', '')
|
599 |
+
archived_filter = request.args.get('archived', 'all')
|
600 |
+
|
601 |
+
# Tạo query filter
|
602 |
+
query_filter = {}
|
603 |
+
if search:
|
604 |
+
query_filter["title"] = {"$regex": search, "$options": "i"}
|
605 |
+
if age_filter:
|
606 |
+
query_filter["age_context"] = int(age_filter)
|
607 |
+
if archived_filter != 'all':
|
608 |
+
query_filter["is_archived"] = archived_filter == 'true'
|
609 |
+
|
610 |
+
skip = (page - 1) * per_page
|
611 |
+
conversations = list(db.conversations.find(query_filter).skip(skip).limit(per_page).sort("updated_at", -1))
|
612 |
+
total_conversations = db.conversations.count_documents(query_filter)
|
613 |
+
|
614 |
+
conversations_list = []
|
615 |
+
for conv in conversations:
|
616 |
+
try:
|
617 |
+
# ✅ FIX: Safely convert ObjectId to string và handle user lookup
|
618 |
+
user_id = conv.get("user_id")
|
619 |
+
user_name = "Unknown"
|
620 |
+
|
621 |
+
if user_id:
|
622 |
+
try:
|
623 |
+
# Ensure user_id is ObjectId
|
624 |
+
if isinstance(user_id, str):
|
625 |
+
user_id = ObjectId(user_id)
|
626 |
+
|
627 |
+
user = User.find_by_id(str(user_id)) # Convert to string for the method
|
628 |
+
if user:
|
629 |
+
user_name = user.name
|
630 |
+
except Exception as user_error:
|
631 |
+
logger.warning(f"Could not fetch user {user_id}: {user_error}")
|
632 |
+
user_name = "Unknown"
|
633 |
+
|
634 |
+
# ✅ FIX: Safely handle datetime objects
|
635 |
+
def safe_isoformat(dt):
|
636 |
+
if dt is None:
|
637 |
+
return None
|
638 |
+
if hasattr(dt, 'isoformat'):
|
639 |
+
return dt.isoformat()
|
640 |
+
return str(dt)
|
641 |
+
|
642 |
+
# ✅ FIX: Safely handle messages array
|
643 |
+
messages = conv.get("messages", [])
|
644 |
+
processed_messages = []
|
645 |
+
|
646 |
+
for msg in messages:
|
647 |
+
if isinstance(msg, dict):
|
648 |
+
processed_msg = {
|
649 |
+
"role": msg.get("role", ""),
|
650 |
+
"content": msg.get("content", ""),
|
651 |
+
"timestamp": safe_isoformat(msg.get("timestamp"))
|
652 |
+
}
|
653 |
+
processed_messages.append(processed_msg)
|
654 |
+
|
655 |
+
conversation_data = {
|
656 |
+
"id": str(conv["_id"]), # ✅ Always convert ObjectId to string
|
657 |
+
"title": conv.get("title", ""),
|
658 |
+
"user_name": user_name,
|
659 |
+
"age_context": conv.get("age_context"),
|
660 |
+
"message_count": len(messages),
|
661 |
+
"is_archived": conv.get("is_archived", False),
|
662 |
+
"created_at": safe_isoformat(conv.get("created_at")),
|
663 |
+
"updated_at": safe_isoformat(conv.get("updated_at")),
|
664 |
+
"messages": processed_messages
|
665 |
+
}
|
666 |
+
|
667 |
+
conversations_list.append(conversation_data)
|
668 |
+
|
669 |
+
except Exception as conv_error:
|
670 |
+
logger.error(f"Error processing conversation {conv.get('_id')}: {conv_error}")
|
671 |
+
# Skip this conversation but continue with others
|
672 |
+
continue
|
673 |
+
|
674 |
+
return jsonify({
|
675 |
+
"success": True,
|
676 |
+
"conversations": conversations_list,
|
677 |
+
"pagination": {
|
678 |
+
"page": page,
|
679 |
+
"per_page": per_page,
|
680 |
+
"total": total_conversations,
|
681 |
+
"pages": (total_conversations + per_page - 1) // per_page
|
682 |
+
}
|
683 |
+
})
|
684 |
+
|
685 |
+
except Exception as e:
|
686 |
+
logger.error(f"Lỗi lấy danh sách conversations: {str(e)}")
|
687 |
+
return jsonify({
|
688 |
+
"success": False,
|
689 |
+
"error": str(e)
|
690 |
+
}), 500
|
691 |
+
|
692 |
+
@admin_routes.route('/conversations/<conversation_id>', methods=['DELETE'])
|
693 |
+
@require_admin
|
694 |
+
def delete_conversation(conversation_id):
|
695 |
+
"""Xóa cuộc hội thoại - Fixed ObjectId handling"""
|
696 |
+
try:
|
697 |
+
# ✅ FIX: Use Conversation model instead of direct DB access
|
698 |
+
conversation = Conversation.find_by_id(conversation_id)
|
699 |
+
if not conversation:
|
700 |
+
return jsonify({
|
701 |
+
"success": False,
|
702 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
703 |
+
}), 404
|
704 |
+
|
705 |
+
success = conversation.delete()
|
706 |
+
|
707 |
+
if success:
|
708 |
+
return jsonify({
|
709 |
+
"success": True,
|
710 |
+
"message": "Đã xóa cuộc hội thoại"
|
711 |
+
})
|
712 |
+
else:
|
713 |
+
return jsonify({
|
714 |
+
"success": False,
|
715 |
+
"error": "Không thể xóa cuộc hội thoại"
|
716 |
+
}), 500
|
717 |
+
|
718 |
+
except Exception as e:
|
719 |
+
logger.error(f"Lỗi xóa conversation: {str(e)}")
|
720 |
+
return jsonify({
|
721 |
+
"success": False,
|
722 |
+
"error": str(e)
|
723 |
+
}), 500
|
724 |
+
|
725 |
+
@admin_routes.route('/conversations/bulk-delete', methods=['POST'])
|
726 |
+
@require_admin
|
727 |
+
def bulk_delete_conversations():
|
728 |
+
"""Xóa nhiều cuộc hội thoại - Fixed ObjectId handling"""
|
729 |
+
try:
|
730 |
+
data = request.json
|
731 |
+
conversation_ids = data.get('conversation_ids', [])
|
732 |
+
|
733 |
+
if not conversation_ids:
|
734 |
+
return jsonify({
|
735 |
+
"success": False,
|
736 |
+
"error": "Không có conversation nào được chọn"
|
737 |
+
}), 400
|
738 |
+
|
739 |
+
db = get_db()
|
740 |
+
deleted_count = 0
|
741 |
+
failed_ids = []
|
742 |
+
|
743 |
+
for conv_id in conversation_ids:
|
744 |
+
try:
|
745 |
+
# ✅ FIX: Ensure proper ObjectId conversion
|
746 |
+
if isinstance(conv_id, str):
|
747 |
+
conv_id = ObjectId(conv_id)
|
748 |
+
|
749 |
+
result = db.conversations.delete_one({"_id": conv_id})
|
750 |
+
if result.deleted_count > 0:
|
751 |
+
deleted_count += 1
|
752 |
+
else:
|
753 |
+
failed_ids.append(str(conv_id))
|
754 |
+
|
755 |
+
except Exception as e:
|
756 |
+
logger.error(f"Error deleting conversation {conv_id}: {e}")
|
757 |
+
failed_ids.append(str(conv_id))
|
758 |
+
continue
|
759 |
+
|
760 |
+
return jsonify({
|
761 |
+
"success": True,
|
762 |
+
"message": f"Đã xóa {deleted_count}/{len(conversation_ids)} cuộc hội thoại",
|
763 |
+
"deleted_count": deleted_count,
|
764 |
+
"failed_ids": failed_ids
|
765 |
+
})
|
766 |
+
|
767 |
+
except Exception as e:
|
768 |
+
logger.error(f"Lỗi xóa bulk conversations: {str(e)}")
|
769 |
+
return jsonify({
|
770 |
+
"success": False,
|
771 |
+
"error": str(e)
|
772 |
+
}), 500
|
773 |
+
# ===== DOCUMENT MANAGEMENT ROUTES =====
|
774 |
+
|
775 |
+
@admin_routes.route('/documents', methods=['GET'])
|
776 |
+
@require_admin
|
777 |
+
def get_all_documents():
|
778 |
+
"""Lấy danh sách tài liệu t�� ChromaDB theo đúng metadata"""
|
779 |
+
try:
|
780 |
+
embedding_model = get_embedding_model()
|
781 |
+
|
782 |
+
# Lấy tất cả documents từ ChromaDB
|
783 |
+
results = embedding_model.collection.get(
|
784 |
+
include=['metadatas', 'documents']
|
785 |
+
)
|
786 |
+
|
787 |
+
if not results or not results['metadatas']:
|
788 |
+
return jsonify({
|
789 |
+
"success": True,
|
790 |
+
"documents": [],
|
791 |
+
"stats": {
|
792 |
+
"total": 0,
|
793 |
+
"by_chapter": {},
|
794 |
+
"by_type": {}
|
795 |
+
}
|
796 |
+
})
|
797 |
+
|
798 |
+
# Phân tích metadata để nhóm theo chapter
|
799 |
+
documents_by_chapter = {}
|
800 |
+
stats = {
|
801 |
+
"total": 0,
|
802 |
+
"by_chapter": {},
|
803 |
+
"by_type": {}
|
804 |
+
}
|
805 |
+
|
806 |
+
for i, metadata in enumerate(results['metadatas']):
|
807 |
+
# Lấy chapter từ metadata
|
808 |
+
chapter = metadata.get('chapter', 'unknown')
|
809 |
+
content_type = metadata.get('content_type', 'text')
|
810 |
+
|
811 |
+
# Cập nhật stats
|
812 |
+
stats["by_chapter"][chapter] = stats["by_chapter"].get(chapter, 0) + 1
|
813 |
+
stats["by_type"][content_type] = stats["by_type"].get(content_type, 0) + 1
|
814 |
+
|
815 |
+
# Nhóm theo chapter
|
816 |
+
if chapter not in documents_by_chapter:
|
817 |
+
chapter_title = get_chapter_title(chapter)
|
818 |
+
chapter_type = get_chapter_type(chapter)
|
819 |
+
|
820 |
+
documents_by_chapter[chapter] = {
|
821 |
+
"id": chapter,
|
822 |
+
"title": chapter_title,
|
823 |
+
"description": f"Tài liệu {chapter_title.lower()}",
|
824 |
+
"type": chapter_type,
|
825 |
+
"status": "processed",
|
826 |
+
"created_at": metadata.get('created_at', datetime.datetime.now().isoformat()),
|
827 |
+
"content_stats": {
|
828 |
+
"chunks": 0,
|
829 |
+
"tables": 0,
|
830 |
+
"figures": 0
|
831 |
+
}
|
832 |
+
}
|
833 |
+
|
834 |
+
# Cập nhật content stats
|
835 |
+
if content_type == "table":
|
836 |
+
documents_by_chapter[chapter]["content_stats"]["tables"] += 1
|
837 |
+
elif content_type == "figure":
|
838 |
+
documents_by_chapter[chapter]["content_stats"]["figures"] += 1
|
839 |
+
else:
|
840 |
+
documents_by_chapter[chapter]["content_stats"]["chunks"] += 1
|
841 |
+
|
842 |
+
documents_list = list(documents_by_chapter.values())
|
843 |
+
stats["total"] = len(documents_list)
|
844 |
+
|
845 |
+
logger.info(f"Found chapters: {list(documents_by_chapter.keys())}")
|
846 |
+
logger.info(f"Stats: {stats}")
|
847 |
+
|
848 |
+
return jsonify({
|
849 |
+
"success": True,
|
850 |
+
"documents": documents_list,
|
851 |
+
"stats": stats
|
852 |
+
})
|
853 |
+
|
854 |
+
except Exception as e:
|
855 |
+
logger.error(f"Lỗi lấy danh sách documents: {str(e)}")
|
856 |
+
return jsonify({
|
857 |
+
"success": False,
|
858 |
+
"error": str(e)
|
859 |
+
}), 500
|
860 |
+
|
861 |
+
def get_chapter_title(chapter):
|
862 |
+
try:
|
863 |
+
# Nếu là document upload, lấy title từ metadata
|
864 |
+
if chapter.startswith('bosung') or chapter.startswith('upload_'):
|
865 |
+
embedding_model = get_embedding_model()
|
866 |
+
results = embedding_model.collection.get(
|
867 |
+
where={"chapter": chapter},
|
868 |
+
limit=1
|
869 |
+
)
|
870 |
+
|
871 |
+
if results and results.get('metadatas') and results['metadatas'][0]:
|
872 |
+
metadata = results['metadatas'][0]
|
873 |
+
document_title = metadata.get('document_title') or metadata.get('document_source')
|
874 |
+
if document_title and document_title != 'Tài liệu upload':
|
875 |
+
return document_title
|
876 |
+
|
877 |
+
# Fallback cho các chapter chuẩn
|
878 |
+
chapter_titles = {
|
879 |
+
'bai1': 'Bài 1: Dinh dưỡng theo lứa tuổi học sinh',
|
880 |
+
'bai2': 'Bài 2: An toàn thực phẩm',
|
881 |
+
'bai3': 'Bài 3: Vệ sinh dinh dưỡng',
|
882 |
+
'bai4': 'Bài 4: Giáo dục dinh dưỡng',
|
883 |
+
'phuluc': 'Phụ lục'
|
884 |
+
}
|
885 |
+
|
886 |
+
# Kiểm tra pattern bosung
|
887 |
+
if chapter.startswith('bosung'):
|
888 |
+
return f'Tài liệu bổ sung {chapter.replace("bosung", "")}'
|
889 |
+
|
890 |
+
return chapter_titles.get(chapter, f'Tài liệu {chapter}')
|
891 |
+
|
892 |
+
except Exception as e:
|
893 |
+
logger.error(f"Error getting chapter title: {e}")
|
894 |
+
return f'Tài liệu {chapter}'
|
895 |
+
|
896 |
+
def get_chapter_type(chapter):
|
897 |
+
"""Lấy loại chapter"""
|
898 |
+
if chapter.startswith('bai'):
|
899 |
+
return 'lesson'
|
900 |
+
elif chapter == 'phuluc':
|
901 |
+
return 'appendix'
|
902 |
+
else:
|
903 |
+
return 'uploaded'
|
904 |
+
|
905 |
+
@admin_routes.route('/documents/<doc_id>', methods=['GET'])
|
906 |
+
@require_admin
|
907 |
+
def get_document_detail(doc_id):
|
908 |
+
"""Lấy chi tiết document theo chapter"""
|
909 |
+
try:
|
910 |
+
logger.info(f"Getting document detail for: {doc_id}")
|
911 |
+
|
912 |
+
# Sử dụng try-catch để tránh l���i tensor
|
913 |
+
try:
|
914 |
+
embedding_model = get_embedding_model()
|
915 |
+
except Exception as model_error:
|
916 |
+
logger.warning(f"Cannot load embedding model: {model_error}")
|
917 |
+
# Fallback: truy cập trực tiếp ChromaDB
|
918 |
+
import chromadb
|
919 |
+
from config import CHROMA_PERSIST_DIRECTORY, COLLECTION_NAME
|
920 |
+
|
921 |
+
chroma_client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIRECTORY)
|
922 |
+
collection = chroma_client.get_collection(name=COLLECTION_NAME)
|
923 |
+
else:
|
924 |
+
collection = embedding_model.collection
|
925 |
+
|
926 |
+
# Lấy tất cả documents và filter manually để tránh lỗi query
|
927 |
+
try:
|
928 |
+
all_results = collection.get(
|
929 |
+
include=['metadatas', 'documents'] # Bỏ 'ids' để tránh lỗi
|
930 |
+
)
|
931 |
+
|
932 |
+
if not all_results or not all_results.get('metadatas'):
|
933 |
+
logger.warning("No documents found in collection")
|
934 |
+
return jsonify({
|
935 |
+
"success": False,
|
936 |
+
"error": "Không có dữ liệu trong collection"
|
937 |
+
}), 404
|
938 |
+
|
939 |
+
# Filter manually
|
940 |
+
filtered_documents = []
|
941 |
+
filtered_metadatas = []
|
942 |
+
|
943 |
+
for i, metadata in enumerate(all_results['metadatas']):
|
944 |
+
if not metadata:
|
945 |
+
continue
|
946 |
+
|
947 |
+
chunk_id = metadata.get('chunk_id', '')
|
948 |
+
chapter = metadata.get('chapter', '')
|
949 |
+
|
950 |
+
# Kiểm tra match với doc_id
|
951 |
+
if (chunk_id.startswith(f"{doc_id}_") or
|
952 |
+
chapter == doc_id or
|
953 |
+
(doc_id in ['bai1', 'bai2', 'bai3', 'bai4'] and chunk_id.startswith(f"{doc_id}_")) or
|
954 |
+
(doc_id == 'phuluc' and 'phuluc' in chunk_id.lower())):
|
955 |
+
|
956 |
+
filtered_documents.append(all_results['documents'][i])
|
957 |
+
filtered_metadatas.append(metadata)
|
958 |
+
|
959 |
+
logger.info(f"Found {len(filtered_documents)} matching documents for {doc_id}")
|
960 |
+
|
961 |
+
if not filtered_documents:
|
962 |
+
return jsonify({
|
963 |
+
"success": False,
|
964 |
+
"error": f"Không tìm thấy tài liệu cho {doc_id}"
|
965 |
+
}), 404
|
966 |
+
|
967 |
+
except Exception as query_error:
|
968 |
+
logger.error(f"ChromaDB query error: {query_error}")
|
969 |
+
return jsonify({
|
970 |
+
"success": False,
|
971 |
+
"error": f"Lỗi truy vấn cơ sở dữ liệu: {str(query_error)}"
|
972 |
+
}), 500
|
973 |
+
|
974 |
+
# Xử lý kết quả và nhóm theo content_type
|
975 |
+
chunks_by_type = {
|
976 |
+
'text': [],
|
977 |
+
'table': [],
|
978 |
+
'figure': []
|
979 |
+
}
|
980 |
+
|
981 |
+
for i, metadata in enumerate(filtered_metadatas):
|
982 |
+
content_type = metadata.get('content_type', 'text')
|
983 |
+
|
984 |
+
# Parse age_range
|
985 |
+
age_range_str = metadata.get('age_range', '1-19')
|
986 |
+
try:
|
987 |
+
if '-' in age_range_str:
|
988 |
+
age_min, age_max = map(int, age_range_str.split('-'))
|
989 |
+
else:
|
990 |
+
age_min = age_max = int(age_range_str)
|
991 |
+
except:
|
992 |
+
age_min, age_max = 1, 19
|
993 |
+
|
994 |
+
chunk = {
|
995 |
+
"id": metadata.get('chunk_id', f'chunk_{i}'),
|
996 |
+
"title": metadata.get('title', 'Không có tiêu đề'),
|
997 |
+
"content": filtered_documents[i],
|
998 |
+
"content_type": content_type,
|
999 |
+
"age_range": age_range_str,
|
1000 |
+
"age_min": age_min,
|
1001 |
+
"age_max": age_max,
|
1002 |
+
"summary": metadata.get('summary', 'Không có tóm tắt'),
|
1003 |
+
"pages": metadata.get('pages', ''),
|
1004 |
+
"word_count": metadata.get('word_count', 0),
|
1005 |
+
"token_count": metadata.get('token_count', 0),
|
1006 |
+
"related_chunks": metadata.get('related_chunks', '').split(',') if metadata.get('related_chunks') else [],
|
1007 |
+
"created_at": metadata.get('created_at', ''),
|
1008 |
+
"document_source": metadata.get('document_source', ''),
|
1009 |
+
# Metadata đặc biệt
|
1010 |
+
"contains_table": metadata.get('contains_table', False),
|
1011 |
+
"contains_figure": metadata.get('contains_figure', False),
|
1012 |
+
"table_columns": metadata.get('table_columns', '').split(',') if metadata.get('table_columns') else []
|
1013 |
+
}
|
1014 |
+
|
1015 |
+
# Phân loại vào đúng nhóm
|
1016 |
+
if content_type in chunks_by_type:
|
1017 |
+
chunks_by_type[content_type].append(chunk)
|
1018 |
+
else:
|
1019 |
+
chunks_by_type['text'].append(chunk)
|
1020 |
+
|
1021 |
+
# Tính thống kê
|
1022 |
+
total_chunks = sum(len(chunks) for chunks in chunks_by_type.values())
|
1023 |
+
|
1024 |
+
logger.info(f"Successfully processed {total_chunks} chunks for {doc_id}")
|
1025 |
+
|
1026 |
+
return jsonify({
|
1027 |
+
"success": True,
|
1028 |
+
"document": {
|
1029 |
+
"id": doc_id,
|
1030 |
+
"chunks": chunks_by_type,
|
1031 |
+
"stats": {
|
1032 |
+
"total_chunks": total_chunks,
|
1033 |
+
"text_chunks": len(chunks_by_type['text']),
|
1034 |
+
"table_chunks": len(chunks_by_type['table']),
|
1035 |
+
"figure_chunks": len(chunks_by_type['figure'])
|
1036 |
+
}
|
1037 |
+
}
|
1038 |
+
})
|
1039 |
+
|
1040 |
+
except Exception as e:
|
1041 |
+
logger.error(f"Error getting document detail: {str(e)}")
|
1042 |
+
return jsonify({
|
1043 |
+
"success": False,
|
1044 |
+
"error": f"Lỗi máy chủ: {str(e)}"
|
1045 |
+
}), 500
|
1046 |
+
|
1047 |
+
@admin_routes.route('/documents/debug/metadata', methods=['GET'])
|
1048 |
+
@require_admin
|
1049 |
+
def debug_metadata():
|
1050 |
+
"""Debug metadata trong ChromaDB"""
|
1051 |
+
try:
|
1052 |
+
# Sử dụng try-catch để tránh lỗi tensor
|
1053 |
+
try:
|
1054 |
+
embedding_model = get_embedding_model()
|
1055 |
+
total_docs = embedding_model.count()
|
1056 |
+
except Exception as model_error:
|
1057 |
+
logger.warning(f"Không thể load embedding model: {model_error}")
|
1058 |
+
# Fallback: truy cập trực tiếp ChromaDB
|
1059 |
+
import chromadb
|
1060 |
+
from config import CHROMA_PERSIST_DIRECTORY, COLLECTION_NAME
|
1061 |
+
|
1062 |
+
chroma_client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIRECTORY)
|
1063 |
+
collection = chroma_client.get_collection(name=COLLECTION_NAME)
|
1064 |
+
total_docs = collection.count()
|
1065 |
+
|
1066 |
+
# Lấy sample metadata
|
1067 |
+
results = collection.get(
|
1068 |
+
limit=10,
|
1069 |
+
include=['metadatas']
|
1070 |
+
)
|
1071 |
+
else:
|
1072 |
+
# Lấy 10 documents đầu tiên để debug
|
1073 |
+
results = embedding_model.collection.get(
|
1074 |
+
limit=10,
|
1075 |
+
include=['metadatas']
|
1076 |
+
)
|
1077 |
+
|
1078 |
+
debug_info = {
|
1079 |
+
"total_documents": total_docs,
|
1080 |
+
"sample_metadata": results['metadatas'][:5] if results and results.get('metadatas') else [],
|
1081 |
+
"all_metadata_keys": []
|
1082 |
+
}
|
1083 |
+
|
1084 |
+
if results and results.get('metadatas'):
|
1085 |
+
# Lấy tất cả keys từ metadata
|
1086 |
+
all_keys = set()
|
1087 |
+
for metadata in results['metadatas']:
|
1088 |
+
if metadata: # Kiểm tra metadata không None
|
1089 |
+
all_keys.update(metadata.keys())
|
1090 |
+
debug_info["all_metadata_keys"] = list(all_keys)
|
1091 |
+
|
1092 |
+
return jsonify({
|
1093 |
+
"success": True,
|
1094 |
+
"debug_info": debug_info
|
1095 |
+
})
|
1096 |
+
|
1097 |
+
except Exception as e:
|
1098 |
+
logger.error(f"Lỗi debug metadata: {str(e)}")
|
1099 |
+
return jsonify({
|
1100 |
+
"success": False,
|
1101 |
+
"error": str(e)
|
1102 |
+
}), 500
|
1103 |
+
|
1104 |
+
@admin_routes.route('/documents/upload', methods=['POST'])
|
1105 |
+
@require_admin
|
1106 |
+
def upload_document():
|
1107 |
+
"""Upload PDF document"""
|
1108 |
+
try:
|
1109 |
+
if 'file' not in request.files:
|
1110 |
+
return jsonify({
|
1111 |
+
"success": False,
|
1112 |
+
"error": "Không có file được upload"
|
1113 |
+
}), 400
|
1114 |
+
|
1115 |
+
file = request.files['file']
|
1116 |
+
if file.filename == '':
|
1117 |
+
return jsonify({
|
1118 |
+
"success": False,
|
1119 |
+
"error": "Không có file được chọn"
|
1120 |
+
}), 400
|
1121 |
+
|
1122 |
+
if not file.filename.lower().endswith('.pdf'):
|
1123 |
+
return jsonify({
|
1124 |
+
"success": False,
|
1125 |
+
"error": "Chỉ chấp nhận file PDF"
|
1126 |
+
}), 400
|
1127 |
+
|
1128 |
+
# Tạo thư mục temp nếu chưa có
|
1129 |
+
temp_dir = '/tmp'
|
1130 |
+
if not os.path.exists(temp_dir):
|
1131 |
+
os.makedirs(temp_dir)
|
1132 |
+
|
1133 |
+
# Lưu file tạm thời
|
1134 |
+
filename = secure_filename(file.filename)
|
1135 |
+
temp_path = os.path.join(temp_dir, f"{int(time.time())}_{filename}")
|
1136 |
+
file.save(temp_path)
|
1137 |
+
|
1138 |
+
# Metadata từ form
|
1139 |
+
title = request.form.get('title', filename.replace('.pdf', ''))
|
1140 |
+
description = request.form.get('description', '')
|
1141 |
+
author = request.form.get('author', '')
|
1142 |
+
|
1143 |
+
# Tạo document_id duy nhất dựa trên số lượng tài liệu bổ sung hiện có
|
1144 |
+
document_id = generate_unique_document_id(title)
|
1145 |
+
|
1146 |
+
logger.info(f"Uploaded file: {filename} -> {temp_path}")
|
1147 |
+
|
1148 |
+
return jsonify({
|
1149 |
+
"success": True,
|
1150 |
+
"message": "File đã được upload thành công",
|
1151 |
+
"document_id": document_id,
|
1152 |
+
"filename": filename,
|
1153 |
+
"temp_path": temp_path,
|
1154 |
+
"metadata": {
|
1155 |
+
"title": title,
|
1156 |
+
"description": description,
|
1157 |
+
"author": author
|
1158 |
+
}
|
1159 |
+
})
|
1160 |
+
|
1161 |
+
except Exception as e:
|
1162 |
+
logger.error(f"Lỗi upload document: {str(e)}")
|
1163 |
+
return jsonify({
|
1164 |
+
"success": False,
|
1165 |
+
"error": f"Lỗi upload: {str(e)}"
|
1166 |
+
}), 500
|
1167 |
+
|
1168 |
+
def generate_unique_document_id(title):
|
1169 |
+
"""Tạo document ID duy nhất dựa trên title và số thứ tự"""
|
1170 |
+
try:
|
1171 |
+
embedding_model = get_embedding_model()
|
1172 |
+
|
1173 |
+
# Đếm số tài liệu bổ sung hiện có
|
1174 |
+
results = embedding_model.collection.get(
|
1175 |
+
where={"chapter": {"$like": "bosung%"}}
|
1176 |
+
)
|
1177 |
+
|
1178 |
+
if results and results.get('metadatas'):
|
1179 |
+
# Lấy tất cả chapter IDs bắt đầu bằng "bosung"
|
1180 |
+
existing_chapters = set()
|
1181 |
+
for metadata in results['metadatas']:
|
1182 |
+
if metadata and metadata.get('chapter'):
|
1183 |
+
chapter = metadata['chapter']
|
1184 |
+
if chapter.startswith('bosung'):
|
1185 |
+
existing_chapters.add(chapter)
|
1186 |
+
|
1187 |
+
# Tìm số thứ tự cao nhất
|
1188 |
+
max_num = 0
|
1189 |
+
for chapter in existing_chapters:
|
1190 |
+
try:
|
1191 |
+
num = int(chapter.replace('bosung', ''))
|
1192 |
+
max_num = max(max_num, num)
|
1193 |
+
except:
|
1194 |
+
continue
|
1195 |
+
|
1196 |
+
next_num = max_num + 1
|
1197 |
+
else:
|
1198 |
+
next_num = 1
|
1199 |
+
|
1200 |
+
# Tạo ID mới
|
1201 |
+
document_id = f"bosung{next_num}"
|
1202 |
+
logger.info(f"Generated unique document ID: {document_id}")
|
1203 |
+
|
1204 |
+
return document_id
|
1205 |
+
|
1206 |
+
except Exception as e:
|
1207 |
+
logger.error(f"Error generating document ID: {e}")
|
1208 |
+
# Fallback: sử dụng timestamp
|
1209 |
+
return f"bosung_{int(time.time())}"
|
1210 |
+
|
1211 |
+
@admin_routes.route('/documents/<doc_id>/process', methods=['POST'])
|
1212 |
+
@require_admin
|
1213 |
+
def process_document(doc_id):
|
1214 |
+
"""Process document với Gemini - THÊM DEBUG LOGGING"""
|
1215 |
+
try:
|
1216 |
+
data = request.json
|
1217 |
+
temp_path = data.get('temp_path')
|
1218 |
+
|
1219 |
+
if not temp_path or not os.path.exists(temp_path):
|
1220 |
+
return jsonify({
|
1221 |
+
"success": False,
|
1222 |
+
"error": "File không tồn tại"
|
1223 |
+
}), 400
|
1224 |
+
|
1225 |
+
logger.info(f"Processing document {doc_id} from {temp_path}")
|
1226 |
+
|
1227 |
+
# Kiểm tra và import PyPDF2
|
1228 |
+
try:
|
1229 |
+
import PyPDF2
|
1230 |
+
except ImportError:
|
1231 |
+
logger.error("PyPDF2 không được cài đặt")
|
1232 |
+
return jsonify({
|
1233 |
+
"success": False,
|
1234 |
+
"error": "Thiếu thư viện PyPDF2. Vui lòng cài đặt: pip install PyPDF2"
|
1235 |
+
}), 500
|
1236 |
+
|
1237 |
+
# Đọc PDF content
|
1238 |
+
try:
|
1239 |
+
with open(temp_path, 'rb') as file:
|
1240 |
+
pdf_reader = PyPDF2.PdfReader(file)
|
1241 |
+
pdf_text = ""
|
1242 |
+
for page_num in range(len(pdf_reader.pages)):
|
1243 |
+
page = pdf_reader.pages[page_num]
|
1244 |
+
page_text = page.extract_text()
|
1245 |
+
if page_text:
|
1246 |
+
pdf_text += page_text + "\n"
|
1247 |
+
|
1248 |
+
logger.info(f"Extracted {len(pdf_text)} characters from PDF")
|
1249 |
+
|
1250 |
+
# DEBUG: Log phần đầu của PDF text
|
1251 |
+
logger.info(f"PDF text preview (first 500 chars): {pdf_text[:500]}...")
|
1252 |
+
|
1253 |
+
except Exception as pdf_error:
|
1254 |
+
logger.error(f"Lỗi đọc PDF: {pdf_error}")
|
1255 |
+
return jsonify({
|
1256 |
+
"success": False,
|
1257 |
+
"error": f"Không thể đọc file PDF: {str(pdf_error)}"
|
1258 |
+
}), 400
|
1259 |
+
|
1260 |
+
if not pdf_text.strip():
|
1261 |
+
return jsonify({
|
1262 |
+
"success": False,
|
1263 |
+
"error": "Không thể trích xuất text từ PDF. Vui lòng đảm bảo PDF có thể copy được chữ (không phải file scan)"
|
1264 |
+
}), 400
|
1265 |
+
|
1266 |
+
# Tạo prompt cho Gemini
|
1267 |
+
try:
|
1268 |
+
prompt = create_document_processing_prompt(pdf_text)
|
1269 |
+
logger.info("Created prompt for Gemini")
|
1270 |
+
|
1271 |
+
# DEBUG: Log độ dài prompt
|
1272 |
+
logger.info(f"Prompt length: {len(prompt)} characters")
|
1273 |
+
|
1274 |
+
except Exception as prompt_error:
|
1275 |
+
logger.error(f"Lỗi tạo prompt: {prompt_error}")
|
1276 |
+
return jsonify({
|
1277 |
+
"success": False,
|
1278 |
+
"error": f"Lỗi tạo prompt: {str(prompt_error)}"
|
1279 |
+
}), 500
|
1280 |
+
|
1281 |
+
# Gọi Gemini API
|
1282 |
+
try:
|
1283 |
+
model = genai.GenerativeModel('gemini-2.5-flash')
|
1284 |
+
logger.info("Calling Gemini API...")
|
1285 |
+
|
1286 |
+
response = model.generate_content(
|
1287 |
+
prompt,
|
1288 |
+
generation_config=genai.types.GenerationConfig(
|
1289 |
+
temperature=0.1,
|
1290 |
+
max_output_tokens=100000 # Sử dụng giá trị bạn đã tăng
|
1291 |
+
)
|
1292 |
+
)
|
1293 |
+
|
1294 |
+
if not response or not response.text:
|
1295 |
+
return jsonify({
|
1296 |
+
"success": False,
|
1297 |
+
"error": "Gemini không trả về response"
|
1298 |
+
}), 500
|
1299 |
+
|
1300 |
+
result_text = response.text.strip()
|
1301 |
+
logger.info(f"Got response from Gemini: {len(result_text)} characters")
|
1302 |
+
|
1303 |
+
# DEBUG: Log phần đầu của response
|
1304 |
+
logger.info(f"Gemini response preview (first 1000 chars): {result_text[:1000]}...")
|
1305 |
+
|
1306 |
+
except Exception as gemini_error:
|
1307 |
+
logger.error(f"Lỗi gọi Gemini API: {gemini_error}")
|
1308 |
+
return jsonify({
|
1309 |
+
"success": False,
|
1310 |
+
"error": f"Lỗi gọi AI API: {str(gemini_error)}"
|
1311 |
+
}), 500
|
1312 |
+
|
1313 |
+
# Parse JSON response
|
1314 |
+
try:
|
1315 |
+
# Làm sạch JSON response
|
1316 |
+
original_result_text = result_text # Lưu bản gốc để log
|
1317 |
+
|
1318 |
+
if result_text.startswith('```json'):
|
1319 |
+
result_text = result_text.replace('```json', '').replace('```', '').strip()
|
1320 |
+
elif result_text.startswith('```'):
|
1321 |
+
result_text = result_text[3:].rstrip('```').strip()
|
1322 |
+
|
1323 |
+
logger.info(f"Cleaned response length: {len(result_text)} characters")
|
1324 |
+
|
1325 |
+
processed_data = json.loads(result_text)
|
1326 |
+
logger.info("Successfully parsed JSON response")
|
1327 |
+
|
1328 |
+
# DEBUG: Log sample chunks để kiểm tra content vs summary
|
1329 |
+
chunks = processed_data.get('chunks', [])
|
1330 |
+
logger.info(f"Found {len(chunks)} chunks in response")
|
1331 |
+
|
1332 |
+
if chunks:
|
1333 |
+
for i, chunk in enumerate(chunks[:2]): # Log 2 chunks đầu
|
1334 |
+
chunk_id = chunk.get('id', f'chunk_{i}')
|
1335 |
+
summary_len = len(chunk.get('summary', ''))
|
1336 |
+
content_len = len(chunk.get('content', ''))
|
1337 |
+
logger.info(f"Chunk {chunk_id}: summary_len={summary_len}, content_len={content_len}")
|
1338 |
+
|
1339 |
+
# Log content preview
|
1340 |
+
content_preview = chunk.get('content', '')[:300]
|
1341 |
+
summary_preview = chunk.get('summary', '')[:300]
|
1342 |
+
logger.info(f"Chunk {chunk_id} content preview: {content_preview}...")
|
1343 |
+
logger.info(f"Chunk {chunk_id} summary preview: {summary_preview}...")
|
1344 |
+
|
1345 |
+
# Lưu debug log vào file
|
1346 |
+
debug_log_path = save_gemini_response_to_file(original_result_text, processed_data, doc_id)
|
1347 |
+
logger.info(f"Debug log saved to: {debug_log_path}")
|
1348 |
+
|
1349 |
+
except json.JSONDecodeError as json_error:
|
1350 |
+
logger.error(f"JSON decode error: {json_error}")
|
1351 |
+
logger.error(f"Raw response: {result_text}")
|
1352 |
+
|
1353 |
+
# Lưu response lỗi vào file debug
|
1354 |
+
try:
|
1355 |
+
log_dir = setup_debug_logging()
|
1356 |
+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
1357 |
+
error_log_path = os.path.join(log_dir, f"gemini_error_{doc_id}_{timestamp}.log")
|
1358 |
+
|
1359 |
+
with open(error_log_path, 'w', encoding='utf-8') as f:
|
1360 |
+
f.write("GEMINI JSON PARSE ERROR\n")
|
1361 |
+
f.write("=" * 40 + "\n")
|
1362 |
+
f.write(f"Error: {json_error}\n\n")
|
1363 |
+
f.write("Raw Response:\n")
|
1364 |
+
f.write(result_text)
|
1365 |
+
|
1366 |
+
logger.info(f"Error log saved to: {error_log_path}")
|
1367 |
+
except:
|
1368 |
+
pass
|
1369 |
+
|
1370 |
+
return jsonify({
|
1371 |
+
"success": False,
|
1372 |
+
"error": f"AI trả về format không hợp lệ: {str(json_error)}"
|
1373 |
+
}), 500
|
1374 |
+
|
1375 |
+
# Validate format
|
1376 |
+
try:
|
1377 |
+
if not validate_processed_format(processed_data):
|
1378 |
+
return jsonify({
|
1379 |
+
"success": False,
|
1380 |
+
"error": "AI trả về format không đúng cấu trúc yêu cầu"
|
1381 |
+
}), 500
|
1382 |
+
except Exception as validate_error:
|
1383 |
+
logger.error(f"Validation error: {validate_error}")
|
1384 |
+
return jsonify({
|
1385 |
+
"success": False,
|
1386 |
+
"error": f"Lỗi validate format: {str(validate_error)}"
|
1387 |
+
}), 500
|
1388 |
+
|
1389 |
+
# Lưu vào ChromaDB
|
1390 |
+
try:
|
1391 |
+
save_processed_document(doc_id, processed_data)
|
1392 |
+
logger.info(f"Successfully saved document {doc_id} to ChromaDB")
|
1393 |
+
except Exception as save_error:
|
1394 |
+
logger.error(f"Lỗi lưu document: {save_error}")
|
1395 |
+
return jsonify({
|
1396 |
+
"success": False,
|
1397 |
+
"error": f"Lỗi lưu document: {str(save_error)}"
|
1398 |
+
}), 500
|
1399 |
+
|
1400 |
+
# Xóa file tạm
|
1401 |
+
try:
|
1402 |
+
if os.path.exists(temp_path):
|
1403 |
+
os.remove(temp_path)
|
1404 |
+
logger.info(f"Deleted temp file: {temp_path}")
|
1405 |
+
except Exception as delete_error:
|
1406 |
+
logger.warning(f"Cannot delete temp file: {delete_error}")
|
1407 |
+
|
1408 |
+
return jsonify({
|
1409 |
+
"success": True,
|
1410 |
+
"message": "Xử lý tài liệu thành công",
|
1411 |
+
"processed_chunks": len(processed_data.get('chunks', [])),
|
1412 |
+
"processed_tables": len(processed_data.get('tables', [])),
|
1413 |
+
"processed_figures": len(processed_data.get('figures', [])),
|
1414 |
+
"document_id": doc_id,
|
1415 |
+
"debug_log": debug_log_path # Trả về đường dẫn log file
|
1416 |
+
})
|
1417 |
+
|
1418 |
+
except Exception as e:
|
1419 |
+
logger.error(f"Lỗi xử lý document: {str(e)}")
|
1420 |
+
return jsonify({
|
1421 |
+
"success": False,
|
1422 |
+
"error": f"Lỗi xử lý: {str(e)}"
|
1423 |
+
}), 500
|
1424 |
+
|
1425 |
+
def create_document_processing_prompt(pdf_text):
|
1426 |
+
"""Tạo prompt chi tiết cho Gemini - CẢI THIỆN THÊM"""
|
1427 |
+
# Cắt ngắn text để tránh vượt quá limit
|
1428 |
+
max_text_length = 80000 # Tăng lên do bạn đã tăng token limit
|
1429 |
+
if len(pdf_text) > max_text_length:
|
1430 |
+
pdf_text = pdf_text[:max_text_length] + "..."
|
1431 |
+
|
1432 |
+
prompt = f"""
|
1433 |
+
Bạn là một chuyên gia phân tích tài liệu. Nhiệm vụ của bạn là phân tích và chia nhỏ tài liệu PDF thành các phần có nghĩa.
|
1434 |
+
|
1435 |
+
TÀI LIỆU CẦN XỬ LÝ:
|
1436 |
+
{pdf_text}
|
1437 |
+
|
1438 |
+
YÊU CẦU PHÂN TÍCH:
|
1439 |
+
|
1440 |
+
1. PHÂN CHIA NỘI DUNG:
|
1441 |
+
- Chia tài liệu thành các chunk có nghĩa (mỗi chunk 200-1000 từ)
|
1442 |
+
- QUAN TRỌNG: Trong field "content" của mỗi chunk, bạn PHẢI SAO CHÉP NGUYÊN VĂN nội dung từ tài liệu gốc
|
1443 |
+
- Field "summary" chỉ là tóm tắt ngắn gọn (1-2 câu)
|
1444 |
+
- Field "content" phải chứa toàn bộ văn bản gốc của phần đó
|
1445 |
+
- KHÔNG viết lại, KHÔNG diễn giải, KHÔNG tóm tắt trong field "content"
|
1446 |
+
|
1447 |
+
2. CẤU TRÚC JSON:
|
1448 |
+
Mỗi chunk PHẢI có đủ các field sau:
|
1449 |
+
- "id": ID duy nhất
|
1450 |
+
- "title": Tiêu đề của chunk
|
1451 |
+
- "content": NỘI DUNG NGUYÊN VĂN từ PDF (bắt buộc phải có)
|
1452 |
+
- "summary": Tóm tắt ngắn gọn (khác với content)
|
1453 |
+
- "content_type": "text", "table", hoặc "figure"
|
1454 |
+
- "age_range": [min_age, max_age] từ 1-19
|
1455 |
+
- "pages": trang nếu biết
|
1456 |
+
- "related_chunks": []
|
1457 |
+
- "word_count": số từ trong content
|
1458 |
+
- "token_count": ước tính token
|
1459 |
+
|
1460 |
+
3. VÍ DỤ ĐÚNG:
|
1461 |
+
{{
|
1462 |
+
"id": "bosung1_muc1_1",
|
1463 |
+
"title": "Giới thiệu về dinh dưỡng",
|
1464 |
+
"content": "Dinh dưỡng là quá trình cung cấp cho cơ thể các chất cần thiết để duy trì sự sống, phát triển và hoạt động. Các chất dinh dưỡng bao gồm...",
|
1465 |
+
"summary": "Giới thiệu khái niệm cơ bản về dinh dưỡng",
|
1466 |
+
"content_type": "text",
|
1467 |
+
"age_range": [1, 19]
|
1468 |
+
}}
|
1469 |
+
|
1470 |
+
4. ĐỊNH DẠNG OUTPUT JSON:
|
1471 |
+
{{
|
1472 |
+
"bai_info": {{
|
1473 |
+
"id": "bosung1",
|
1474 |
+
"title": "Tiêu đề tài liệu",
|
1475 |
+
"overview": "Tổng quan"
|
1476 |
+
}},
|
1477 |
+
"chunks": [
|
1478 |
+
{{
|
1479 |
+
"id": "bosung1_muc1_1",
|
1480 |
+
"title": "Tiêu đề chunk",
|
1481 |
+
"content": "NỘI DUNG NGUYÊN VĂN TỪ PDF - BẮTED BUỘC PHẢI CÓ",
|
1482 |
+
"summary": "Tóm tắt ngắn gọn",
|
1483 |
+
"content_type": "text",
|
1484 |
+
"age_range": [1, 19],
|
1485 |
+
"pages": "",
|
1486 |
+
"related_chunks": [],
|
1487 |
+
"word_count": 100,
|
1488 |
+
"token_count": 150,
|
1489 |
+
"contains_table": false,
|
1490 |
+
"contains_figure": false
|
1491 |
+
}}
|
1492 |
+
],
|
1493 |
+
"tables": [],
|
1494 |
+
"figures": [],
|
1495 |
+
"total_items": {{"chunks": 1, "tables": 0, "figures": 0}}
|
1496 |
+
}}
|
1497 |
+
|
1498 |
+
LƯU Ý QUAN TRỌNG:
|
1499 |
+
- Field "content" PHẢI chứa văn bản nguyên gốc từ PDF
|
1500 |
+
- Field "summary" mới là phần tóm tắt
|
1501 |
+
- KHÔNG được để field "content" trống hoặc giống "summary"
|
1502 |
+
- Trả về JSON hợp lệ, không có text thừa
|
1503 |
+
|
1504 |
+
Hãy phân tích và trả về JSON, đảm bảo field "content" chứa nội dung đầy đủ từ PDF.
|
1505 |
+
"""
|
1506 |
+
return prompt
|
1507 |
+
|
1508 |
+
def validate_processed_format(data):
|
1509 |
+
"""Validate format của processed data"""
|
1510 |
+
try:
|
1511 |
+
# Kiểm tra required fields
|
1512 |
+
required_fields = ['bai_info', 'chunks', 'total_items']
|
1513 |
+
for field in required_fields:
|
1514 |
+
if field not in data:
|
1515 |
+
logger.error(f"Missing required field: {field}")
|
1516 |
+
return False
|
1517 |
+
|
1518 |
+
# Validate bai_info
|
1519 |
+
bai_info = data['bai_info']
|
1520 |
+
bai_info_fields = ['id', 'title', 'overview']
|
1521 |
+
for field in bai_info_fields:
|
1522 |
+
if field not in bai_info:
|
1523 |
+
logger.error(f"Missing bai_info field: {field}")
|
1524 |
+
return False
|
1525 |
+
|
1526 |
+
# Validate chunks
|
1527 |
+
if not isinstance(data['chunks'], list):
|
1528 |
+
logger.error("chunks must be a list")
|
1529 |
+
return False
|
1530 |
+
|
1531 |
+
for i, chunk in enumerate(data['chunks']):
|
1532 |
+
chunk_fields = ['id', 'title', 'content_type', 'age_range', 'summary']
|
1533 |
+
for field in chunk_fields:
|
1534 |
+
if field not in chunk:
|
1535 |
+
logger.error(f"Missing chunk field '{field}' in chunk {i}")
|
1536 |
+
return False
|
1537 |
+
|
1538 |
+
# Validate age_range
|
1539 |
+
age_range = chunk['age_range']
|
1540 |
+
if not isinstance(age_range, list) or len(age_range) != 2:
|
1541 |
+
logger.error(f"Invalid age_range in chunk {i}: must be list of 2 integers")
|
1542 |
+
return False
|
1543 |
+
|
1544 |
+
if not all(isinstance(x, int) and 1 <= x <= 19 for x in age_range):
|
1545 |
+
logger.error(f"Invalid age_range values in chunk {i}: must be integers 1-19")
|
1546 |
+
return False
|
1547 |
+
|
1548 |
+
# Validate optional fields
|
1549 |
+
for table in data.get('tables', []):
|
1550 |
+
if 'age_range' in table:
|
1551 |
+
age_range = table['age_range']
|
1552 |
+
if not isinstance(age_range, list) or len(age_range) != 2:
|
1553 |
+
logger.error("Invalid age_range in table")
|
1554 |
+
return False
|
1555 |
+
|
1556 |
+
for figure in data.get('figures', []):
|
1557 |
+
if 'age_range' in figure:
|
1558 |
+
age_range = figure['age_range']
|
1559 |
+
if not isinstance(age_range, list) or len(age_range) != 2:
|
1560 |
+
logger.error("Invalid age_range in figure")
|
1561 |
+
return False
|
1562 |
+
|
1563 |
+
logger.info("Document format validation passed")
|
1564 |
+
return True
|
1565 |
+
|
1566 |
+
except Exception as e:
|
1567 |
+
logger.error(f"Validation error: {e}")
|
1568 |
+
return False
|
1569 |
+
|
1570 |
+
def save_processed_document(doc_id, processed_data):
|
1571 |
+
try:
|
1572 |
+
embedding_model = get_embedding_model()
|
1573 |
+
|
1574 |
+
document_title = processed_data.get('bai_info', {}).get('title', f'Tài liệu {doc_id}')
|
1575 |
+
|
1576 |
+
# Chuẩn bị tất cả items để index
|
1577 |
+
all_items = []
|
1578 |
+
|
1579 |
+
# Xử lý chunks
|
1580 |
+
for chunk in processed_data.get('chunks', []):
|
1581 |
+
chunk_content = chunk.get('content', chunk.get('summary', ''))
|
1582 |
+
if not chunk_content:
|
1583 |
+
# Fallback nếu không có content
|
1584 |
+
chunk_content = f"Tiêu đề: {chunk['title']}\nNội dung: {chunk.get('summary', '')}"
|
1585 |
+
|
1586 |
+
# Chuẩn bị metadata theo format cần thiết
|
1587 |
+
metadata = {
|
1588 |
+
"chunk_id": chunk['id'],
|
1589 |
+
"chapter": doc_id,
|
1590 |
+
"title": chunk['title'],
|
1591 |
+
"content_type": chunk['content_type'],
|
1592 |
+
"age_range": f"{chunk['age_range'][0]}-{chunk['age_range'][1]}",
|
1593 |
+
"age_min": chunk['age_range'][0],
|
1594 |
+
"age_max": chunk['age_range'][1],
|
1595 |
+
"summary": chunk['summary'],
|
1596 |
+
"pages": chunk.get('pages', ''),
|
1597 |
+
"related_chunks": ','.join(chunk.get('related_chunks', [])),
|
1598 |
+
"word_count": chunk.get('word_count', 0),
|
1599 |
+
"token_count": chunk.get('token_count', 0),
|
1600 |
+
"contains_table": chunk.get('contains_table', False),
|
1601 |
+
"contains_figure": chunk.get('contains_figure', False),
|
1602 |
+
"created_at": datetime.datetime.now().isoformat(),
|
1603 |
+
"document_source": document_title,
|
1604 |
+
"document_title": document_title
|
1605 |
+
}
|
1606 |
+
|
1607 |
+
# Thêm vào danh sách
|
1608 |
+
all_items.append({
|
1609 |
+
"content": chunk_content,
|
1610 |
+
"metadata": metadata,
|
1611 |
+
"id": chunk['id']
|
1612 |
+
})
|
1613 |
+
|
1614 |
+
# Xử lý tables
|
1615 |
+
for table in processed_data.get('tables', []):
|
1616 |
+
table_content = table.get('content', f"Bảng: {table['title']}\nMô tả: {table.get('summary', '')}")
|
1617 |
+
|
1618 |
+
metadata = {
|
1619 |
+
"chunk_id": table['id'],
|
1620 |
+
"chapter": doc_id,
|
1621 |
+
"title": table['title'],
|
1622 |
+
"content_type": "table",
|
1623 |
+
"age_range": f"{table['age_range'][0]}-{table['age_range'][1]}",
|
1624 |
+
"age_min": table['age_range'][0],
|
1625 |
+
"age_max": table['age_range'][1],
|
1626 |
+
"summary": table['summary'],
|
1627 |
+
"pages": table.get('pages', ''),
|
1628 |
+
"related_chunks": ','.join(table.get('related_chunks', [])),
|
1629 |
+
"table_columns": ','.join(table.get('table_columns', [])),
|
1630 |
+
"word_count": table.get('word_count', 0),
|
1631 |
+
"token_count": table.get('token_count', 0),
|
1632 |
+
"created_at": datetime.datetime.now().isoformat(),
|
1633 |
+
"document_source": document_title,
|
1634 |
+
"document_title": document_title
|
1635 |
+
}
|
1636 |
+
|
1637 |
+
all_items.append({
|
1638 |
+
"content": table_content,
|
1639 |
+
"metadata": metadata,
|
1640 |
+
"id": table['id']
|
1641 |
+
})
|
1642 |
+
|
1643 |
+
# Xử lý figures
|
1644 |
+
for figure in processed_data.get('figures', []):
|
1645 |
+
figure_content = figure.get('content', f"Hình: {figure['title']}\nMô tả: {figure.get('summary', '')}")
|
1646 |
+
|
1647 |
+
metadata = {
|
1648 |
+
"chunk_id": figure['id'],
|
1649 |
+
"chapter": doc_id,
|
1650 |
+
"title": figure['title'],
|
1651 |
+
"content_type": "figure",
|
1652 |
+
"age_range": f"{figure['age_range'][0]}-{figure['age_range'][1]}",
|
1653 |
+
"age_min": figure['age_range'][0],
|
1654 |
+
"age_max": figure['age_range'][1],
|
1655 |
+
"summary": figure['summary'],
|
1656 |
+
"pages": figure.get('pages', ''),
|
1657 |
+
"related_chunks": ','.join(figure.get('related_chunks', [])),
|
1658 |
+
"created_at": datetime.datetime.now().isoformat(),
|
1659 |
+
"document_source": document_title,
|
1660 |
+
"document_title": document_title
|
1661 |
+
}
|
1662 |
+
|
1663 |
+
all_items.append({
|
1664 |
+
"content": figure_content,
|
1665 |
+
"metadata": metadata,
|
1666 |
+
"id": figure['id']
|
1667 |
+
})
|
1668 |
+
|
1669 |
+
# Sử dụng index_chunks để lưu tất cả items
|
1670 |
+
if all_items:
|
1671 |
+
success = embedding_model.index_chunks(all_items)
|
1672 |
+
if success:
|
1673 |
+
logger.info(f"Successfully indexed {len(all_items)} items for document {doc_id} (title: {document_title})")
|
1674 |
+
else:
|
1675 |
+
raise Exception("Failed to index chunks")
|
1676 |
+
else:
|
1677 |
+
logger.warning(f"No items to index for document {doc_id}")
|
1678 |
+
|
1679 |
+
except Exception as e:
|
1680 |
+
logger.error(f"Error in save_processed_document: {str(e)}")
|
1681 |
+
raise
|
1682 |
+
|
1683 |
+
@admin_routes.route('/documents/<doc_id>', methods=['DELETE'])
|
1684 |
+
@require_admin
|
1685 |
+
def delete_document(doc_id):
|
1686 |
+
"""Xóa document theo chapter"""
|
1687 |
+
try:
|
1688 |
+
embedding_model = get_embedding_model()
|
1689 |
+
|
1690 |
+
# Lấy tất cả chunks của document - BỎ include=['ids']
|
1691 |
+
results = embedding_model.collection.get(
|
1692 |
+
where={"chapter": doc_id}
|
1693 |
+
# Không cần include=['ids'] vì mặc định đã trả về ids
|
1694 |
+
)
|
1695 |
+
|
1696 |
+
if results and results.get('ids'):
|
1697 |
+
# Xóa từng chunk
|
1698 |
+
for chunk_id in results['ids']:
|
1699 |
+
embedding_model.collection.delete(ids=[chunk_id])
|
1700 |
+
|
1701 |
+
return jsonify({
|
1702 |
+
"success": True,
|
1703 |
+
"message": f"Đã xóa {len(results['ids'])} chunks của document {doc_id}"
|
1704 |
+
})
|
1705 |
+
else:
|
1706 |
+
return jsonify({
|
1707 |
+
"success": False,
|
1708 |
+
"error": "Không tìm thấy document"
|
1709 |
+
}), 404
|
1710 |
+
|
1711 |
+
except Exception as e:
|
1712 |
+
logger.error(f"Lỗi xóa document: {str(e)}")
|
1713 |
+
return jsonify({
|
1714 |
+
"success": False,
|
1715 |
+
"error": str(e)
|
1716 |
+
}), 500
|
1717 |
+
|
1718 |
+
@admin_routes.route('/documents/bulk-delete', methods=['POST'])
|
1719 |
+
@require_admin
|
1720 |
+
def bulk_delete_documents():
|
1721 |
+
"""Xóa nhiều documents"""
|
1722 |
+
try:
|
1723 |
+
data = request.json
|
1724 |
+
doc_ids = data.get('doc_ids', [])
|
1725 |
+
|
1726 |
+
if not doc_ids:
|
1727 |
+
return jsonify({
|
1728 |
+
"success": False,
|
1729 |
+
"error": "Không có document nào được chọn"
|
1730 |
+
}), 400
|
1731 |
+
|
1732 |
+
embedding_model = get_embedding_model()
|
1733 |
+
deleted_count = 0
|
1734 |
+
|
1735 |
+
for doc_id in doc_ids:
|
1736 |
+
try:
|
1737 |
+
# SỬA: Bỏ include=['ids']
|
1738 |
+
results = embedding_model.collection.get(
|
1739 |
+
where={"chapter": doc_id}
|
1740 |
+
)
|
1741 |
+
|
1742 |
+
if results and results.get('ids'):
|
1743 |
+
for chunk_id in results['ids']:
|
1744 |
+
embedding_model.collection.delete(ids=[chunk_id])
|
1745 |
+
deleted_count += 1
|
1746 |
+
|
1747 |
+
except Exception as e:
|
1748 |
+
logger.error(f"Lỗi xóa document {doc_id}: {e}")
|
1749 |
+
continue
|
1750 |
+
|
1751 |
+
return jsonify({
|
1752 |
+
"success": True,
|
1753 |
+
"message": f"Đã xóa {deleted_count}/{len(doc_ids)} documents",
|
1754 |
+
"deleted_count": deleted_count
|
1755 |
+
})
|
1756 |
+
|
1757 |
+
except Exception as e:
|
1758 |
+
logger.error(f"Lỗi xóa bulk documents: {str(e)}")
|
1759 |
+
return jsonify({
|
1760 |
+
"success": False,
|
1761 |
+
"error": str(e)
|
1762 |
+
}), 500
|
1763 |
+
|
1764 |
+
# ===== ANALYTICS ROUTES =====
|
1765 |
+
|
1766 |
+
@admin_routes.route('/analytics/overview', methods=['GET'])
|
1767 |
+
@require_admin
|
1768 |
+
def get_analytics_overview():
|
1769 |
+
"""Lấy thống kê phân tích"""
|
1770 |
+
try:
|
1771 |
+
db = get_db()
|
1772 |
+
|
1773 |
+
# Thống kê cơ bản
|
1774 |
+
total_conversations = db.conversations.count_documents({})
|
1775 |
+
total_users = db.users.count_documents({}) if hasattr(db, 'users') else 1
|
1776 |
+
|
1777 |
+
# Thống kê theo tuần (7 ngày gần nhất)
|
1778 |
+
daily_stats = []
|
1779 |
+
for i in range(7):
|
1780 |
+
day_start = datetime.datetime.now() - datetime.timedelta(days=6-i)
|
1781 |
+
day_end = day_start + datetime.timedelta(days=1)
|
1782 |
+
|
1783 |
+
daily_conversations = db.conversations.count_documents({
|
1784 |
+
"created_at": {"$gte": day_start, "$lt": day_end}
|
1785 |
+
})
|
1786 |
+
|
1787 |
+
daily_stats.append({
|
1788 |
+
"date": day_start.strftime("%Y-%m-%d"),
|
1789 |
+
"label": day_start.strftime("%d/%m"),
|
1790 |
+
"conversations": daily_conversations,
|
1791 |
+
"users": 0 # Mock data
|
1792 |
+
})
|
1793 |
+
|
1794 |
+
# Thống kê theo độ tuổi
|
1795 |
+
age_stats = list(db.conversations.aggregate([
|
1796 |
+
{"$group": {"_id": "$age_context", "count": {"$sum": 1}}},
|
1797 |
+
{"$sort": {"_id": 1}}
|
1798 |
+
]))
|
1799 |
+
|
1800 |
+
return jsonify({
|
1801 |
+
"success": True,
|
1802 |
+
"overview": {
|
1803 |
+
"totalUsers": total_users,
|
1804 |
+
"activeUsers": total_users,
|
1805 |
+
"totalConversations": total_conversations,
|
1806 |
+
"avgMessagesPerConversation": 6.8,
|
1807 |
+
"userGrowth": "+8.5%",
|
1808 |
+
"conversationGrowth": "+12.3%"
|
1809 |
+
},
|
1810 |
+
"dailyStats": daily_stats,
|
1811 |
+
"ageDistribution": [
|
1812 |
+
{
|
1813 |
+
"age_group": f"{stat['_id']} tuổi" if stat['_id'] else "Không rõ",
|
1814 |
+
"count": stat["count"]
|
1815 |
+
}
|
1816 |
+
for stat in age_stats
|
1817 |
+
]
|
1818 |
+
})
|
1819 |
+
|
1820 |
+
except Exception as e:
|
1821 |
+
logger.error(f"Lỗi lấy analytics: {str(e)}")
|
1822 |
+
return jsonify({
|
1823 |
+
"success": False,
|
1824 |
+
"error": str(e)
|
1825 |
+
}), 500
|
1826 |
+
|
1827 |
+
@admin_routes.route('/users/<user_id>', methods=['PUT'])
|
1828 |
+
@require_admin
|
1829 |
+
def update_user(user_id):
|
1830 |
+
"""Cập nhật thông tin người dùng"""
|
1831 |
+
try:
|
1832 |
+
data = request.json
|
1833 |
+
|
1834 |
+
user = User.find_by_id(user_id)
|
1835 |
+
if not user:
|
1836 |
+
return jsonify({
|
1837 |
+
"success": False,
|
1838 |
+
"error": "Không tìm thấy người dùng"
|
1839 |
+
}), 404
|
1840 |
+
|
1841 |
+
# Cập nhật thông tin
|
1842 |
+
if 'name' in data:
|
1843 |
+
user.name = data['name']
|
1844 |
+
|
1845 |
+
if 'gender' in data:
|
1846 |
+
user.gender = data['gender']
|
1847 |
+
|
1848 |
+
if 'role' in data:
|
1849 |
+
user.role = data['role']
|
1850 |
+
|
1851 |
+
# Lưu thay đổi
|
1852 |
+
user.save()
|
1853 |
+
|
1854 |
+
return jsonify({
|
1855 |
+
"success": True,
|
1856 |
+
"message": "Cập nhật người dùng thành công",
|
1857 |
+
"user": {
|
1858 |
+
"id": str(user.user_id),
|
1859 |
+
"name": user.name,
|
1860 |
+
"email": user.email,
|
1861 |
+
"gender": user.gender,
|
1862 |
+
"role": user.role
|
1863 |
+
}
|
1864 |
+
})
|
1865 |
+
|
1866 |
+
except Exception as e:
|
1867 |
+
logger.error(f"Lỗi cập nhật user: {str(e)}")
|
1868 |
+
return jsonify({
|
1869 |
+
"success": False,
|
1870 |
+
"error": str(e)
|
1871 |
+
}), 500
|
1872 |
+
|
1873 |
+
@admin_routes.route('/feedback', methods=['GET'])
|
1874 |
+
@require_admin
|
1875 |
+
def get_all_feedback():
|
1876 |
+
"""API endpoint để admin xem tất cả feedback"""
|
1877 |
+
try:
|
1878 |
+
page = int(request.args.get('page', 1))
|
1879 |
+
per_page = int(request.args.get('per_page', 20))
|
1880 |
+
status_filter = request.args.get('status')
|
1881 |
+
skip = (page - 1) * per_page
|
1882 |
+
|
1883 |
+
feedbacks = Feedback.get_all_for_admin(limit=per_page, skip=skip, status_filter=status_filter)
|
1884 |
+
|
1885 |
+
result = []
|
1886 |
+
for feedback in feedbacks:
|
1887 |
+
result.append({
|
1888 |
+
"id": str(feedback.feedback_id),
|
1889 |
+
"user_name": getattr(feedback, 'user_name', 'Ẩn danh'),
|
1890 |
+
"user_email": getattr(feedback, 'user_email', ''),
|
1891 |
+
"rating": feedback.rating,
|
1892 |
+
"category": feedback.category,
|
1893 |
+
"title": feedback.title,
|
1894 |
+
"content": feedback.content,
|
1895 |
+
"status": feedback.status,
|
1896 |
+
"admin_response": feedback.admin_response,
|
1897 |
+
"created_at": feedback.created_at.isoformat(),
|
1898 |
+
"updated_at": feedback.updated_at.isoformat()
|
1899 |
+
})
|
1900 |
+
|
1901 |
+
return jsonify({
|
1902 |
+
"success": True,
|
1903 |
+
"feedbacks": result
|
1904 |
+
})
|
1905 |
+
|
1906 |
+
except Exception as e:
|
1907 |
+
logger.error(f"Error getting feedback for admin: {e}")
|
1908 |
+
return jsonify({
|
1909 |
+
"success": False,
|
1910 |
+
"error": str(e)
|
1911 |
+
}), 500
|
1912 |
+
|
1913 |
+
@admin_routes.route('/feedback/stats', methods=['GET'])
|
1914 |
+
@require_admin
|
1915 |
+
def get_feedback_stats():
|
1916 |
+
"""API endpoint để lấy thống kê feedback"""
|
1917 |
+
try:
|
1918 |
+
stats = Feedback.get_stats()
|
1919 |
+
return jsonify({
|
1920 |
+
"success": True,
|
1921 |
+
"stats": stats
|
1922 |
+
})
|
1923 |
+
except Exception as e:
|
1924 |
+
logger.error(f"Error getting feedback stats: {e}")
|
1925 |
+
return jsonify({
|
1926 |
+
"success": False,
|
1927 |
+
"error": str(e)
|
1928 |
+
}), 500
|
1929 |
+
|
1930 |
+
@admin_routes.route('/feedback/<feedback_id>/respond', methods=['PUT'])
|
1931 |
+
@require_admin
|
1932 |
+
def respond_to_feedback(feedback_id):
|
1933 |
+
"""API endpoint để admin phản hồi feedback"""
|
1934 |
+
try:
|
1935 |
+
data = request.json
|
1936 |
+
response_text = data.get('response', '')
|
1937 |
+
new_status = data.get('status', 'reviewed')
|
1938 |
+
|
1939 |
+
if not response_text.strip():
|
1940 |
+
return jsonify({
|
1941 |
+
"success": False,
|
1942 |
+
"error": "Vui lòng nhập phản hồi"
|
1943 |
+
}), 400
|
1944 |
+
|
1945 |
+
feedback = Feedback.find_by_id(feedback_id)
|
1946 |
+
if not feedback:
|
1947 |
+
return jsonify({
|
1948 |
+
"success": False,
|
1949 |
+
"error": "Không tìm thấy feedback"
|
1950 |
+
}), 404
|
1951 |
+
|
1952 |
+
success = feedback.update_admin_response(response_text.strip(), new_status)
|
1953 |
+
|
1954 |
+
if success:
|
1955 |
+
return jsonify({
|
1956 |
+
"success": True,
|
1957 |
+
"message": "Đã phản hồi feedback thành công"
|
1958 |
+
})
|
1959 |
+
else:
|
1960 |
+
return jsonify({
|
1961 |
+
"success": False,
|
1962 |
+
"error": "Không thể cập nhật phản hồi"
|
1963 |
+
}), 500
|
1964 |
+
|
1965 |
+
except Exception as e:
|
1966 |
+
logger.error(f"Error responding to feedback: {e}")
|
1967 |
+
return jsonify({
|
1968 |
+
"success": False,
|
1969 |
+
"error": str(e)
|
1970 |
+
}), 500
|
1971 |
+
|
1972 |
+
# ===== SYSTEM SETTINGS ROUTES =====
|
1973 |
+
|
1974 |
+
@admin_routes.route('/settings/system-config', methods=['GET'])
|
1975 |
+
@require_admin
|
1976 |
+
def get_system_config():
|
1977 |
+
"""Lấy cấu hình hệ thống thật"""
|
1978 |
+
try:
|
1979 |
+
import os
|
1980 |
+
from config import EMBEDDING_MODEL, GEMINI_API_KEY, CHROMA_PERSIST_DIRECTORY, COLLECTION_NAME
|
1981 |
+
from core.embedding_model import get_embedding_model
|
1982 |
+
|
1983 |
+
# Thông tin database
|
1984 |
+
db = get_db()
|
1985 |
+
|
1986 |
+
# Thông tin Collections trong MongoDB
|
1987 |
+
try:
|
1988 |
+
mongo_collections = db.list_collection_names()
|
1989 |
+
mongo_stats = {}
|
1990 |
+
for collection_name in mongo_collections:
|
1991 |
+
collection = db[collection_name]
|
1992 |
+
mongo_stats[collection_name] = {
|
1993 |
+
"document_count": collection.count_documents({}),
|
1994 |
+
"estimated_size": collection.estimated_document_count()
|
1995 |
+
}
|
1996 |
+
except Exception as e:
|
1997 |
+
mongo_collections = []
|
1998 |
+
mongo_stats = {"error": str(e)}
|
1999 |
+
|
2000 |
+
# Thông tin ChromaDB/Vector Database
|
2001 |
+
try:
|
2002 |
+
embedding_model = get_embedding_model()
|
2003 |
+
vector_stats = embedding_model.get_stats()
|
2004 |
+
chroma_status = "Connected"
|
2005 |
+
vector_count = embedding_model.count()
|
2006 |
+
except Exception as e:
|
2007 |
+
vector_stats = {"error": str(e)}
|
2008 |
+
chroma_status = "Error"
|
2009 |
+
vector_count = 0
|
2010 |
+
|
2011 |
+
# Thông tin Gemini API
|
2012 |
+
gemini_status = "Connected" if GEMINI_API_KEY else "Not configured"
|
2013 |
+
|
2014 |
+
# Thông tin hệ thống
|
2015 |
+
import platform
|
2016 |
+
import psutil
|
2017 |
+
|
2018 |
+
system_info = {
|
2019 |
+
"python_version": platform.python_version(),
|
2020 |
+
"platform": platform.platform(),
|
2021 |
+
"cpu_count": psutil.cpu_count(),
|
2022 |
+
"memory_total": round(psutil.virtual_memory().total / (1024**3), 2), # GB
|
2023 |
+
"memory_available": round(psutil.virtual_memory().available / (1024**3), 2), # GB
|
2024 |
+
"disk_usage": round(psutil.disk_usage('/').percent, 2)
|
2025 |
+
}
|
2026 |
+
|
2027 |
+
# Cấu hình application
|
2028 |
+
app_config = {
|
2029 |
+
"debug_mode": os.getenv("FLASK_ENV") == "development",
|
2030 |
+
"secret_key_configured": bool(os.getenv("JWT_SECRET_KEY")),
|
2031 |
+
"mongodb_uri": os.getenv("MONGO_URI", "mongodb://localhost:27017/"),
|
2032 |
+
"database_name": os.getenv("MONGO_DB_NAME", "nutribot_db"),
|
2033 |
+
"embedding_model": EMBEDDING_MODEL,
|
2034 |
+
"chroma_directory": CHROMA_PERSIST_DIRECTORY,
|
2035 |
+
"collection_name": COLLECTION_NAME,
|
2036 |
+
"gemini_configured": bool(GEMINI_API_KEY)
|
2037 |
+
}
|
2038 |
+
|
2039 |
+
return jsonify({
|
2040 |
+
"success": True,
|
2041 |
+
"system_config": {
|
2042 |
+
"application": app_config,
|
2043 |
+
"system": system_info,
|
2044 |
+
"database": {
|
2045 |
+
"mongodb": {
|
2046 |
+
"status": "Connected",
|
2047 |
+
"collections": mongo_collections,
|
2048 |
+
"statistics": mongo_stats
|
2049 |
+
},
|
2050 |
+
"vector_db": {
|
2051 |
+
"status": chroma_status,
|
2052 |
+
"document_count": vector_count,
|
2053 |
+
"statistics": vector_stats
|
2054 |
+
}
|
2055 |
+
},
|
2056 |
+
"ai_services": {
|
2057 |
+
"gemini": {
|
2058 |
+
"status": gemini_status,
|
2059 |
+
"model": "gemini-2.0-flash"
|
2060 |
+
}
|
2061 |
+
}
|
2062 |
+
}
|
2063 |
+
})
|
2064 |
+
|
2065 |
+
except Exception as e:
|
2066 |
+
logger.error(f"Lỗi lấy system config: {str(e)}")
|
2067 |
+
return jsonify({
|
2068 |
+
"success": False,
|
2069 |
+
"error": str(e)
|
2070 |
+
}), 500
|
2071 |
+
|
2072 |
+
@admin_routes.route('/settings/performance', methods=['GET'])
|
2073 |
+
@require_admin
|
2074 |
+
def get_performance_metrics():
|
2075 |
+
"""Lấy metrics hiệu năng hệ thống"""
|
2076 |
+
try:
|
2077 |
+
import psutil
|
2078 |
+
import time
|
2079 |
+
from datetime import datetime, timedelta
|
2080 |
+
|
2081 |
+
# CPU và Memory hiện tại
|
2082 |
+
cpu_percent = psutil.cpu_percent(interval=1)
|
2083 |
+
memory = psutil.virtual_memory()
|
2084 |
+
disk = psutil.disk_usage('/')
|
2085 |
+
|
2086 |
+
# Thống kê database
|
2087 |
+
db = get_db()
|
2088 |
+
|
2089 |
+
# Thống kê MongoDB performance
|
2090 |
+
try:
|
2091 |
+
# Thời gian trung bình cho queries (mock data - MongoDB professional monitoring cần tools khác)
|
2092 |
+
conversations_count = db.conversations.count_documents({})
|
2093 |
+
users_count = db.users.count_documents({}) if hasattr(db, 'users') else 0
|
2094 |
+
|
2095 |
+
# Tốc độ xử lý tin nhắn trong 24h qua
|
2096 |
+
yesterday = datetime.now() - timedelta(days=1)
|
2097 |
+
recent_conversations = db.conversations.count_documents({
|
2098 |
+
"updated_at": {"$gte": yesterday}
|
2099 |
+
})
|
2100 |
+
|
2101 |
+
db_performance = {
|
2102 |
+
"total_documents": conversations_count + users_count,
|
2103 |
+
"query_speed": "~50ms", # Mock data
|
2104 |
+
"recent_activity": recent_conversations,
|
2105 |
+
"index_efficiency": "95%" # Mock data
|
2106 |
+
}
|
2107 |
+
except Exception as e:
|
2108 |
+
db_performance = {"error": str(e)}
|
2109 |
+
|
2110 |
+
# Vector DB performance
|
2111 |
+
try:
|
2112 |
+
from core.embedding_model import get_embedding_model
|
2113 |
+
embedding_model = get_embedding_model()
|
2114 |
+
vector_count = embedding_model.count()
|
2115 |
+
|
2116 |
+
# Test search speed
|
2117 |
+
start_time = time.time()
|
2118 |
+
test_results = embedding_model.search("test query", top_k=5)
|
2119 |
+
search_time = (time.time() - start_time) * 1000 # milliseconds
|
2120 |
+
|
2121 |
+
vector_performance = {
|
2122 |
+
"total_vectors": vector_count,
|
2123 |
+
"search_speed_ms": round(search_time, 2),
|
2124 |
+
"embedding_dimension": 768, # multilingual-e5-base dimension
|
2125 |
+
"retrieval_accuracy": "85%" # Mock data - would need evaluation dataset
|
2126 |
+
}
|
2127 |
+
except Exception as e:
|
2128 |
+
vector_performance = {"error": str(e)}
|
2129 |
+
|
2130 |
+
# AI API performance
|
2131 |
+
ai_performance = {
|
2132 |
+
"average_response_time": "2.5s", # Mock data
|
2133 |
+
"success_rate": "98.5%", # Mock data
|
2134 |
+
"daily_requests": recent_conversations * 2, # Estimate
|
2135 |
+
"token_usage": "~150k tokens/day" # Mock data
|
2136 |
+
}
|
2137 |
+
|
2138 |
+
return jsonify({
|
2139 |
+
"success": True,
|
2140 |
+
"performance": {
|
2141 |
+
"system": {
|
2142 |
+
"cpu_usage": cpu_percent,
|
2143 |
+
"memory_usage": memory.percent,
|
2144 |
+
"memory_total_gb": round(memory.total / (1024**3), 2),
|
2145 |
+
"memory_used_gb": round(memory.used / (1024**3), 2),
|
2146 |
+
"disk_usage": disk.percent,
|
2147 |
+
"disk_total_gb": round(disk.total / (1024**3), 2),
|
2148 |
+
"uptime": "24h 15m" # Mock data
|
2149 |
+
},
|
2150 |
+
"database": db_performance,
|
2151 |
+
"vector_search": vector_performance,
|
2152 |
+
"ai_generation": ai_performance
|
2153 |
+
}
|
2154 |
+
})
|
2155 |
+
|
2156 |
+
except Exception as e:
|
2157 |
+
logger.error(f"Lỗi lấy performance metrics: {str(e)}")
|
2158 |
+
return jsonify({
|
2159 |
+
"success": False,
|
2160 |
+
"error": str(e)
|
2161 |
+
}), 500
|
2162 |
+
|
2163 |
+
@admin_routes.route('/settings/logs', methods=['GET'])
|
2164 |
+
@require_admin
|
2165 |
+
def get_system_logs():
|
2166 |
+
"""Lấy logs hệ thống"""
|
2167 |
+
try:
|
2168 |
+
import os
|
2169 |
+
from datetime import datetime
|
2170 |
+
|
2171 |
+
log_entries = []
|
2172 |
+
|
2173 |
+
# Đọc logs từ file nếu có
|
2174 |
+
log_files = [
|
2175 |
+
"logs/app.log",
|
2176 |
+
"logs/error.log",
|
2177 |
+
"embed_data.log"
|
2178 |
+
]
|
2179 |
+
|
2180 |
+
for log_file in log_files:
|
2181 |
+
if os.path.exists(log_file):
|
2182 |
+
try:
|
2183 |
+
with open(log_file, 'r', encoding='utf-8') as f:
|
2184 |
+
lines = f.readlines()
|
2185 |
+
# Lấy 50 dòng cuối
|
2186 |
+
recent_lines = lines[-50:] if len(lines) > 50 else lines
|
2187 |
+
|
2188 |
+
for line in recent_lines:
|
2189 |
+
if line.strip():
|
2190 |
+
log_entries.append({
|
2191 |
+
"timestamp": datetime.now().isoformat(),
|
2192 |
+
"level": "INFO", # Parse từ log format thực tế
|
2193 |
+
"source": log_file,
|
2194 |
+
"message": line.strip()
|
2195 |
+
})
|
2196 |
+
except Exception as e:
|
2197 |
+
log_entries.append({
|
2198 |
+
"timestamp": datetime.now().isoformat(),
|
2199 |
+
"level": "ERROR",
|
2200 |
+
"source": "system",
|
2201 |
+
"message": f"Cannot read {log_file}: {str(e)}"
|
2202 |
+
})
|
2203 |
+
|
2204 |
+
# Nếu không có log files, tạo mock logs
|
2205 |
+
if not log_entries:
|
2206 |
+
log_entries = [
|
2207 |
+
{
|
2208 |
+
"timestamp": datetime.now().isoformat(),
|
2209 |
+
"level": "INFO",
|
2210 |
+
"source": "system",
|
2211 |
+
"message": "Application started successfully"
|
2212 |
+
},
|
2213 |
+
{
|
2214 |
+
"timestamp": datetime.now().isoformat(),
|
2215 |
+
"level": "INFO",
|
2216 |
+
"source": "database",
|
2217 |
+
"message": "MongoDB connection established"
|
2218 |
+
},
|
2219 |
+
{
|
2220 |
+
"timestamp": datetime.now().isoformat(),
|
2221 |
+
"level": "INFO",
|
2222 |
+
"source": "vector_db",
|
2223 |
+
"message": "ChromaDB initialized successfully"
|
2224 |
+
}
|
2225 |
+
]
|
2226 |
+
|
2227 |
+
# Sort by timestamp descending
|
2228 |
+
log_entries.sort(key=lambda x: x["timestamp"], reverse=True)
|
2229 |
+
|
2230 |
+
return jsonify({
|
2231 |
+
"success": True,
|
2232 |
+
"logs": log_entries[:100] # Limit to 100 entries
|
2233 |
+
})
|
2234 |
+
|
2235 |
+
except Exception as e:
|
2236 |
+
logger.error(f"Lỗi lấy system logs: {str(e)}")
|
2237 |
+
return jsonify({
|
2238 |
+
"success": False,
|
2239 |
+
"error": str(e)
|
2240 |
+
}), 500
|
2241 |
+
|
2242 |
+
@admin_routes.route('/settings/backup', methods=['POST'])
|
2243 |
+
@require_admin
|
2244 |
+
def create_backup():
|
2245 |
+
"""Tạo backup dữ liệu"""
|
2246 |
+
try:
|
2247 |
+
from datetime import datetime
|
2248 |
+
import json
|
2249 |
+
import os
|
2250 |
+
|
2251 |
+
# Tạo thư mục backup nếu chưa có
|
2252 |
+
backup_dir = "backups"
|
2253 |
+
if not os.path.exists(backup_dir):
|
2254 |
+
os.makedirs(backup_dir)
|
2255 |
+
|
2256 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
2257 |
+
|
2258 |
+
# Backup MongoDB data
|
2259 |
+
db = get_db()
|
2260 |
+
backup_data = {
|
2261 |
+
"timestamp": datetime.now().isoformat(),
|
2262 |
+
"collections": {}
|
2263 |
+
}
|
2264 |
+
|
2265 |
+
# Export conversations
|
2266 |
+
conversations = list(db.conversations.find({}))
|
2267 |
+
for conv in conversations:
|
2268 |
+
conv["_id"] = str(conv["_id"]) # Convert ObjectId to string
|
2269 |
+
if "user_id" in conv:
|
2270 |
+
conv["user_id"] = str(conv["user_id"])
|
2271 |
+
backup_data["collections"]["conversations"] = conversations
|
2272 |
+
|
2273 |
+
# Export users if exists
|
2274 |
+
if hasattr(db, 'users'):
|
2275 |
+
users = list(db.users.find({}))
|
2276 |
+
for user in users:
|
2277 |
+
user["_id"] = str(user["_id"])
|
2278 |
+
# Remove password for security
|
2279 |
+
if "password" in user:
|
2280 |
+
del user["password"]
|
2281 |
+
backup_data["collections"]["users"] = users
|
2282 |
+
|
2283 |
+
# Save backup file
|
2284 |
+
backup_filename = f"backup_{timestamp}.json"
|
2285 |
+
backup_path = os.path.join(backup_dir, backup_filename)
|
2286 |
+
|
2287 |
+
with open(backup_path, 'w', encoding='utf-8') as f:
|
2288 |
+
json.dump(backup_data, f, indent=2, default=str, ensure_ascii=False)
|
2289 |
+
|
2290 |
+
# Get file size
|
2291 |
+
file_size = os.path.getsize(backup_path)
|
2292 |
+
file_size_mb = round(file_size / (1024 * 1024), 2)
|
2293 |
+
|
2294 |
+
return jsonify({
|
2295 |
+
"success": True,
|
2296 |
+
"message": "Backup created successfully",
|
2297 |
+
"backup": {
|
2298 |
+
"filename": backup_filename,
|
2299 |
+
"path": backup_path,
|
2300 |
+
"size_mb": file_size_mb,
|
2301 |
+
"timestamp": datetime.now().isoformat(),
|
2302 |
+
"collections_count": len(backup_data["collections"]),
|
2303 |
+
"total_documents": sum(len(coll) for coll in backup_data["collections"].values())
|
2304 |
+
}
|
2305 |
+
})
|
2306 |
+
|
2307 |
+
except Exception as e:
|
2308 |
+
logger.error(f"Lỗi tạo backup: {str(e)}")
|
2309 |
+
return jsonify({
|
2310 |
+
"success": False,
|
2311 |
+
"error": str(e)
|
2312 |
+
}), 500
|
2313 |
+
|
2314 |
+
@admin_routes.route('/settings/security', methods=['GET'])
|
2315 |
+
@require_admin
|
2316 |
+
def get_security_settings():
|
2317 |
+
"""Lấy cài đặt bảo mật"""
|
2318 |
+
try:
|
2319 |
+
import os
|
2320 |
+
from datetime import datetime, timedelta
|
2321 |
+
|
2322 |
+
# Kiểm tra cấu hình bảo mật
|
2323 |
+
security_config = {
|
2324 |
+
"jwt_configured": bool(os.getenv("JWT_SECRET_KEY")),
|
2325 |
+
"admin_accounts": 1, # Mock data
|
2326 |
+
"password_policy": {
|
2327 |
+
"min_length": 6,
|
2328 |
+
"require_special_chars": False,
|
2329 |
+
"require_numbers": False
|
2330 |
+
},
|
2331 |
+
"session_timeout": "24 hours",
|
2332 |
+
"ssl_enabled": False, # Mock data
|
2333 |
+
"rate_limiting": False # Mock data
|
2334 |
+
}
|
2335 |
+
|
2336 |
+
# Recent login attempts (mock data)
|
2337 |
+
recent_logins = [
|
2338 |
+
{
|
2339 |
+
"timestamp": (datetime.now() - timedelta(hours=1)).isoformat(),
|
2340 |
+
"user": "[email protected]",
|
2341 |
+
"ip": "127.0.0.1",
|
2342 |
+
"status": "success"
|
2343 |
+
},
|
2344 |
+
{
|
2345 |
+
"timestamp": (datetime.now() - timedelta(hours=3)).isoformat(),
|
2346 |
+
"user": "[email protected]",
|
2347 |
+
"ip": "127.0.0.1",
|
2348 |
+
"status": "success"
|
2349 |
+
}
|
2350 |
+
]
|
2351 |
+
|
2352 |
+
# Security recommendations
|
2353 |
+
recommendations = [
|
2354 |
+
{
|
2355 |
+
"priority": "high",
|
2356 |
+
"title": "Enable HTTPS",
|
2357 |
+
"description": "Deploy with SSL certificate for production"
|
2358 |
+
},
|
2359 |
+
{
|
2360 |
+
"priority": "medium",
|
2361 |
+
"title": "Implement Rate Limiting",
|
2362 |
+
"description": "Add rate limiting to prevent abuse"
|
2363 |
+
},
|
2364 |
+
{
|
2365 |
+
"priority": "low",
|
2366 |
+
"title": "Strengthen Password Policy",
|
2367 |
+
"description": "Require stronger passwords for admin accounts"
|
2368 |
+
}
|
2369 |
+
]
|
2370 |
+
|
2371 |
+
return jsonify({
|
2372 |
+
"success": True,
|
2373 |
+
"security": {
|
2374 |
+
"configuration": security_config,
|
2375 |
+
"recent_logins": recent_logins,
|
2376 |
+
"recommendations": recommendations
|
2377 |
+
}
|
2378 |
+
})
|
2379 |
+
|
2380 |
+
except Exception as e:
|
2381 |
+
logger.error(f"Lỗi lấy security settings: {str(e)}")
|
2382 |
+
return jsonify({
|
2383 |
+
"success": False,
|
2384 |
+
"error": str(e)
|
2385 |
+
}), 500
|
api/auth.py
ADDED
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Blueprint, request, jsonify, make_response
|
2 |
+
import re
|
3 |
+
import logging
|
4 |
+
from models.user_model import User
|
5 |
+
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required, get_jwt
|
6 |
+
import datetime
|
7 |
+
from functools import wraps
|
8 |
+
|
9 |
+
# Cấu hình logging
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
# Tạo blueprint
|
13 |
+
auth_routes = Blueprint('auth', __name__)
|
14 |
+
|
15 |
+
def validate_email(email):
|
16 |
+
"""Kiểm tra định dạng email"""
|
17 |
+
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
18 |
+
return re.match(pattern, email) is not None
|
19 |
+
|
20 |
+
def validate_password(password):
|
21 |
+
"""Kiểm tra mật khẩu có đủ mạnh không"""
|
22 |
+
return len(password) >= 6
|
23 |
+
|
24 |
+
# SỬA: Decorator đơn giản hơn để check admin
|
25 |
+
def require_admin(f):
|
26 |
+
"""Decorator đơn giản để kiểm tra admin"""
|
27 |
+
@wraps(f)
|
28 |
+
@jwt_required()
|
29 |
+
def decorated_function(*args, **kwargs):
|
30 |
+
try:
|
31 |
+
user_id = get_jwt_identity()
|
32 |
+
user = User.find_by_id(user_id)
|
33 |
+
|
34 |
+
if not user:
|
35 |
+
return jsonify({
|
36 |
+
"success": False,
|
37 |
+
"error": "User không tồn tại"
|
38 |
+
}), 403
|
39 |
+
|
40 |
+
# Kiểm tra có phải admin không
|
41 |
+
if not user.is_admin():
|
42 |
+
return jsonify({
|
43 |
+
"success": False,
|
44 |
+
"error": "Không có quyền truy cập admin"
|
45 |
+
}), 403
|
46 |
+
|
47 |
+
# Thêm user vào request context
|
48 |
+
request.current_user = user
|
49 |
+
return f(*args, **kwargs)
|
50 |
+
|
51 |
+
except Exception as e:
|
52 |
+
logger.error(f"Lỗi xác thực admin: {e}")
|
53 |
+
return jsonify({
|
54 |
+
"success": False,
|
55 |
+
"error": "Lỗi xác thực"
|
56 |
+
}), 500
|
57 |
+
return decorated_function
|
58 |
+
|
59 |
+
def register_user(name, email, password, gender=None):
|
60 |
+
"""Đăng ký người dùng mới"""
|
61 |
+
if not name or not email or not password:
|
62 |
+
return False, "Vui lòng nhập đầy đủ thông tin"
|
63 |
+
|
64 |
+
if not validate_email(email):
|
65 |
+
return False, "Email không hợp lệ"
|
66 |
+
|
67 |
+
if not validate_password(password):
|
68 |
+
return False, "Mật khẩu phải có ít nhất 6 ký tự"
|
69 |
+
|
70 |
+
success, result = User.register(name, email, password, gender)
|
71 |
+
if success:
|
72 |
+
return True, {"user_id": result}
|
73 |
+
else:
|
74 |
+
return False, result
|
75 |
+
|
76 |
+
def login_user(email, password):
|
77 |
+
"""Đăng nhập người dùng"""
|
78 |
+
if not email or not password:
|
79 |
+
return False, "Vui lòng nhập đầy đủ thông tin đăng nhập"
|
80 |
+
|
81 |
+
success, result = User.login(email, password)
|
82 |
+
if success:
|
83 |
+
user = result
|
84 |
+
|
85 |
+
# Tạo JWT token với thời gian hết hạn 24 giờ
|
86 |
+
expires = datetime.timedelta(hours=24)
|
87 |
+
access_token = create_access_token(
|
88 |
+
identity=str(user.user_id),
|
89 |
+
expires_delta=expires,
|
90 |
+
additional_claims={
|
91 |
+
"name": user.name,
|
92 |
+
"email": user.email,
|
93 |
+
"gender": user.gender,
|
94 |
+
"role": user.role,
|
95 |
+
"permissions": user.permissions,
|
96 |
+
"is_admin": user.is_admin()
|
97 |
+
}
|
98 |
+
)
|
99 |
+
|
100 |
+
return True, {
|
101 |
+
"user_id": str(user.user_id),
|
102 |
+
"user": {
|
103 |
+
"id": str(user.user_id),
|
104 |
+
"name": user.name,
|
105 |
+
"email": user.email,
|
106 |
+
"gender": user.gender,
|
107 |
+
"role": user.role,
|
108 |
+
"permissions": user.permissions,
|
109 |
+
"is_admin": user.is_admin()
|
110 |
+
},
|
111 |
+
"access_token": access_token,
|
112 |
+
"expires_in": 86400
|
113 |
+
}
|
114 |
+
else:
|
115 |
+
return False, result
|
116 |
+
|
117 |
+
@auth_routes.route('/register', methods=['POST'])
|
118 |
+
def register():
|
119 |
+
"""API endpoint để đăng ký người dùng mới"""
|
120 |
+
try:
|
121 |
+
data = request.json
|
122 |
+
|
123 |
+
name = data.get('fullName')
|
124 |
+
email = data.get('email')
|
125 |
+
password = data.get('password')
|
126 |
+
gender = data.get('gender')
|
127 |
+
|
128 |
+
success, result = register_user(name, email, password, gender)
|
129 |
+
|
130 |
+
if success:
|
131 |
+
return jsonify({
|
132 |
+
"success": True,
|
133 |
+
"message": "Đăng ký thành công",
|
134 |
+
"user_id": result.get("user_id")
|
135 |
+
})
|
136 |
+
else:
|
137 |
+
return jsonify({
|
138 |
+
"success": False,
|
139 |
+
"error": result
|
140 |
+
}), 400
|
141 |
+
|
142 |
+
except Exception as e:
|
143 |
+
logger.error(f"Lỗi đăng ký người dùng: {str(e)}")
|
144 |
+
return jsonify({
|
145 |
+
"success": False,
|
146 |
+
"error": str(e)
|
147 |
+
}), 500
|
148 |
+
|
149 |
+
@auth_routes.route('/login', methods=['POST'])
|
150 |
+
def login():
|
151 |
+
"""API endpoint để đăng nhập"""
|
152 |
+
try:
|
153 |
+
data = request.json
|
154 |
+
|
155 |
+
email = data.get('email')
|
156 |
+
password = data.get('password')
|
157 |
+
remember_me = data.get('rememberMe', False)
|
158 |
+
|
159 |
+
success, result = login_user(email, password)
|
160 |
+
|
161 |
+
if success:
|
162 |
+
access_token = result.get("access_token")
|
163 |
+
|
164 |
+
response = make_response(jsonify({
|
165 |
+
"success": True,
|
166 |
+
"user_id": result.get("user_id"),
|
167 |
+
"user": result.get("user"),
|
168 |
+
"access_token": access_token,
|
169 |
+
"expires_in": result.get("expires_in")
|
170 |
+
}))
|
171 |
+
|
172 |
+
cookie_max_age = 86400 if remember_me else None
|
173 |
+
response.set_cookie(
|
174 |
+
'access_token_cookie',
|
175 |
+
access_token,
|
176 |
+
max_age=cookie_max_age,
|
177 |
+
httponly=True,
|
178 |
+
path='/api',
|
179 |
+
samesite='Lax',
|
180 |
+
secure=False
|
181 |
+
)
|
182 |
+
|
183 |
+
return response
|
184 |
+
else:
|
185 |
+
return jsonify({
|
186 |
+
"success": False,
|
187 |
+
"error": result
|
188 |
+
}), 401
|
189 |
+
|
190 |
+
except Exception as e:
|
191 |
+
logger.error(f"Lỗi đăng nhập: {str(e)}")
|
192 |
+
return jsonify({
|
193 |
+
"success": False,
|
194 |
+
"error": str(e)
|
195 |
+
}), 500
|
196 |
+
|
197 |
+
@auth_routes.route('/logout', methods=['POST'])
|
198 |
+
def logout():
|
199 |
+
"""API endpoint để đăng xuất"""
|
200 |
+
response = make_response(jsonify({
|
201 |
+
"success": True,
|
202 |
+
"message": "Đăng xuất thành công"
|
203 |
+
}))
|
204 |
+
response.delete_cookie('access_token_cookie', path='/api')
|
205 |
+
return response
|
206 |
+
|
207 |
+
@auth_routes.route('/verify-token', methods=['POST'])
|
208 |
+
@jwt_required()
|
209 |
+
def verify_token():
|
210 |
+
"""API endpoint để kiểm tra token có hợp lệ không"""
|
211 |
+
try:
|
212 |
+
current_user_id = get_jwt_identity()
|
213 |
+
|
214 |
+
user = User.find_by_id(current_user_id)
|
215 |
+
if not user:
|
216 |
+
return jsonify({
|
217 |
+
"success": False,
|
218 |
+
"error": "User không tồn tại"
|
219 |
+
}), 401
|
220 |
+
|
221 |
+
return jsonify({
|
222 |
+
"success": True,
|
223 |
+
"user_id": current_user_id,
|
224 |
+
"user": {
|
225 |
+
"id": str(user.user_id),
|
226 |
+
"name": user.name,
|
227 |
+
"email": user.email,
|
228 |
+
"gender": user.gender,
|
229 |
+
"role": user.role,
|
230 |
+
"permissions": user.permissions,
|
231 |
+
"is_admin": user.is_admin()
|
232 |
+
}
|
233 |
+
})
|
234 |
+
except Exception as e:
|
235 |
+
return jsonify({
|
236 |
+
"success": False,
|
237 |
+
"error": str(e)
|
238 |
+
}), 401
|
239 |
+
|
240 |
+
@auth_routes.route('/profile', methods=['GET'])
|
241 |
+
@jwt_required()
|
242 |
+
def user_profile():
|
243 |
+
"""API endpoint để lấy thông tin người dùng"""
|
244 |
+
try:
|
245 |
+
user_id = get_jwt_identity()
|
246 |
+
user = User.find_by_id(user_id)
|
247 |
+
|
248 |
+
if user:
|
249 |
+
profile = {
|
250 |
+
"id": str(user.user_id),
|
251 |
+
"name": user.name,
|
252 |
+
"email": user.email,
|
253 |
+
"gender": user.gender,
|
254 |
+
"role": user.role,
|
255 |
+
"permissions": user.permissions,
|
256 |
+
"is_admin": user.is_admin(),
|
257 |
+
"created_at": user.created_at.isoformat() if user.created_at else None,
|
258 |
+
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
259 |
+
"last_login": user.last_login.isoformat() if user.last_login else None
|
260 |
+
}
|
261 |
+
|
262 |
+
return jsonify({
|
263 |
+
"success": True,
|
264 |
+
"user": profile
|
265 |
+
})
|
266 |
+
else:
|
267 |
+
return jsonify({
|
268 |
+
"success": False,
|
269 |
+
"error": "Không tìm thấy thông tin người dùng"
|
270 |
+
}), 404
|
271 |
+
|
272 |
+
except Exception as e:
|
273 |
+
logger.error(f"Lỗi lấy thông tin người dùng: {str(e)}")
|
274 |
+
return jsonify({
|
275 |
+
"success": False,
|
276 |
+
"error": str(e)
|
277 |
+
}), 500
|
278 |
+
|
279 |
+
@auth_routes.route('/profile', methods=['PUT'])
|
280 |
+
@jwt_required()
|
281 |
+
def update_profile():
|
282 |
+
"""API endpoint để cập nhật thông tin người dùng"""
|
283 |
+
try:
|
284 |
+
data = request.json
|
285 |
+
user_id = get_jwt_identity()
|
286 |
+
|
287 |
+
user = User.find_by_id(user_id)
|
288 |
+
if not user:
|
289 |
+
return jsonify({
|
290 |
+
"success": False,
|
291 |
+
"error": "Không tìm thấy người dùng"
|
292 |
+
}), 404
|
293 |
+
|
294 |
+
if 'name' in data:
|
295 |
+
user.name = data['name']
|
296 |
+
|
297 |
+
if 'gender' in data:
|
298 |
+
user.gender = data['gender']
|
299 |
+
|
300 |
+
user.save()
|
301 |
+
|
302 |
+
return jsonify({
|
303 |
+
"success": True,
|
304 |
+
"message": "Cập nhật thông tin thành công"
|
305 |
+
})
|
306 |
+
|
307 |
+
except Exception as e:
|
308 |
+
logger.error(f"Lỗi cập nhật thông tin người dùng: {str(e)}")
|
309 |
+
return jsonify({
|
310 |
+
"success": False,
|
311 |
+
"error": str(e)
|
312 |
+
}), 500
|
313 |
+
|
314 |
+
@auth_routes.route('/change-password', methods=['POST'])
|
315 |
+
@jwt_required()
|
316 |
+
def update_password():
|
317 |
+
"""API endpoint để đổi mật khẩu"""
|
318 |
+
try:
|
319 |
+
data = request.json
|
320 |
+
user_id = get_jwt_identity()
|
321 |
+
|
322 |
+
current_password = data.get('currentPassword')
|
323 |
+
new_password = data.get('newPassword')
|
324 |
+
|
325 |
+
user = User.find_by_id(user_id)
|
326 |
+
if not user:
|
327 |
+
return jsonify({
|
328 |
+
"success": False,
|
329 |
+
"error": "Không tìm thấy người dùng"
|
330 |
+
}), 404
|
331 |
+
|
332 |
+
if not User.check_password(user.password, current_password):
|
333 |
+
return jsonify({
|
334 |
+
"success": False,
|
335 |
+
"error": "Mật khẩu hiện tại không chính xác"
|
336 |
+
}), 400
|
337 |
+
|
338 |
+
if not validate_password(new_password):
|
339 |
+
return jsonify({
|
340 |
+
"success": False,
|
341 |
+
"error": "Mật khẩu mới phải có ít nhất 6 ký tự"
|
342 |
+
}), 400
|
343 |
+
|
344 |
+
user.password = User.hash_password(new_password)
|
345 |
+
user.save()
|
346 |
+
|
347 |
+
return jsonify({
|
348 |
+
"success": True,
|
349 |
+
"message": "Đổi mật khẩu thành công"
|
350 |
+
})
|
351 |
+
|
352 |
+
except Exception as e:
|
353 |
+
logger.error(f"Lỗi đổi mật khẩu: {str(e)}")
|
354 |
+
return jsonify({
|
355 |
+
"success": False,
|
356 |
+
"error": str(e)
|
357 |
+
}), 500
|
358 |
+
|
359 |
+
# === ADMIN ENDPOINTS - SỬA LẠI ĐỂ SỬ DỤNG CHUNG TOKEN ===
|
360 |
+
|
361 |
+
@auth_routes.route('/admin/stats/overview', methods=['GET'])
|
362 |
+
@require_admin
|
363 |
+
def get_admin_overview_stats():
|
364 |
+
"""API endpoint để lấy thống kê tổng quan cho admin"""
|
365 |
+
try:
|
366 |
+
from models.conversation_model import get_db
|
367 |
+
|
368 |
+
db = get_db()
|
369 |
+
|
370 |
+
# Đếm tổng conversations
|
371 |
+
total_conversations = db.conversations.count_documents({})
|
372 |
+
|
373 |
+
# Conversations trong 24h qua
|
374 |
+
day_ago = datetime.datetime.now() - datetime.timedelta(days=1)
|
375 |
+
recent_conversations = db.conversations.count_documents({
|
376 |
+
"created_at": {"$gte": day_ago}
|
377 |
+
})
|
378 |
+
|
379 |
+
# Đếm tổng tin nhắn
|
380 |
+
pipeline = [
|
381 |
+
{"$project": {"message_count": {"$size": "$messages"}}},
|
382 |
+
{"$group": {"_id": None, "total_messages": {"$sum": "$message_count"}}}
|
383 |
+
]
|
384 |
+
message_result = list(db.conversations.aggregate(pipeline))
|
385 |
+
total_messages = message_result[0]["total_messages"] if message_result else 0
|
386 |
+
|
387 |
+
# Mock users data (có thể thay bằng query thực tế)
|
388 |
+
total_users = db.users.count_documents({}) if hasattr(db, 'users') else 1
|
389 |
+
|
390 |
+
return jsonify({
|
391 |
+
"success": True,
|
392 |
+
"stats": {
|
393 |
+
"users": {
|
394 |
+
"total": total_users,
|
395 |
+
"new_today": 0
|
396 |
+
},
|
397 |
+
"conversations": {
|
398 |
+
"total": total_conversations,
|
399 |
+
"recent": recent_conversations
|
400 |
+
},
|
401 |
+
"data": {
|
402 |
+
"total_chunks": total_messages,
|
403 |
+
"total_tables": 0,
|
404 |
+
"total_figures": 0,
|
405 |
+
"total_items": total_messages,
|
406 |
+
"embeddings": 0
|
407 |
+
},
|
408 |
+
"admins": {
|
409 |
+
"total": 1
|
410 |
+
}
|
411 |
+
}
|
412 |
+
})
|
413 |
+
|
414 |
+
except Exception as e:
|
415 |
+
logger.error(f"Lỗi lấy thống kê admin: {str(e)}")
|
416 |
+
return jsonify({
|
417 |
+
"success": False,
|
418 |
+
"error": str(e)
|
419 |
+
}), 500
|
420 |
+
|
421 |
+
@auth_routes.route('/admin/recent-activities', methods=['GET'])
|
422 |
+
@require_admin
|
423 |
+
def get_admin_recent_activities():
|
424 |
+
"""API endpoint để lấy hoạt động gần đây cho admin"""
|
425 |
+
try:
|
426 |
+
limit = int(request.args.get('limit', 10))
|
427 |
+
|
428 |
+
from models.conversation_model import get_db
|
429 |
+
db = get_db()
|
430 |
+
|
431 |
+
# Lấy conversations gần đây
|
432 |
+
recent_conversations = list(db.conversations.find(
|
433 |
+
{},
|
434 |
+
{"title": 1, "created_at": 1, "updated_at": 1}
|
435 |
+
).sort("updated_at", -1).limit(limit))
|
436 |
+
|
437 |
+
activities = []
|
438 |
+
for conv in recent_conversations:
|
439 |
+
activities.append({
|
440 |
+
"type": "conversation_created",
|
441 |
+
"title": "Cuộc hội thoại mới",
|
442 |
+
"description": conv.get("title", "Cuộc hội thoại"),
|
443 |
+
"timestamp": conv.get("updated_at", datetime.datetime.now()).isoformat()
|
444 |
+
})
|
445 |
+
|
446 |
+
return jsonify({
|
447 |
+
"success": True,
|
448 |
+
"activities": activities
|
449 |
+
})
|
450 |
+
|
451 |
+
except Exception as e:
|
452 |
+
logger.error(f"Lỗi lấy hoạt động gần đây: {str(e)}")
|
453 |
+
return jsonify({
|
454 |
+
"success": False,
|
455 |
+
"error": str(e)
|
456 |
+
}), 500
|
457 |
+
|
458 |
+
@auth_routes.route('/admin/alerts', methods=['GET'])
|
459 |
+
@require_admin
|
460 |
+
def get_admin_system_alerts():
|
461 |
+
"""API endpoint để lấy cảnh báo hệ thống cho admin"""
|
462 |
+
try:
|
463 |
+
# Mock alerts - có thể thay bằng logic thực tế
|
464 |
+
alerts = [
|
465 |
+
{
|
466 |
+
"type": "info",
|
467 |
+
"title": "Hệ thống hoạt động bình thường",
|
468 |
+
"message": "Tất cả các dịch vụ đang chạy ổn định",
|
469 |
+
"severity": "low"
|
470 |
+
}
|
471 |
+
]
|
472 |
+
|
473 |
+
return jsonify({
|
474 |
+
"success": True,
|
475 |
+
"alerts": alerts
|
476 |
+
})
|
477 |
+
|
478 |
+
except Exception as e:
|
479 |
+
logger.error(f"Lỗi lấy cảnh báo hệ thống: {str(e)}")
|
480 |
+
return jsonify({
|
481 |
+
"success": False,
|
482 |
+
"error": str(e)
|
483 |
+
}), 500
|
484 |
+
|
485 |
+
@auth_routes.route('/init-admin', methods=['POST'])
|
486 |
+
def init_admin():
|
487 |
+
"""API endpoint để khởi tạo admin mặc định"""
|
488 |
+
try:
|
489 |
+
success, result = User.create_default_admin()
|
490 |
+
|
491 |
+
if success:
|
492 |
+
return jsonify({
|
493 |
+
"success": True,
|
494 |
+
"message": "Khởi tạo admin thành công",
|
495 |
+
"data": result
|
496 |
+
})
|
497 |
+
else:
|
498 |
+
return jsonify({
|
499 |
+
"success": False,
|
500 |
+
"error": result
|
501 |
+
}), 400
|
502 |
+
|
503 |
+
except Exception as e:
|
504 |
+
logger.error(f"Lỗi khởi tạo admin: {str(e)}")
|
505 |
+
return jsonify({
|
506 |
+
"success": False,
|
507 |
+
"error": str(e)
|
508 |
+
}), 500
|
api/chat.py
ADDED
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Blueprint, request, jsonify
|
2 |
+
import logging
|
3 |
+
from core.rag_pipeline import RAGPipeline
|
4 |
+
from core.embedding_model import get_embedding_model
|
5 |
+
from models.conversation_model import Conversation
|
6 |
+
from flask_jwt_extended import jwt_required, get_jwt_identity
|
7 |
+
import datetime
|
8 |
+
|
9 |
+
# Cấu hình logging
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
# Tạo blueprint
|
13 |
+
chat_routes = Blueprint('chat', __name__)
|
14 |
+
|
15 |
+
# Khởi tạo RAG Pipeline một lần duy nhất khi module được load
|
16 |
+
rag_pipeline = None
|
17 |
+
|
18 |
+
def get_rag_pipeline():
|
19 |
+
"""Singleton pattern để tránh khởi tạo lại RAG Pipeline"""
|
20 |
+
global rag_pipeline
|
21 |
+
if rag_pipeline is None:
|
22 |
+
logger.info("Khởi tạo RAG Pipeline lần đầu")
|
23 |
+
rag_pipeline = RAGPipeline()
|
24 |
+
return rag_pipeline
|
25 |
+
|
26 |
+
# Hàm tạo tiêu đề từ tin nhắn
|
27 |
+
def create_title_from_message(message, max_length=50):
|
28 |
+
"""Tạo tiêu đề cuộc trò chuyện từ tin nhắn đầu tiên của người dùng"""
|
29 |
+
message = message.strip().replace('\n', ' ')
|
30 |
+
if len(message) <= max_length:
|
31 |
+
return message
|
32 |
+
return message[:max_length-3] + "..."
|
33 |
+
|
34 |
+
@chat_routes.route('/chat', methods=['POST'])
|
35 |
+
@jwt_required()
|
36 |
+
def chat():
|
37 |
+
"""API endpoint để xử lý tin nhắn chat"""
|
38 |
+
try:
|
39 |
+
data = request.json
|
40 |
+
message = data.get('message')
|
41 |
+
age = data.get('age', 1)
|
42 |
+
conversation_id = data.get('conversation_id')
|
43 |
+
|
44 |
+
user_id = get_jwt_identity()
|
45 |
+
|
46 |
+
if not message:
|
47 |
+
return jsonify({
|
48 |
+
"success": False,
|
49 |
+
"error": "Vui lòng nhập tin nhắn"
|
50 |
+
}), 400
|
51 |
+
|
52 |
+
logger.info(f"Nhận tin nhắn từ user {user_id}: {message[:50]}...")
|
53 |
+
|
54 |
+
# Xử lý conversation
|
55 |
+
if conversation_id:
|
56 |
+
conversation = Conversation.find_by_id(conversation_id)
|
57 |
+
if not conversation or str(conversation.user_id) != user_id:
|
58 |
+
return jsonify({
|
59 |
+
"success": False,
|
60 |
+
"error": "Không tìm thấy cuộc trò chuyện"
|
61 |
+
}), 404
|
62 |
+
|
63 |
+
if len(conversation.messages) == 0:
|
64 |
+
final_title = create_title_from_message(message, 50)
|
65 |
+
conversation.title = final_title
|
66 |
+
logger.info(f"Cập nhật title cho conversation {conversation_id}: '{final_title}'")
|
67 |
+
else:
|
68 |
+
final_title = create_title_from_message(message, 50)
|
69 |
+
conversation = Conversation.create(
|
70 |
+
user_id=user_id,
|
71 |
+
title=final_title,
|
72 |
+
age_context=age
|
73 |
+
)
|
74 |
+
logger.info(f"Tạo conversation mới với title: '{final_title}'")
|
75 |
+
|
76 |
+
# Thêm tin nhắn của user
|
77 |
+
conversation.add_message("user", message)
|
78 |
+
|
79 |
+
# Sử dụng RAG Pipeline để generate response
|
80 |
+
pipeline = get_rag_pipeline()
|
81 |
+
|
82 |
+
# Generate response sử dụng RAG
|
83 |
+
logger.info("Bắt đầu generate response với RAG Pipeline")
|
84 |
+
response_data = pipeline.generate_response(message, age)
|
85 |
+
|
86 |
+
if response_data.get("success"):
|
87 |
+
bot_response = response_data.get("response", "Xin lỗi, tôi không thể trả lời câu hỏi này.")
|
88 |
+
sources = response_data.get("sources", [])
|
89 |
+
|
90 |
+
# Thêm tin nhắn bot vào conversation
|
91 |
+
conversation.add_message("bot", bot_response, sources=sources)
|
92 |
+
|
93 |
+
logger.info(f"Đã generate response thành công cho conversation {conversation.conversation_id}")
|
94 |
+
|
95 |
+
return jsonify({
|
96 |
+
"success": True,
|
97 |
+
"response": bot_response,
|
98 |
+
"sources": sources,
|
99 |
+
"conversation_id": str(conversation.conversation_id)
|
100 |
+
})
|
101 |
+
else:
|
102 |
+
error_msg = response_data.get("error", "Không thể tạo phản hồi")
|
103 |
+
logger.error(f"Lỗi generate response: {error_msg}")
|
104 |
+
|
105 |
+
return jsonify({
|
106 |
+
"success": False,
|
107 |
+
"error": error_msg
|
108 |
+
}), 500
|
109 |
+
|
110 |
+
except Exception as e:
|
111 |
+
logger.error(f"Lỗi xử lý chat: {str(e)}")
|
112 |
+
return jsonify({
|
113 |
+
"success": False,
|
114 |
+
"error": f"Lỗi máy chủ: {str(e)}"
|
115 |
+
}), 500
|
116 |
+
|
117 |
+
@chat_routes.route('/messages/<message_id>/edit', methods=['PUT'])
|
118 |
+
@jwt_required()
|
119 |
+
def edit_message(message_id):
|
120 |
+
"""API endpoint để chỉnh sửa tin nhắn"""
|
121 |
+
try:
|
122 |
+
data = request.json
|
123 |
+
new_content = data.get('content')
|
124 |
+
conversation_id = data.get('conversation_id')
|
125 |
+
age = data.get('age', 1)
|
126 |
+
|
127 |
+
user_id = get_jwt_identity()
|
128 |
+
|
129 |
+
if not new_content or not conversation_id:
|
130 |
+
return jsonify({
|
131 |
+
"success": False,
|
132 |
+
"error": "Thiếu thông tin cần thiết"
|
133 |
+
}), 400
|
134 |
+
|
135 |
+
logger.info(f"Edit message {message_id} in conversation {conversation_id}: {new_content[:50]}...")
|
136 |
+
|
137 |
+
# Tìm conversation
|
138 |
+
conversation = Conversation.find_by_id(conversation_id)
|
139 |
+
if not conversation or str(conversation.user_id) != user_id:
|
140 |
+
return jsonify({
|
141 |
+
"success": False,
|
142 |
+
"error": "Không tìm thấy cuộc trò chuyện"
|
143 |
+
}), 404
|
144 |
+
|
145 |
+
# Tìm message và kiểm tra quyền
|
146 |
+
message_found = False
|
147 |
+
for msg in conversation.messages:
|
148 |
+
if str(msg.get('_id', msg.get('id'))) == message_id:
|
149 |
+
if msg['role'] != 'user':
|
150 |
+
return jsonify({
|
151 |
+
"success": False,
|
152 |
+
"error": "Chỉ có thể chỉnh sửa tin nhắn của người dùng"
|
153 |
+
}), 400
|
154 |
+
message_found = True
|
155 |
+
break
|
156 |
+
|
157 |
+
if not message_found:
|
158 |
+
return jsonify({
|
159 |
+
"success": False,
|
160 |
+
"error": "Không tìm thấy tin nhắn"
|
161 |
+
}), 404
|
162 |
+
|
163 |
+
# Cập nhật tin nhắn và xóa tất cả tin nhắn sau nó
|
164 |
+
success, result_message = conversation.edit_message(message_id, new_content)
|
165 |
+
|
166 |
+
if not success:
|
167 |
+
return jsonify({
|
168 |
+
"success": False,
|
169 |
+
"error": result_message
|
170 |
+
}), 400
|
171 |
+
|
172 |
+
# Tạo phản hồi mới với RAG Pipeline
|
173 |
+
pipeline = get_rag_pipeline()
|
174 |
+
response_data = pipeline.generate_response(new_content, age)
|
175 |
+
|
176 |
+
if response_data.get("success"):
|
177 |
+
bot_response = response_data.get("response", "Xin lỗi, tôi không thể trả lời câu hỏi này.")
|
178 |
+
sources = response_data.get("sources", [])
|
179 |
+
|
180 |
+
# Thêm phản hồi bot mới
|
181 |
+
success, bot_message = conversation.regenerate_bot_response_after_edit(message_id, bot_response, sources)
|
182 |
+
|
183 |
+
if success:
|
184 |
+
# Trả về conversation đã cập nhật
|
185 |
+
updated_conversation = Conversation.find_by_id(conversation_id)
|
186 |
+
logger.info(f"Successfully edited message and generated new response")
|
187 |
+
return jsonify({
|
188 |
+
"success": True,
|
189 |
+
"message": "Đã chỉnh sửa tin nhắn và tạo phản hồi mới",
|
190 |
+
"conversation": updated_conversation.to_dict()
|
191 |
+
})
|
192 |
+
else:
|
193 |
+
return jsonify({
|
194 |
+
"success": False,
|
195 |
+
"error": bot_message
|
196 |
+
}), 500
|
197 |
+
else:
|
198 |
+
return jsonify({
|
199 |
+
"success": False,
|
200 |
+
"error": response_data.get("error", "Không thể tạo phản hồi mới")
|
201 |
+
}), 500
|
202 |
+
|
203 |
+
except Exception as e:
|
204 |
+
logger.error(f"Lỗi chỉnh sửa tin nhắn: {str(e)}")
|
205 |
+
return jsonify({
|
206 |
+
"success": False,
|
207 |
+
"error": f"Lỗi máy chủ: {str(e)}"
|
208 |
+
}), 500
|
209 |
+
|
210 |
+
@chat_routes.route('/messages/<message_id>/regenerate', methods=['POST'])
|
211 |
+
@jwt_required()
|
212 |
+
def regenerate_response(message_id):
|
213 |
+
"""API endpoint để tạo lại phản hồi"""
|
214 |
+
try:
|
215 |
+
data = request.json
|
216 |
+
conversation_id = data.get('conversation_id')
|
217 |
+
age = data.get('age', 1)
|
218 |
+
|
219 |
+
user_id = get_jwt_identity()
|
220 |
+
|
221 |
+
if not conversation_id:
|
222 |
+
return jsonify({
|
223 |
+
"success": False,
|
224 |
+
"error": "Thiếu conversation_id"
|
225 |
+
}), 400
|
226 |
+
|
227 |
+
logger.info(f"Regenerate response for message {message_id} in conversation {conversation_id}")
|
228 |
+
|
229 |
+
# Tìm conversation
|
230 |
+
conversation = Conversation.find_by_id(conversation_id)
|
231 |
+
if not conversation or str(conversation.user_id) != user_id:
|
232 |
+
return jsonify({
|
233 |
+
"success": False,
|
234 |
+
"error": "Không tìm thấy cuộc trò chuyện"
|
235 |
+
}), 404
|
236 |
+
|
237 |
+
# Tìm tin nhắn và tin nhắn user trước đó
|
238 |
+
user_message = None
|
239 |
+
for i, msg in enumerate(conversation.messages):
|
240 |
+
if str(msg.get('_id', msg.get('id'))) == message_id:
|
241 |
+
if msg['role'] != 'bot':
|
242 |
+
return jsonify({
|
243 |
+
"success": False,
|
244 |
+
"error": "Chỉ có thể regenerate phản hồi của bot"
|
245 |
+
}), 400
|
246 |
+
# Tìm tin nhắn user trước đó
|
247 |
+
if i > 0 and conversation.messages[i-1]['role'] == 'user':
|
248 |
+
user_message = conversation.messages[i-1]['content']
|
249 |
+
break
|
250 |
+
|
251 |
+
if not user_message:
|
252 |
+
return jsonify({
|
253 |
+
"success": False,
|
254 |
+
"error": "Không tìm thấy tin nhắn người dùng tương ứng"
|
255 |
+
}), 404
|
256 |
+
|
257 |
+
# Sử dụng RAG Pipeline để generate response mới
|
258 |
+
pipeline = get_rag_pipeline()
|
259 |
+
response_data = pipeline.generate_response(user_message, age)
|
260 |
+
|
261 |
+
if response_data.get("success"):
|
262 |
+
bot_response = response_data.get("response", "Xin lỗi, tôi không thể trả lời câu hỏi này.")
|
263 |
+
sources = response_data.get("sources", [])
|
264 |
+
|
265 |
+
success, result_message = conversation.regenerate_response(message_id, bot_response, sources)
|
266 |
+
|
267 |
+
if success:
|
268 |
+
# Trả về conversation đã cập nhật
|
269 |
+
updated_conversation = Conversation.find_by_id(conversation_id)
|
270 |
+
logger.info(f"Successfully regenerated response")
|
271 |
+
return jsonify({
|
272 |
+
"success": True,
|
273 |
+
"conversation": updated_conversation.to_dict()
|
274 |
+
})
|
275 |
+
else:
|
276 |
+
return jsonify({
|
277 |
+
"success": False,
|
278 |
+
"error": result_message
|
279 |
+
}), 400
|
280 |
+
else:
|
281 |
+
return jsonify({
|
282 |
+
"success": False,
|
283 |
+
"error": response_data.get("error", "Không thể tạo phản hồi mới")
|
284 |
+
}), 500
|
285 |
+
|
286 |
+
except Exception as e:
|
287 |
+
logger.error(f"Lỗi regenerate response: {str(e)}")
|
288 |
+
return jsonify({
|
289 |
+
"success": False,
|
290 |
+
"error": f"Lỗi máy chủ: {str(e)}"
|
291 |
+
}), 500
|
292 |
+
|
293 |
+
@chat_routes.route('/messages/<message_id>/versions/<int:version>', methods=['PUT'])
|
294 |
+
@jwt_required()
|
295 |
+
def switch_message_version(message_id, version):
|
296 |
+
"""API endpoint để chuyển đổi version của tin nhắn"""
|
297 |
+
try:
|
298 |
+
data = request.json
|
299 |
+
conversation_id = data.get('conversation_id')
|
300 |
+
|
301 |
+
user_id = get_jwt_identity()
|
302 |
+
|
303 |
+
if not conversation_id:
|
304 |
+
return jsonify({
|
305 |
+
"success": False,
|
306 |
+
"error": "Thiếu conversation_id"
|
307 |
+
}), 400
|
308 |
+
|
309 |
+
logger.info(f"Switching message {message_id} to version {version} in conversation {conversation_id}")
|
310 |
+
|
311 |
+
# Tìm conversation
|
312 |
+
conversation = Conversation.find_by_id(conversation_id)
|
313 |
+
if not conversation or str(conversation.user_id) != user_id:
|
314 |
+
return jsonify({
|
315 |
+
"success": False,
|
316 |
+
"error": "Không tìm thấy cuộc trò chuyện"
|
317 |
+
}), 404
|
318 |
+
|
319 |
+
# Debug: Log current conversation state before switch
|
320 |
+
logger.info(f"Before switch - Conversation has {len(conversation.messages)} messages")
|
321 |
+
for i, msg in enumerate(conversation.messages):
|
322 |
+
logger.info(f" Message {i}: {msg['role']} - {msg['content'][:30]}... (current_version: {msg.get('current_version', 1)})")
|
323 |
+
|
324 |
+
# Chuyển đổi version
|
325 |
+
success = conversation.switch_message_version(message_id, version)
|
326 |
+
|
327 |
+
if success:
|
328 |
+
# Reload conversation để đảm bảo có dữ liệu mới nhất
|
329 |
+
updated_conversation = Conversation.find_by_id(conversation_id)
|
330 |
+
|
331 |
+
# Debug: Log conversation state after switch
|
332 |
+
logger.info(f"After switch - Conversation has {len(updated_conversation.messages)} messages")
|
333 |
+
for i, msg in enumerate(updated_conversation.messages):
|
334 |
+
logger.info(f" Message {i}: {msg['role']} - {msg['content'][:30]}... (current_version: {msg.get('current_version', 1)})")
|
335 |
+
|
336 |
+
logger.info(f"Successfully switched message {message_id} to version {version}")
|
337 |
+
return jsonify({
|
338 |
+
"success": True,
|
339 |
+
"conversation": updated_conversation.to_dict()
|
340 |
+
})
|
341 |
+
else:
|
342 |
+
logger.error(f"Failed to switch message {message_id} to version {version}")
|
343 |
+
return jsonify({
|
344 |
+
"success": False,
|
345 |
+
"error": "Không thể chuyển đổi version"
|
346 |
+
}), 400
|
347 |
+
|
348 |
+
except Exception as e:
|
349 |
+
logger.error(f"Lỗi chuyển đổi version: {str(e)}")
|
350 |
+
return jsonify({
|
351 |
+
"success": False,
|
352 |
+
"error": f"Lỗi máy chủ: {str(e)}"
|
353 |
+
}), 500
|
354 |
+
|
355 |
+
@chat_routes.route('/messages/<message_id>', methods=['DELETE'])
|
356 |
+
@jwt_required()
|
357 |
+
def delete_message_and_following(message_id):
|
358 |
+
"""API endpoint để xóa tin nhắn và tất cả tin nhắn sau nó"""
|
359 |
+
try:
|
360 |
+
data = request.json
|
361 |
+
conversation_id = data.get('conversation_id')
|
362 |
+
|
363 |
+
user_id = get_jwt_identity()
|
364 |
+
|
365 |
+
if not conversation_id:
|
366 |
+
return jsonify({
|
367 |
+
"success": False,
|
368 |
+
"error": "Thiếu conversation_id"
|
369 |
+
}), 400
|
370 |
+
|
371 |
+
# Tìm conversation
|
372 |
+
conversation = Conversation.find_by_id(conversation_id)
|
373 |
+
if not conversation or str(conversation.user_id) != user_id:
|
374 |
+
return jsonify({
|
375 |
+
"success": False,
|
376 |
+
"error": "Không tìm thấy cuộc trò chuyện"
|
377 |
+
}), 404
|
378 |
+
|
379 |
+
# Xóa tin nhắn và các tin nhắn theo sau
|
380 |
+
success = conversation.delete_message_and_following(message_id)
|
381 |
+
|
382 |
+
if success:
|
383 |
+
updated_conversation = Conversation.find_by_id(conversation_id)
|
384 |
+
return jsonify({
|
385 |
+
"success": True,
|
386 |
+
"conversation": updated_conversation.to_dict()
|
387 |
+
})
|
388 |
+
else:
|
389 |
+
return jsonify({
|
390 |
+
"success": False,
|
391 |
+
"error": "Không thể xóa tin nhắn"
|
392 |
+
}), 400
|
393 |
+
|
394 |
+
except Exception as e:
|
395 |
+
logger.error(f"Lỗi xóa tin nhắn: {str(e)}")
|
396 |
+
return jsonify({
|
397 |
+
"success": False,
|
398 |
+
"error": f"Lỗi máy chủ: {str(e)}"
|
399 |
+
}), 500
|
api/data.py
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Blueprint, request, jsonify, send_from_directory
|
2 |
+
import os
|
3 |
+
import logging
|
4 |
+
from core.data_processor import DataProcessor
|
5 |
+
from flask_jwt_extended import jwt_required, get_jwt_identity
|
6 |
+
|
7 |
+
# Cấu hình logging
|
8 |
+
logger = logging.getLogger(__name__)
|
9 |
+
|
10 |
+
# Tạo blueprint
|
11 |
+
data_routes = Blueprint('data', __name__)
|
12 |
+
|
13 |
+
# Đường dẫn đến thư mục dữ liệu
|
14 |
+
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
15 |
+
data_dir = os.path.join(current_dir, "..", "data")
|
16 |
+
data_dir = os.path.abspath(data_dir)
|
17 |
+
|
18 |
+
@data_routes.route('/figures/<path:bai_id>/<path:filename>', methods=['GET'])
|
19 |
+
def serve_figure(bai_id, filename):
|
20 |
+
"""API endpoint để phục vụ hình ảnh theo bài"""
|
21 |
+
try:
|
22 |
+
# Đường dẫn chính xác đến thư mục figures
|
23 |
+
figure_dir = os.path.join(data_dir, bai_id, 'figures')
|
24 |
+
figure_dir = os.path.abspath(figure_dir)
|
25 |
+
|
26 |
+
# Kiểm tra xem thư mục có tồn tại không
|
27 |
+
if os.path.exists(figure_dir) and os.path.isdir(figure_dir):
|
28 |
+
for ext in ['', '.png', '.jpg', '.jpeg', '.gif', '.svg']:
|
29 |
+
# Kiểm tra với nhiều phần mở rộng khác nhau
|
30 |
+
file_to_check = os.path.join(figure_dir, filename + ext)
|
31 |
+
if os.path.exists(file_to_check):
|
32 |
+
return send_from_directory(figure_dir, filename + ext)
|
33 |
+
|
34 |
+
logger.error(f"Không tìm thấy hình ảnh: {os.path.join(figure_dir, filename)}")
|
35 |
+
return jsonify({"error": "Không tìm thấy hình ảnh"}), 404
|
36 |
+
|
37 |
+
except Exception as e:
|
38 |
+
logger.error(f"Lỗi khi tải hình ảnh: {str(e)}")
|
39 |
+
return jsonify({"error": f"Lỗi máy chủ: {str(e)}"}), 500
|
40 |
+
|
41 |
+
@data_routes.route('/metadata', methods=['GET'])
|
42 |
+
@jwt_required(optional=True)
|
43 |
+
def get_metadata():
|
44 |
+
"""API endpoint để lấy thông tin metadata của tài liệu"""
|
45 |
+
try:
|
46 |
+
# Tạo data_processor mới để lấy metadata
|
47 |
+
data_processor = DataProcessor(data_dir=data_dir)
|
48 |
+
metadata = data_processor.get_all_metadata()
|
49 |
+
return jsonify({
|
50 |
+
"success": True,
|
51 |
+
"metadata": metadata
|
52 |
+
})
|
53 |
+
except Exception as e:
|
54 |
+
logger.error(f"Lỗi khi lấy metadata: {str(e)}")
|
55 |
+
return jsonify({
|
56 |
+
"success": False,
|
57 |
+
"error": str(e)
|
58 |
+
}), 500
|
api/feedback.py
ADDED
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Blueprint, request, jsonify
|
2 |
+
import logging
|
3 |
+
from models.feedback_model import Feedback
|
4 |
+
from models.user_model import User
|
5 |
+
from flask_jwt_extended import jwt_required, get_jwt_identity
|
6 |
+
|
7 |
+
# Cấu hình logging
|
8 |
+
logger = logging.getLogger(__name__)
|
9 |
+
|
10 |
+
# Tạo blueprint
|
11 |
+
feedback_routes = Blueprint('feedback', __name__)
|
12 |
+
|
13 |
+
@feedback_routes.route('/feedback/categories', methods=['GET'])
|
14 |
+
def get_feedback_categories():
|
15 |
+
"""API endpoint để lấy danh sách categories"""
|
16 |
+
categories = [
|
17 |
+
{"value": "bug_report", "label": "Báo lỗi"},
|
18 |
+
{"value": "feature_request", "label": "Đề xuất tính năng"},
|
19 |
+
{"value": "content_feedback", "label": "Phản hồi nội dung"},
|
20 |
+
{"value": "user_experience", "label": "Trải nghiệm người dùng"},
|
21 |
+
{"value": "other", "label": "Khác"}
|
22 |
+
]
|
23 |
+
|
24 |
+
return jsonify({
|
25 |
+
"success": True,
|
26 |
+
"categories": categories
|
27 |
+
})
|
28 |
+
|
29 |
+
@feedback_routes.route('/feedback', methods=['POST'])
|
30 |
+
@jwt_required()
|
31 |
+
def create_feedback():
|
32 |
+
"""API endpoint để tạo feedback mới"""
|
33 |
+
try:
|
34 |
+
data = request.json
|
35 |
+
user_id = get_jwt_identity()
|
36 |
+
|
37 |
+
rating = data.get('rating', 5)
|
38 |
+
category = data.get('category', '')
|
39 |
+
title = data.get('title', '')
|
40 |
+
content = data.get('content', '')
|
41 |
+
|
42 |
+
if not category or not content.strip():
|
43 |
+
return jsonify({
|
44 |
+
"success": False,
|
45 |
+
"error": "Vui lòng điền đầy đủ thông tin"
|
46 |
+
}), 400
|
47 |
+
|
48 |
+
if len(content.strip()) < 10:
|
49 |
+
return jsonify({
|
50 |
+
"success": False,
|
51 |
+
"error": "Nội dung phải có ít nhất 10 ký tự"
|
52 |
+
}), 400
|
53 |
+
|
54 |
+
success, result = Feedback.create_feedback(
|
55 |
+
user_id=user_id,
|
56 |
+
rating=rating,
|
57 |
+
category=category,
|
58 |
+
title=title,
|
59 |
+
content=content
|
60 |
+
)
|
61 |
+
|
62 |
+
if success:
|
63 |
+
return jsonify({
|
64 |
+
"success": True,
|
65 |
+
"message": "Đã gửi feedback thành công",
|
66 |
+
"feedback_id": result["feedback_id"]
|
67 |
+
})
|
68 |
+
else:
|
69 |
+
return jsonify({
|
70 |
+
"success": False,
|
71 |
+
"error": result
|
72 |
+
}), 500
|
73 |
+
|
74 |
+
except Exception as e:
|
75 |
+
logger.error(f"Lỗi tạo feedback: {str(e)}")
|
76 |
+
return jsonify({
|
77 |
+
"success": False,
|
78 |
+
"error": str(e)
|
79 |
+
}), 500
|
80 |
+
|
81 |
+
@feedback_routes.route('/feedback', methods=['GET'])
|
82 |
+
@jwt_required()
|
83 |
+
def get_user_feedback():
|
84 |
+
"""API endpoint để lấy feedback của user hiện tại"""
|
85 |
+
try:
|
86 |
+
user_id = get_jwt_identity()
|
87 |
+
page = int(request.args.get('page', 1))
|
88 |
+
per_page = int(request.args.get('per_page', 10))
|
89 |
+
|
90 |
+
skip = (page - 1) * per_page
|
91 |
+
feedbacks = Feedback.find_by_user(user_id, limit=per_page, skip=skip)
|
92 |
+
|
93 |
+
result = []
|
94 |
+
for feedback in feedbacks:
|
95 |
+
result.append({
|
96 |
+
"id": str(feedback.feedback_id),
|
97 |
+
"rating": feedback.rating,
|
98 |
+
"category": feedback.category,
|
99 |
+
"title": feedback.title,
|
100 |
+
"content": feedback.content,
|
101 |
+
"status": feedback.status,
|
102 |
+
"admin_response": feedback.admin_response,
|
103 |
+
"created_at": feedback.created_at.isoformat(),
|
104 |
+
"updated_at": feedback.updated_at.isoformat()
|
105 |
+
})
|
106 |
+
|
107 |
+
return jsonify({
|
108 |
+
"success": True,
|
109 |
+
"feedbacks": result
|
110 |
+
})
|
111 |
+
|
112 |
+
except Exception as e:
|
113 |
+
logger.error(f"Lỗi lấy feedback: {str(e)}")
|
114 |
+
return jsonify({
|
115 |
+
"success": False,
|
116 |
+
"error": str(e)
|
117 |
+
}), 500
|
api/history.py
ADDED
@@ -0,0 +1,777 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Blueprint, request, jsonify
|
2 |
+
import logging
|
3 |
+
from bson.objectid import ObjectId
|
4 |
+
from models.conversation_model import Conversation
|
5 |
+
from models.user_model import User
|
6 |
+
from flask_jwt_extended import jwt_required, get_jwt_identity
|
7 |
+
|
8 |
+
# Cấu hình logging
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
# Tạo blueprint
|
12 |
+
history_routes = Blueprint('history', __name__)
|
13 |
+
|
14 |
+
# Hàm đơn giản để tạo tiêu đề từ tin nhắn
|
15 |
+
def create_title_from_message(message, max_length=50):
|
16 |
+
"""Tạo tiêu đề cuộc trò chuyện từ tin nhắn đầu tiên của người dùng"""
|
17 |
+
# Loại bỏ ký tự xuống dòng và khoảng trắng thừa
|
18 |
+
message = message.strip().replace('\n', ' ')
|
19 |
+
|
20 |
+
# Nếu tin nhắn đủ ngắn, sử dụng làm tiêu đề luôn
|
21 |
+
if len(message) <= max_length:
|
22 |
+
return message
|
23 |
+
|
24 |
+
# Nếu tin nhắn quá dài, cắt ngắn và thêm dấu "..."
|
25 |
+
return message[:max_length-3] + "..."
|
26 |
+
|
27 |
+
@history_routes.route('/conversations', methods=['GET'])
|
28 |
+
@jwt_required()
|
29 |
+
def get_conversations():
|
30 |
+
"""API endpoint để lấy danh sách cuộc hội thoại của người dùng"""
|
31 |
+
try:
|
32 |
+
user_id = get_jwt_identity()
|
33 |
+
|
34 |
+
# Lấy tham số phân trang từ query string
|
35 |
+
page = int(request.args.get('page', 1))
|
36 |
+
per_page = int(request.args.get('per_page', 50)) # ✅ TĂNG default per_page
|
37 |
+
include_archived = request.args.get('include_archived', 'false').lower() == 'true'
|
38 |
+
|
39 |
+
# Tính toán offset
|
40 |
+
skip = (page - 1) * per_page
|
41 |
+
|
42 |
+
logger.info(f"🔍 Getting conversations for user {user_id}, page {page}, per_page {per_page}, include_archived {include_archived}")
|
43 |
+
|
44 |
+
# Lấy danh sách cuộc hội thoại
|
45 |
+
conversations = Conversation.find_by_user(
|
46 |
+
user_id=user_id,
|
47 |
+
limit=per_page,
|
48 |
+
skip=skip,
|
49 |
+
include_archived=include_archived
|
50 |
+
)
|
51 |
+
|
52 |
+
# Đếm tổng số cuộc hội thoại
|
53 |
+
total_count = Conversation.count_by_user(
|
54 |
+
user_id=user_id,
|
55 |
+
include_archived=include_archived
|
56 |
+
)
|
57 |
+
|
58 |
+
logger.info(f"📊 Found {len(conversations)} conversations, total: {total_count}")
|
59 |
+
|
60 |
+
# Chuẩn bị dữ liệu phản hồi
|
61 |
+
result = []
|
62 |
+
for conversation in conversations:
|
63 |
+
# Chỉ lấy tin nhắn mới nhất để hiển thị xem trước
|
64 |
+
last_message = conversation.messages[-1]["content"] if conversation.messages else ""
|
65 |
+
message_count = len(conversation.messages)
|
66 |
+
|
67 |
+
result.append({
|
68 |
+
"id": str(conversation.conversation_id),
|
69 |
+
"title": conversation.title,
|
70 |
+
"created_at": conversation.created_at.isoformat(),
|
71 |
+
"updated_at": conversation.updated_at.isoformat(),
|
72 |
+
"age_context": conversation.age_context,
|
73 |
+
"is_archived": conversation.is_archived,
|
74 |
+
"last_message": last_message[:100] + "..." if len(last_message) > 100 else last_message,
|
75 |
+
"message_count": message_count
|
76 |
+
})
|
77 |
+
|
78 |
+
logger.info(f"✅ Returning {len(result)} conversations")
|
79 |
+
|
80 |
+
# Tạo phản hồi với thông tin phân trang
|
81 |
+
return jsonify({
|
82 |
+
"success": True,
|
83 |
+
"conversations": result,
|
84 |
+
"pagination": {
|
85 |
+
"page": page,
|
86 |
+
"per_page": per_page,
|
87 |
+
"total": total_count,
|
88 |
+
"pages": (total_count + per_page - 1) // per_page # Ceiling division
|
89 |
+
}
|
90 |
+
})
|
91 |
+
|
92 |
+
except Exception as e:
|
93 |
+
logger.error(f"❌ Lỗi khi lấy danh sách cuộc hội thoại: {str(e)}")
|
94 |
+
return jsonify({
|
95 |
+
"success": False,
|
96 |
+
"error": str(e)
|
97 |
+
}), 500
|
98 |
+
|
99 |
+
@history_routes.route('/conversations/<conversation_id>', methods=['GET'])
|
100 |
+
@jwt_required()
|
101 |
+
def get_conversation_detail(conversation_id):
|
102 |
+
"""API endpoint để lấy chi tiết một cuộc hội thoại"""
|
103 |
+
try:
|
104 |
+
user_id = get_jwt_identity()
|
105 |
+
|
106 |
+
def safe_datetime_to_string(dt_obj):
|
107 |
+
"""Safely convert datetime object to ISO string"""
|
108 |
+
if dt_obj is None:
|
109 |
+
return None
|
110 |
+
# Nếu đã là string, return nguyên
|
111 |
+
if isinstance(dt_obj, str):
|
112 |
+
return dt_obj
|
113 |
+
# Nếu là datetime object, convert sang string
|
114 |
+
if hasattr(dt_obj, 'isoformat'):
|
115 |
+
return dt_obj.isoformat()
|
116 |
+
# Fallback: convert to string
|
117 |
+
return str(dt_obj)
|
118 |
+
|
119 |
+
# Lấy thông tin cuộc hội thoại
|
120 |
+
conversation = Conversation.find_by_id(conversation_id)
|
121 |
+
|
122 |
+
if not conversation:
|
123 |
+
return jsonify({
|
124 |
+
"success": False,
|
125 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
126 |
+
}), 404
|
127 |
+
|
128 |
+
# Kiểm tra quy���n truy cập
|
129 |
+
if str(conversation.user_id) != user_id:
|
130 |
+
return jsonify({
|
131 |
+
"success": False,
|
132 |
+
"error": "Bạn không có quyền truy cập cuộc hội thoại này"
|
133 |
+
}), 403
|
134 |
+
|
135 |
+
conversation_data = {
|
136 |
+
"id": str(conversation.conversation_id),
|
137 |
+
"title": conversation.title,
|
138 |
+
"created_at": safe_datetime_to_string(conversation.created_at),
|
139 |
+
"updated_at": safe_datetime_to_string(conversation.updated_at),
|
140 |
+
"age_context": conversation.age_context,
|
141 |
+
"is_archived": conversation.is_archived,
|
142 |
+
"messages": []
|
143 |
+
}
|
144 |
+
|
145 |
+
for message in conversation.messages:
|
146 |
+
message_data = {
|
147 |
+
"id": str(message["_id"]),
|
148 |
+
"_id": str(message["_id"]),
|
149 |
+
"role": message["role"],
|
150 |
+
"content": message["content"],
|
151 |
+
"timestamp": safe_datetime_to_string(message.get("timestamp")),
|
152 |
+
"current_version": message.get("current_version", 1),
|
153 |
+
"is_edited": message.get("is_edited", False)
|
154 |
+
}
|
155 |
+
|
156 |
+
if "versions" in message and message["versions"]:
|
157 |
+
message_data["versions"] = []
|
158 |
+
for version in message["versions"]:
|
159 |
+
version_data = {
|
160 |
+
"content": version["content"],
|
161 |
+
"timestamp": safe_datetime_to_string(version.get("timestamp")),
|
162 |
+
"version": version["version"]
|
163 |
+
}
|
164 |
+
|
165 |
+
# Thêm sources cho version nếu có
|
166 |
+
if "sources" in version:
|
167 |
+
version_data["sources"] = version["sources"]
|
168 |
+
|
169 |
+
# Thêm metadata cho version nếu có
|
170 |
+
if "metadata" in version:
|
171 |
+
version_data["metadata"] = version["metadata"]
|
172 |
+
|
173 |
+
# conversation_snapshot chỉ dùng để restore, không cần trả về frontend
|
174 |
+
message_data["versions"].append(version_data)
|
175 |
+
else:
|
176 |
+
# Nếu không có versions, tạo default version
|
177 |
+
message_data["versions"] = [{
|
178 |
+
"content": message["content"],
|
179 |
+
"timestamp": safe_datetime_to_string(message.get("timestamp")),
|
180 |
+
"version": 1
|
181 |
+
}]
|
182 |
+
|
183 |
+
# Thêm sources nếu có
|
184 |
+
if "sources" in message:
|
185 |
+
message_data["sources"] = message["sources"]
|
186 |
+
|
187 |
+
# Thêm metadata nếu có
|
188 |
+
if "metadata" in message:
|
189 |
+
message_data["metadata"] = message["metadata"]
|
190 |
+
|
191 |
+
conversation_data["messages"].append(message_data)
|
192 |
+
|
193 |
+
return jsonify({
|
194 |
+
"success": True,
|
195 |
+
"conversation": conversation_data
|
196 |
+
})
|
197 |
+
|
198 |
+
except Exception as e:
|
199 |
+
logger.error(f"Lỗi khi lấy chi tiết cuộc hội thoại: {str(e)}")
|
200 |
+
return jsonify({
|
201 |
+
"success": False,
|
202 |
+
"error": str(e)
|
203 |
+
}), 500
|
204 |
+
|
205 |
+
@history_routes.route('/conversations', methods=['POST'])
|
206 |
+
@jwt_required()
|
207 |
+
def create_conversation():
|
208 |
+
"""API endpoint để tạo cuộc hội thoại mới"""
|
209 |
+
try:
|
210 |
+
data = request.json
|
211 |
+
user_id = get_jwt_identity()
|
212 |
+
|
213 |
+
# Lấy thông tin user
|
214 |
+
user = User.find_by_id(user_id)
|
215 |
+
if not user:
|
216 |
+
return jsonify({
|
217 |
+
"success": False,
|
218 |
+
"error": "Không tìm thấy thông tin người dùng"
|
219 |
+
}), 404
|
220 |
+
|
221 |
+
# Tạo cuộc hội thoại mới
|
222 |
+
title = data.get('title', 'Cuộc trò chuyện mới')
|
223 |
+
age_context = data.get('age_context')
|
224 |
+
|
225 |
+
# ✅ SỬA: Sử dụng Conversation.create thay vì khởi tạo trực tiếp
|
226 |
+
conversation_id = Conversation.create(
|
227 |
+
user_id=user_id,
|
228 |
+
title=title,
|
229 |
+
age_context=age_context
|
230 |
+
)
|
231 |
+
|
232 |
+
logger.info(f"✅ Created new conversation {conversation_id} for user {user_id}")
|
233 |
+
|
234 |
+
return jsonify({
|
235 |
+
"success": True,
|
236 |
+
"message": "Đã tạo cuộc hội thoại mới",
|
237 |
+
"conversation_id": str(conversation_id)
|
238 |
+
})
|
239 |
+
|
240 |
+
except Exception as e:
|
241 |
+
logger.error(f"❌ Lỗi khi tạo cuộc hội thoại mới: {str(e)}")
|
242 |
+
return jsonify({
|
243 |
+
"success": False,
|
244 |
+
"error": str(e)
|
245 |
+
}), 500
|
246 |
+
|
247 |
+
@history_routes.route('/conversations/<conversation_id>', methods=['PUT'])
|
248 |
+
@jwt_required()
|
249 |
+
def update_conversation(conversation_id):
|
250 |
+
"""API endpoint để cập nhật thông tin cuộc hội thoại"""
|
251 |
+
try:
|
252 |
+
data = request.json
|
253 |
+
user_id = get_jwt_identity()
|
254 |
+
|
255 |
+
# Lấy thông tin cuộc hội thoại
|
256 |
+
conversation = Conversation.find_by_id(conversation_id)
|
257 |
+
|
258 |
+
if not conversation:
|
259 |
+
return jsonify({
|
260 |
+
"success": False,
|
261 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
262 |
+
}), 404
|
263 |
+
|
264 |
+
# Kiểm tra quyền truy cập
|
265 |
+
if str(conversation.user_id) != user_id:
|
266 |
+
return jsonify({
|
267 |
+
"success": False,
|
268 |
+
"error": "Bạn không có quyền cập nhật cuộc hội thoại này"
|
269 |
+
}), 403
|
270 |
+
|
271 |
+
# Cập nhật thông tin
|
272 |
+
if 'title' in data:
|
273 |
+
conversation.title = data['title']
|
274 |
+
|
275 |
+
if 'age_context' in data:
|
276 |
+
conversation.age_context = data['age_context']
|
277 |
+
|
278 |
+
if 'is_archived' in data:
|
279 |
+
conversation.is_archived = data['is_archived']
|
280 |
+
|
281 |
+
# Lưu thay đổi
|
282 |
+
conversation.save()
|
283 |
+
|
284 |
+
logger.info(f"✅ Updated conversation {conversation_id}")
|
285 |
+
|
286 |
+
return jsonify({
|
287 |
+
"success": True,
|
288 |
+
"message": "Đã cập nhật thông tin cuộc hội thoại"
|
289 |
+
})
|
290 |
+
|
291 |
+
except Exception as e:
|
292 |
+
logger.error(f"Lỗi khi cập nhật thông tin cuộc hội thoại: {str(e)}")
|
293 |
+
return jsonify({
|
294 |
+
"success": False,
|
295 |
+
"error": str(e)
|
296 |
+
}), 500
|
297 |
+
|
298 |
+
@history_routes.route('/conversations/<conversation_id>', methods=['DELETE'])
|
299 |
+
@jwt_required()
|
300 |
+
def delete_conversation(conversation_id):
|
301 |
+
"""API endpoint để xóa cuộc hội thoại"""
|
302 |
+
try:
|
303 |
+
user_id = get_jwt_identity()
|
304 |
+
|
305 |
+
# Lấy thông tin cuộc hội thoại
|
306 |
+
conversation = Conversation.find_by_id(conversation_id)
|
307 |
+
|
308 |
+
if not conversation:
|
309 |
+
return jsonify({
|
310 |
+
"success": False,
|
311 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
312 |
+
}), 404
|
313 |
+
|
314 |
+
# Kiểm tra quyền truy cập
|
315 |
+
if str(conversation.user_id) != user_id:
|
316 |
+
return jsonify({
|
317 |
+
"success": False,
|
318 |
+
"error": "Bạn không có quyền xóa cuộc hội thoại này"
|
319 |
+
}), 403
|
320 |
+
|
321 |
+
# Xóa cuộc hội thoại
|
322 |
+
conversation.delete()
|
323 |
+
|
324 |
+
logger.info(f"✅ Deleted conversation {conversation_id}")
|
325 |
+
|
326 |
+
return jsonify({
|
327 |
+
"success": True,
|
328 |
+
"message": "Đã xóa cuộc hội thoại"
|
329 |
+
})
|
330 |
+
|
331 |
+
except Exception as e:
|
332 |
+
logger.error(f"Lỗi khi xóa cuộc hội thoại: {str(e)}")
|
333 |
+
return jsonify({
|
334 |
+
"success": False,
|
335 |
+
"error": str(e)
|
336 |
+
}), 500
|
337 |
+
|
338 |
+
@history_routes.route('/conversations/<conversation_id>/archive', methods=['POST'])
|
339 |
+
@jwt_required()
|
340 |
+
def archive_conversation(conversation_id):
|
341 |
+
"""API endpoint để lưu trữ cuộc hội thoại"""
|
342 |
+
try:
|
343 |
+
user_id = get_jwt_identity()
|
344 |
+
|
345 |
+
# Lấy thông tin cuộc hội thoại
|
346 |
+
conversation = Conversation.find_by_id(conversation_id)
|
347 |
+
|
348 |
+
if not conversation:
|
349 |
+
return jsonify({
|
350 |
+
"success": False,
|
351 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
352 |
+
}), 404
|
353 |
+
|
354 |
+
# Kiểm tra quyền truy cập
|
355 |
+
if str(conversation.user_id) != user_id:
|
356 |
+
return jsonify({
|
357 |
+
"success": False,
|
358 |
+
"error": "Bạn không có quyền lưu trữ cuộc hội thoại này"
|
359 |
+
}), 403
|
360 |
+
|
361 |
+
# Lưu trữ cuộc hội thoại
|
362 |
+
conversation.is_archived = True
|
363 |
+
conversation.save()
|
364 |
+
|
365 |
+
return jsonify({
|
366 |
+
"success": True,
|
367 |
+
"message": "Đã lưu trữ cuộc hội thoại"
|
368 |
+
})
|
369 |
+
|
370 |
+
except Exception as e:
|
371 |
+
logger.error(f"Lỗi khi lưu trữ cuộc hội thoại: {str(e)}")
|
372 |
+
return jsonify({
|
373 |
+
"success": False,
|
374 |
+
"error": str(e)
|
375 |
+
}), 500
|
376 |
+
|
377 |
+
@history_routes.route('/conversations/<conversation_id>/unarchive', methods=['POST'])
|
378 |
+
@jwt_required()
|
379 |
+
def unarchive_conversation(conversation_id):
|
380 |
+
"""API endpoint để hủy lưu trữ cuộc hội thoại"""
|
381 |
+
try:
|
382 |
+
user_id = get_jwt_identity()
|
383 |
+
|
384 |
+
# Lấy thông tin cuộc hội thoại
|
385 |
+
conversation = Conversation.find_by_id(conversation_id)
|
386 |
+
|
387 |
+
if not conversation:
|
388 |
+
return jsonify({
|
389 |
+
"success": False,
|
390 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
391 |
+
}), 404
|
392 |
+
|
393 |
+
# Kiểm tra quyền truy cập
|
394 |
+
if str(conversation.user_id) != user_id:
|
395 |
+
return jsonify({
|
396 |
+
"success": False,
|
397 |
+
"error": "Bạn không có quyền hủy lưu trữ cuộc hội thoại này"
|
398 |
+
}), 403
|
399 |
+
|
400 |
+
# Hủy lưu trữ cuộc hội thoại
|
401 |
+
conversation.is_archived = False
|
402 |
+
conversation.save()
|
403 |
+
|
404 |
+
return jsonify({
|
405 |
+
"success": True,
|
406 |
+
"message": "Đã hủy lưu trữ cuộc hội thoại"
|
407 |
+
})
|
408 |
+
|
409 |
+
except Exception as e:
|
410 |
+
logger.error(f"Lỗi khi hủy lưu trữ cuộc hội thoại: {str(e)}")
|
411 |
+
return jsonify({
|
412 |
+
"success": False,
|
413 |
+
"error": str(e)
|
414 |
+
}), 500
|
415 |
+
|
416 |
+
@history_routes.route('/conversations/search', methods=['GET'])
|
417 |
+
@jwt_required()
|
418 |
+
def search_conversations():
|
419 |
+
"""API endpoint để tìm kiếm cuộc hội thoại theo nội dung"""
|
420 |
+
try:
|
421 |
+
user_id = get_jwt_identity()
|
422 |
+
|
423 |
+
# Lấy tham số từ query string
|
424 |
+
query = request.args.get('q', '')
|
425 |
+
page = int(request.args.get('page', 1))
|
426 |
+
per_page = int(request.args.get('per_page', 10))
|
427 |
+
|
428 |
+
# Tính toán offset
|
429 |
+
skip = (page - 1) * per_page
|
430 |
+
|
431 |
+
# Kiểm tra từ khóa tìm kiếm
|
432 |
+
if not query:
|
433 |
+
return jsonify({
|
434 |
+
"success": False,
|
435 |
+
"error": "Vui lòng nhập từ khóa tìm kiếm"
|
436 |
+
}), 400
|
437 |
+
|
438 |
+
# Tìm kiếm cuộc hội thoại
|
439 |
+
conversations = Conversation.search_by_content(
|
440 |
+
user_id=user_id,
|
441 |
+
query=query,
|
442 |
+
limit=per_page,
|
443 |
+
skip=skip
|
444 |
+
)
|
445 |
+
|
446 |
+
# Chuẩn bị dữ liệu phản hồi
|
447 |
+
result = []
|
448 |
+
for conversation in conversations:
|
449 |
+
# Tìm tin nhắn chứa từ khóa tìm kiếm
|
450 |
+
matching_messages = [m for m in conversation.messages if query.lower() in m["content"].lower()]
|
451 |
+
|
452 |
+
result.append({
|
453 |
+
"id": str(conversation.conversation_id),
|
454 |
+
"title": conversation.title,
|
455 |
+
"created_at": conversation.created_at.isoformat(),
|
456 |
+
"updated_at": conversation.updated_at.isoformat(),
|
457 |
+
"age_context": conversation.age_context,
|
458 |
+
"is_archived": conversation.is_archived,
|
459 |
+
"message_count": len(conversation.messages),
|
460 |
+
"matching_messages": len(matching_messages),
|
461 |
+
"preview": matching_messages[0]["content"][:100] + "..." if matching_messages else ""
|
462 |
+
})
|
463 |
+
|
464 |
+
return jsonify({
|
465 |
+
"success": True,
|
466 |
+
"conversations": result,
|
467 |
+
"query": query
|
468 |
+
})
|
469 |
+
|
470 |
+
except Exception as e:
|
471 |
+
logger.error(f"Lỗi khi tìm kiếm cuộc hội thoại: {str(e)}")
|
472 |
+
return jsonify({
|
473 |
+
"success": False,
|
474 |
+
"error": str(e)
|
475 |
+
}), 500
|
476 |
+
|
477 |
+
@history_routes.route('/conversations/<conversation_id>/messages', methods=['POST'])
|
478 |
+
@jwt_required()
|
479 |
+
def add_message(conversation_id):
|
480 |
+
"""API endpoint để thêm tin nhắn mới vào cuộc hội thoại"""
|
481 |
+
try:
|
482 |
+
data = request.json
|
483 |
+
user_id = get_jwt_identity()
|
484 |
+
|
485 |
+
# Lấy thông tin cuộc hội thoại
|
486 |
+
conversation = Conversation.find_by_id(conversation_id)
|
487 |
+
|
488 |
+
if not conversation:
|
489 |
+
return jsonify({
|
490 |
+
"success": False,
|
491 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
492 |
+
}), 404
|
493 |
+
|
494 |
+
# Kiểm tra quyền truy cập
|
495 |
+
if str(conversation.user_id) != user_id:
|
496 |
+
return jsonify({
|
497 |
+
"success": False,
|
498 |
+
"error": "Bạn không có quyền thêm tin nhắn vào cuộc hội thoại này"
|
499 |
+
}), 403
|
500 |
+
|
501 |
+
# Lấy thông tin tin nhắn
|
502 |
+
role = data.get('role')
|
503 |
+
content = data.get('content')
|
504 |
+
sources = data.get('sources')
|
505 |
+
metadata = data.get('metadata')
|
506 |
+
|
507 |
+
# Kiểm tra dữ liệu
|
508 |
+
if not role or not content:
|
509 |
+
return jsonify({
|
510 |
+
"success": False,
|
511 |
+
"error": "Vui lòng cung cấp role và content cho tin nhắn"
|
512 |
+
}), 400
|
513 |
+
|
514 |
+
# Kiểm tra role hợp lệ
|
515 |
+
if role not in ["user", "bot"]:
|
516 |
+
return jsonify({
|
517 |
+
"success": False,
|
518 |
+
"error": "Role không hợp lệ, chỉ chấp nhận 'user' hoặc 'bot'"
|
519 |
+
}), 400
|
520 |
+
|
521 |
+
# Thêm tin nhắn mới
|
522 |
+
message_id = conversation.add_message(
|
523 |
+
role=role,
|
524 |
+
content=content,
|
525 |
+
sources=sources,
|
526 |
+
metadata=metadata
|
527 |
+
)
|
528 |
+
|
529 |
+
return jsonify({
|
530 |
+
"success": True,
|
531 |
+
"message": "Đã thêm tin nhắn mới",
|
532 |
+
"message_id": str(message_id)
|
533 |
+
})
|
534 |
+
|
535 |
+
except Exception as e:
|
536 |
+
logger.error(f"Lỗi khi thêm tin nhắn mới: {str(e)}")
|
537 |
+
return jsonify({
|
538 |
+
"success": False,
|
539 |
+
"error": str(e)
|
540 |
+
}), 500
|
541 |
+
|
542 |
+
@history_routes.route('/conversations/stats', methods=['GET'])
|
543 |
+
@jwt_required()
|
544 |
+
def get_user_conversation_stats():
|
545 |
+
"""API endpoint để lấy thống kê cuộc hội thoại của người dùng"""
|
546 |
+
try:
|
547 |
+
user_id = get_jwt_identity()
|
548 |
+
|
549 |
+
# Lấy tổng số cuộc hội thoại
|
550 |
+
total_conversations = Conversation.count_by_user(
|
551 |
+
user_id=user_id,
|
552 |
+
include_archived=True
|
553 |
+
)
|
554 |
+
|
555 |
+
# Lấy số cuộc hội thoại đã lưu trữ
|
556 |
+
archived_conversations = Conversation.count_by_user(
|
557 |
+
user_id=user_id,
|
558 |
+
include_archived=True
|
559 |
+
) - Conversation.count_by_user(
|
560 |
+
user_id=user_id,
|
561 |
+
include_archived=False
|
562 |
+
)
|
563 |
+
|
564 |
+
# Lấy danh sách cuộc hội thoại để tính số tin nhắn
|
565 |
+
all_conversations = Conversation.find_by_user(
|
566 |
+
user_id=user_id,
|
567 |
+
limit=100, # Giới hạn 100 cuộc hội thoại gần nhất để tính thống kê
|
568 |
+
skip=0,
|
569 |
+
include_archived=True
|
570 |
+
)
|
571 |
+
|
572 |
+
# Tính số tin nhắn và số ngày
|
573 |
+
total_messages = 0
|
574 |
+
messages_by_date = {}
|
575 |
+
|
576 |
+
for conversation in all_conversations:
|
577 |
+
total_messages += len(conversation.messages)
|
578 |
+
|
579 |
+
# Đếm số tin nhắn theo ngày
|
580 |
+
for message in conversation.messages:
|
581 |
+
date_str = message["timestamp"].strftime("%Y-%m-%d")
|
582 |
+
if date_str not in messages_by_date:
|
583 |
+
messages_by_date[date_str] = 0
|
584 |
+
messages_by_date[date_str] += 1
|
585 |
+
|
586 |
+
# Sắp xếp ngày và lấy 7 ngày gần nhất
|
587 |
+
sorted_dates = sorted(messages_by_date.keys(), reverse=True)[:7]
|
588 |
+
recent_activity = {date: messages_by_date[date] for date in sorted_dates}
|
589 |
+
|
590 |
+
# Tính trung bình số tin nhắn mỗi cuộc hội thoại
|
591 |
+
avg_messages = total_messages / total_conversations if total_conversations > 0 else 0
|
592 |
+
|
593 |
+
return jsonify({
|
594 |
+
"success": True,
|
595 |
+
"stats": {
|
596 |
+
"total_conversations": total_conversations,
|
597 |
+
"archived_conversations": archived_conversations,
|
598 |
+
"total_messages": total_messages,
|
599 |
+
"avg_messages_per_conversation": round(avg_messages, 1),
|
600 |
+
"recent_activity": recent_activity
|
601 |
+
}
|
602 |
+
})
|
603 |
+
|
604 |
+
except Exception as e:
|
605 |
+
logger.error(f"Lỗi khi lấy thống kê cuộc hội thoại: {str(e)}")
|
606 |
+
return jsonify({
|
607 |
+
"success": False,
|
608 |
+
"error": str(e)
|
609 |
+
}), 500
|
610 |
+
|
611 |
+
@history_routes.route('/conversations/<conversation_id>/export', methods=['GET'])
|
612 |
+
@jwt_required()
|
613 |
+
def export_conversation(conversation_id):
|
614 |
+
"""API endpoint để xuất cuộc hội thoại dưới dạng JSON"""
|
615 |
+
try:
|
616 |
+
user_id = get_jwt_identity()
|
617 |
+
|
618 |
+
# Lấy thông tin cuộc hội thoại
|
619 |
+
conversation = Conversation.find_by_id(conversation_id)
|
620 |
+
|
621 |
+
if not conversation:
|
622 |
+
return jsonify({
|
623 |
+
"success": False,
|
624 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
625 |
+
}), 404
|
626 |
+
|
627 |
+
# Kiểm tra quyền truy cập
|
628 |
+
if str(conversation.user_id) != user_id:
|
629 |
+
return jsonify({
|
630 |
+
"success": False,
|
631 |
+
"error": "Bạn không có quyền xuất cuộc hội thoại này"
|
632 |
+
}), 403
|
633 |
+
|
634 |
+
# Chuẩn bị dữ liệu xuất
|
635 |
+
export_data = {
|
636 |
+
"id": str(conversation.conversation_id),
|
637 |
+
"title": conversation.title,
|
638 |
+
"created_at": conversation.created_at.isoformat(),
|
639 |
+
"updated_at": conversation.updated_at.isoformat(),
|
640 |
+
"age_context": conversation.age_context,
|
641 |
+
"messages": []
|
642 |
+
}
|
643 |
+
|
644 |
+
# Chuẩn bị danh sách tin nhắn
|
645 |
+
for message in conversation.messages:
|
646 |
+
message_data = {
|
647 |
+
"role": message["role"],
|
648 |
+
"content": message["content"],
|
649 |
+
"timestamp": message["timestamp"].isoformat()
|
650 |
+
}
|
651 |
+
|
652 |
+
# Thêm sources nếu có
|
653 |
+
if "sources" in message:
|
654 |
+
message_data["sources"] = message["sources"]
|
655 |
+
|
656 |
+
export_data["messages"].append(message_data)
|
657 |
+
|
658 |
+
return jsonify(export_data)
|
659 |
+
|
660 |
+
except Exception as e:
|
661 |
+
logger.error(f"Lỗi khi xuất cuộc hội thoại: {str(e)}")
|
662 |
+
return jsonify({
|
663 |
+
"success": False,
|
664 |
+
"error": str(e)
|
665 |
+
}), 500
|
666 |
+
|
667 |
+
@history_routes.route('/conversations/bulk-delete', methods=['POST'])
|
668 |
+
@jwt_required()
|
669 |
+
def bulk_delete_conversations():
|
670 |
+
"""API endpoint để xóa nhiều cuộc hội thoại cùng lúc"""
|
671 |
+
try:
|
672 |
+
data = request.json
|
673 |
+
user_id = get_jwt_identity()
|
674 |
+
|
675 |
+
conversation_ids = data.get('conversation_ids', [])
|
676 |
+
|
677 |
+
if not conversation_ids:
|
678 |
+
return jsonify({
|
679 |
+
"success": False,
|
680 |
+
"error": "Vui lòng cung cấp danh sách IDs cuộc hội thoại"
|
681 |
+
}), 400
|
682 |
+
|
683 |
+
# Duyệt qua từng ID và xóa
|
684 |
+
deleted_count = 0
|
685 |
+
failed_ids = []
|
686 |
+
|
687 |
+
for conv_id in conversation_ids:
|
688 |
+
try:
|
689 |
+
# Lấy thông tin cuộc hội thoại
|
690 |
+
conversation = Conversation.find_by_id(conv_id)
|
691 |
+
|
692 |
+
# Kiểm tra quyền truy cập và xóa nếu hợp lệ
|
693 |
+
if conversation and str(conversation.user_id) == user_id:
|
694 |
+
conversation.delete()
|
695 |
+
deleted_count += 1
|
696 |
+
else:
|
697 |
+
failed_ids.append(conv_id)
|
698 |
+
except Exception:
|
699 |
+
failed_ids.append(conv_id)
|
700 |
+
continue
|
701 |
+
|
702 |
+
return jsonify({
|
703 |
+
"success": True,
|
704 |
+
"message": f"Đã xóa {deleted_count}/{len(conversation_ids)} cuộc hội thoại",
|
705 |
+
"deleted_count": deleted_count,
|
706 |
+
"failed_ids": failed_ids
|
707 |
+
})
|
708 |
+
|
709 |
+
except Exception as e:
|
710 |
+
logger.error(f"Lỗi khi xóa nhiều cuộc hội thoại: {str(e)}")
|
711 |
+
return jsonify({
|
712 |
+
"success": False,
|
713 |
+
"error": str(e)
|
714 |
+
}), 500
|
715 |
+
|
716 |
+
@history_routes.route('/conversations/<conversation_id>/generate-title', methods=['POST'])
|
717 |
+
@jwt_required()
|
718 |
+
def generate_title_for_conversation(conversation_id):
|
719 |
+
"""API endpoint để tạo tự động tiêu đề cho cuộc hội thoại dựa trên nội dung"""
|
720 |
+
try:
|
721 |
+
user_id = get_jwt_identity()
|
722 |
+
|
723 |
+
# Lấy thông tin cuộc hội thoại
|
724 |
+
conversation = Conversation.find_by_id(conversation_id)
|
725 |
+
|
726 |
+
if not conversation:
|
727 |
+
return jsonify({
|
728 |
+
"success": False,
|
729 |
+
"error": "Không tìm thấy cuộc hội thoại"
|
730 |
+
}), 404
|
731 |
+
|
732 |
+
# Kiểm tra quyền truy cập
|
733 |
+
if str(conversation.user_id) != user_id:
|
734 |
+
return jsonify({
|
735 |
+
"success": False,
|
736 |
+
"error": "Bạn không có quyền cập nhật cuộc hội thoại này"
|
737 |
+
}), 403
|
738 |
+
|
739 |
+
# Kiểm tra số lượng tin nhắn
|
740 |
+
if len(conversation.messages) < 2:
|
741 |
+
return jsonify({
|
742 |
+
"success": False,
|
743 |
+
"error": "Cuộc hội thoại cần ít nhất 2 tin nhắn để tạo tiêu đề"
|
744 |
+
}), 400
|
745 |
+
|
746 |
+
# Lấy nội dung tin nhắn đầu tiên của người dùng
|
747 |
+
first_user_message = None
|
748 |
+
for message in conversation.messages:
|
749 |
+
if message["role"] == "user":
|
750 |
+
first_user_message = message["content"]
|
751 |
+
break
|
752 |
+
|
753 |
+
if not first_user_message:
|
754 |
+
return jsonify({
|
755 |
+
"success": False,
|
756 |
+
"error": "Không tìm thấy tin nhắn của người dùng"
|
757 |
+
}), 400
|
758 |
+
|
759 |
+
# Tạo tiêu đề từ nội dung
|
760 |
+
title = create_title_from_message(first_user_message)
|
761 |
+
|
762 |
+
# Cập nhật tiêu đề
|
763 |
+
conversation.title = title
|
764 |
+
conversation.save()
|
765 |
+
|
766 |
+
return jsonify({
|
767 |
+
"success": True,
|
768 |
+
"message": "Đã tạo tiêu đề mới",
|
769 |
+
"title": title
|
770 |
+
})
|
771 |
+
|
772 |
+
except Exception as e:
|
773 |
+
logger.error(f"Lỗi khi tạo tiêu đề: {str(e)}")
|
774 |
+
return jsonify({
|
775 |
+
"success": False,
|
776 |
+
"error": str(e)
|
777 |
+
}), 500
|
app.py
ADDED
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Flask, jsonify
|
2 |
+
from flask_cors import CORS
|
3 |
+
import logging
|
4 |
+
import os
|
5 |
+
from dotenv import load_dotenv
|
6 |
+
from flask_jwt_extended import JWTManager
|
7 |
+
import datetime
|
8 |
+
from api.admin import admin_routes
|
9 |
+
|
10 |
+
# Cấu hình logging
|
11 |
+
logging.basicConfig(
|
12 |
+
level=logging.INFO,
|
13 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
14 |
+
)
|
15 |
+
logger = logging.getLogger(__name__)
|
16 |
+
|
17 |
+
# Tải biến môi trường
|
18 |
+
load_dotenv()
|
19 |
+
|
20 |
+
# Khởi tạo Flask app
|
21 |
+
app = Flask(__name__)
|
22 |
+
|
23 |
+
# Cấu hình JWT - sử dụng environment variables
|
24 |
+
app.config['JWT_SECRET_KEY'] = os.getenv("JWT_SECRET_KEY", "default-secret-key-change-in-production")
|
25 |
+
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = datetime.timedelta(hours=24)
|
26 |
+
app.config['JWT_TOKEN_LOCATION'] = ['headers', 'cookies']
|
27 |
+
app.config['JWT_COOKIE_SECURE'] = os.getenv("ENVIRONMENT") == "production"
|
28 |
+
app.config['JWT_COOKIE_CSRF_PROTECT'] = False
|
29 |
+
|
30 |
+
# Cấu hình upload files
|
31 |
+
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # 50MB
|
32 |
+
|
33 |
+
jwt = JWTManager(app)
|
34 |
+
|
35 |
+
# Cấu hình CORS cho production
|
36 |
+
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000").split(",")
|
37 |
+
CORS(app, resources={
|
38 |
+
r"/api/*": {
|
39 |
+
"origins": allowed_origins + ["*"], # Cho phép tất cả origins trong production
|
40 |
+
"supports_credentials": True
|
41 |
+
}
|
42 |
+
})
|
43 |
+
|
44 |
+
# Import và đăng ký blueprints
|
45 |
+
from api.auth import auth_routes
|
46 |
+
from api.chat import chat_routes
|
47 |
+
from api.data import data_routes
|
48 |
+
from api.history import history_routes
|
49 |
+
from api.feedback import feedback_routes
|
50 |
+
|
51 |
+
app.register_blueprint(auth_routes, url_prefix='/api/auth')
|
52 |
+
app.register_blueprint(chat_routes, url_prefix='/api')
|
53 |
+
app.register_blueprint(data_routes, url_prefix='/api')
|
54 |
+
app.register_blueprint(history_routes, url_prefix='/api')
|
55 |
+
app.register_blueprint(feedback_routes, url_prefix='/api')
|
56 |
+
app.register_blueprint(admin_routes, url_prefix='/api/admin')
|
57 |
+
|
58 |
+
@app.route('/', methods=['GET'])
|
59 |
+
def home():
|
60 |
+
"""Root endpoint"""
|
61 |
+
return jsonify({
|
62 |
+
"message": "API đang hoạt động",
|
63 |
+
"status": "healthy",
|
64 |
+
"endpoints": {
|
65 |
+
"health": "/api/health",
|
66 |
+
"admin_init": "/api/admin/init"
|
67 |
+
}
|
68 |
+
})
|
69 |
+
|
70 |
+
@app.route('/api/health', methods=['GET'])
|
71 |
+
def health_check():
|
72 |
+
"""API endpoint để kiểm tra trạng thái của server"""
|
73 |
+
import time
|
74 |
+
try:
|
75 |
+
from core.embedding_model import get_embedding_model
|
76 |
+
embedding_model = get_embedding_model()
|
77 |
+
collection_count = embedding_model.count()
|
78 |
+
except Exception as e:
|
79 |
+
logger.error(f"Lỗi kiểm tra embedding model: {e}")
|
80 |
+
collection_count = 0
|
81 |
+
|
82 |
+
return jsonify({
|
83 |
+
"status": "healthy",
|
84 |
+
"message": "Server đang hoạt động",
|
85 |
+
"time": time.strftime('%Y-%m-%d %H:%M:%S'),
|
86 |
+
"data_items": collection_count
|
87 |
+
})
|
88 |
+
|
89 |
+
@app.route('/api/admin/init', methods=['POST'])
|
90 |
+
def init_admin():
|
91 |
+
"""API endpoint để khởi tạo admin đầu tiên"""
|
92 |
+
try:
|
93 |
+
from models.admin_model import AdminUser
|
94 |
+
|
95 |
+
success, result = AdminUser.create_default_super_admin()
|
96 |
+
|
97 |
+
if success:
|
98 |
+
return jsonify({
|
99 |
+
"success": True,
|
100 |
+
"message": "Khởi tạo admin thành công",
|
101 |
+
"admin_info": result
|
102 |
+
})
|
103 |
+
else:
|
104 |
+
return jsonify({
|
105 |
+
"success": False,
|
106 |
+
"error": result
|
107 |
+
}), 400
|
108 |
+
|
109 |
+
except Exception as e:
|
110 |
+
logger.error(f"Lỗi khởi tạo admin: {str(e)}")
|
111 |
+
return jsonify({
|
112 |
+
"success": False,
|
113 |
+
"error": str(e)
|
114 |
+
}), 500
|
115 |
+
|
116 |
+
def initialize_app():
|
117 |
+
"""Khởi tạo ứng dụng"""
|
118 |
+
# Tạo super admin mặc định nếu chưa có
|
119 |
+
try:
|
120 |
+
from models.admin_model import AdminUser
|
121 |
+
success, result = AdminUser.create_default_super_admin()
|
122 |
+
if success and "email" in result:
|
123 |
+
logger.info("=== THÔNG TIN ADMIN ===")
|
124 |
+
logger.info(f"Email: {result['email']}")
|
125 |
+
logger.info(f"Password: {result['password']}")
|
126 |
+
logger.info("======================")
|
127 |
+
except Exception as e:
|
128 |
+
logger.error(f"Lỗi tạo super admin: {e}")
|
129 |
+
|
130 |
+
# Tạo indexes cho feedback
|
131 |
+
try:
|
132 |
+
from models.feedback_model import ensure_indexes
|
133 |
+
ensure_indexes()
|
134 |
+
except Exception as e:
|
135 |
+
logger.error(f"Lỗi tạo feedback indexes: {e}")
|
136 |
+
|
137 |
+
if __name__ == '__main__':
|
138 |
+
initialize_app()
|
139 |
+
# Chạy Flask app
|
140 |
+
port = int(os.getenv("PORT", 7860)) # Hugging Face Spaces sử dụng port 7860
|
141 |
+
app.run(host='0.0.0.0', port=port, debug=False)
|
142 |
+
else:
|
143 |
+
# Khi chạy trong production (như Gunicorn)
|
144 |
+
initialize_app()
|
config.py
ADDED
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from dotenv import load_dotenv
|
3 |
+
|
4 |
+
load_dotenv() # Tải biến môi trường từ file .env
|
5 |
+
|
6 |
+
# API Keys
|
7 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "YOUR_API_KEY_HERE")
|
8 |
+
|
9 |
+
# Embedding model
|
10 |
+
EMBEDDING_MODEL = "intfloat/multilingual-e5-base"
|
11 |
+
|
12 |
+
# ChromaDB settings
|
13 |
+
CHROMA_PERSIST_DIRECTORY = "chroma_db"
|
14 |
+
COLLECTION_NAME = "nutrition_data"
|
15 |
+
|
16 |
+
# RAG settings
|
17 |
+
TOP_K_RESULTS = 5
|
18 |
+
TEMPERATURE = 0.2
|
19 |
+
MAX_OUTPUT_TOKENS = 4096
|
20 |
+
|
21 |
+
# Prompt template
|
22 |
+
SYSTEM_PROMPT = """Bạn là một trợ lý AI chuyên về dinh dưỡng và an toàn thực phẩm dành cho học sinh Việt Nam.
|
23 |
+
Tên của bạn là Nutribot.
|
24 |
+
|
25 |
+
Bạn có nhiệm vụ:
|
26 |
+
1. Cung cấp thông tin chính xác, đầy đủ và dễ hiểu về dinh dưỡng, an toàn thực phẩm dựa trên tài liệu của Bộ Giáo dục và Đào tạo
|
27 |
+
2. Điều chỉnh cách trả lời phù hợp với độ tuổi của người dùng
|
28 |
+
3. Trả lời ngắn gọn, súc tích nhưng vẫn đảm bảo đầy đủ thông tin
|
29 |
+
4. Sử dụng ngôn ngữ thân thiện, dễ hiểu với học sinh
|
30 |
+
|
31 |
+
Khi trả lời:
|
32 |
+
- Hãy sử dụng thông tin từ các tài liệu tham khảo được cung cấp
|
33 |
+
- Nếu có bảng biểu trong tài liệu tham khảo, hãy đưa ra nội dung đầy đủ của bảng đó như thông tin bạn nhận được và giữ nguyên định dạng bảng đó khi trả lời
|
34 |
+
- Nếu có hình ảnh trong tài liệu tham khảo, hãy giữ nguyên đường dẫn hình ảnh khi trả lời
|
35 |
+
- Nếu câu hỏi không liên quan đến dinh dưỡng hoặc không có trong tài liệu, hãy lịch sự giải thích rằng bạn chỉ có thể tư vấn về các vấn đề dinh dưỡng và an toàn thực phẩm
|
36 |
+
- Luôn trích dẫn nguồn của thông tin khi trả lời
|
37 |
+
|
38 |
+
Đối với các câu hỏi không liên quan hoặc nhạy cảm:
|
39 |
+
- Bạn sẽ không đưa ra lời khuyên y tế cụ thể cho các bệnh lý nặng
|
40 |
+
- Bạn sẽ không đưa ra thông tin về các chế độ ăn kiêng khắc nghiệt hoặc nguy hiểm
|
41 |
+
- Bạn sẽ không nhận và xử lý thông tin cá nhân nhạy cảm của người dùng ngoài tuổi tác
|
42 |
+
"""
|
43 |
+
|
44 |
+
HUMAN_PROMPT_TEMPLATE = """
|
45 |
+
Câu hỏi: {query}
|
46 |
+
|
47 |
+
Độ tuổi người dùng: {age} tuổi
|
48 |
+
|
49 |
+
Tài liệu tham khảo:
|
50 |
+
{contexts}
|
51 |
+
|
52 |
+
Dựa vào thông tin trong tài liệu tham khảo và ngữ cảnh cuộc trò chuyện (nếu có), hãy trả lời câu hỏi một cách chi tiết, dễ hiểu và phù hợp với độ tuổi của người dùng. Nếu trong tài liệu có bảng biểu hoặc hình ảnh, hãy giữ nguyên và đưa vào câu trả lời. Nhớ trích dẫn nguồn thông tin.
|
53 |
+
|
54 |
+
Nếu câu hỏi hiện tại có liên quan đến các cuộc trò chuyện trước đó, hãy cố gắng duy trì sự nhất quán trong câu trả lời.
|
55 |
+
"""
|
core/__init__.py
ADDED
File without changes
|
core/data_processor.py
ADDED
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import json
|
3 |
+
import re
|
4 |
+
import logging
|
5 |
+
import datetime
|
6 |
+
from typing import Dict, List, Any, Union, Tuple
|
7 |
+
|
8 |
+
# Cấu hình logging
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
class DataProcessor:
|
12 |
+
def __init__(self, data_dir: str = "data"):
|
13 |
+
self.data_dir = data_dir
|
14 |
+
self.metadata = {}
|
15 |
+
self.chunks = []
|
16 |
+
self.tables = []
|
17 |
+
self.figures = []
|
18 |
+
|
19 |
+
logger.info(f"Khởi tạo DataProcessor với data_dir: {data_dir}")
|
20 |
+
if not os.path.exists(self.data_dir):
|
21 |
+
logger.error(f"Thư mục data không tồn tại: {self.data_dir}")
|
22 |
+
else:
|
23 |
+
self._load_all_data()
|
24 |
+
|
25 |
+
def _load_all_data(self):
|
26 |
+
"""Tải tất cả dữ liệu từ các thư mục con trong data"""
|
27 |
+
logger.info(f"Đang tải dữ liệu từ thư mục: {self.data_dir}")
|
28 |
+
|
29 |
+
# Quét qua tất cả thư mục trong data
|
30 |
+
for item in os.listdir(self.data_dir):
|
31 |
+
folder_path = os.path.join(self.data_dir, item)
|
32 |
+
|
33 |
+
# Kiểm tra xem đây có phải là thư mục không
|
34 |
+
if os.path.isdir(folder_path):
|
35 |
+
metadata_file = os.path.join(folder_path, "metadata.json")
|
36 |
+
|
37 |
+
# Nếu có file metadata.json
|
38 |
+
if os.path.exists(metadata_file):
|
39 |
+
try:
|
40 |
+
# Tải metadata
|
41 |
+
with open(metadata_file, 'r', encoding='utf-8') as f:
|
42 |
+
content = f.read()
|
43 |
+
if not content.strip():
|
44 |
+
logger.warning(f"File metadata trống: {metadata_file}")
|
45 |
+
continue
|
46 |
+
folder_metadata = json.loads(content)
|
47 |
+
|
48 |
+
# Xác định ID của thư mục
|
49 |
+
folder_id = None
|
50 |
+
if "bai_info" in folder_metadata:
|
51 |
+
folder_id = folder_metadata["bai_info"].get("id", item)
|
52 |
+
elif "phuluc_info" in folder_metadata:
|
53 |
+
folder_id = folder_metadata["phuluc_info"].get("id", item)
|
54 |
+
else:
|
55 |
+
folder_id = item
|
56 |
+
|
57 |
+
# Lưu metadata vào từ điển
|
58 |
+
self.metadata[folder_id] = folder_metadata
|
59 |
+
|
60 |
+
# Tải tất cả chunks, tables và figures
|
61 |
+
self._load_content_from_metadata(folder_path, folder_metadata)
|
62 |
+
|
63 |
+
logger.info(f"Đã tải xong thư mục: {item}")
|
64 |
+
except json.JSONDecodeError as e:
|
65 |
+
logger.error(f"Lỗi đọc file JSON {metadata_file}: {e}")
|
66 |
+
except Exception as e:
|
67 |
+
logger.error(f"Lỗi khi tải metadata từ {metadata_file}: {e}")
|
68 |
+
|
69 |
+
def _load_content_from_metadata(self, folder_path: str, folder_metadata: Dict[str, Any]):
|
70 |
+
"""Tải nội dung chunks, tables và figures từ metadata"""
|
71 |
+
# Tải chunks
|
72 |
+
for chunk_meta in folder_metadata.get("chunks", []):
|
73 |
+
chunk_id = chunk_meta.get("id")
|
74 |
+
chunk_path = os.path.join(folder_path, "chunks", f"{chunk_id}.md")
|
75 |
+
|
76 |
+
chunk_data = chunk_meta.copy() # Sao chép metadata của chunk
|
77 |
+
|
78 |
+
# Thêm nội dung từ file markdown nếu tồn tại
|
79 |
+
if os.path.exists(chunk_path):
|
80 |
+
with open(chunk_path, 'r', encoding='utf-8') as f:
|
81 |
+
content = f.read()
|
82 |
+
chunk_data["content"] = self._extract_content_from_markdown(content)
|
83 |
+
else:
|
84 |
+
# Nếu không tìm thấy file, tạo nội dung mẫu và ghi log ở debug level
|
85 |
+
chunk_data["content"] = f"Nội dung cho {chunk_id} không tìm thấy."
|
86 |
+
logger.debug(f"Không tìm thấy file chunk: {chunk_path}")
|
87 |
+
|
88 |
+
self.chunks.append(chunk_data)
|
89 |
+
|
90 |
+
# Tải tables
|
91 |
+
for table_meta in folder_metadata.get("tables", []):
|
92 |
+
table_id = table_meta.get("id")
|
93 |
+
table_path = os.path.join(folder_path, "tables", f"{table_id}.md")
|
94 |
+
|
95 |
+
table_data = table_meta.copy()
|
96 |
+
|
97 |
+
# Thêm nội dung từ file markdown nếu tồn tại
|
98 |
+
if os.path.exists(table_path):
|
99 |
+
with open(table_path, 'r', encoding='utf-8') as f:
|
100 |
+
content = f.read()
|
101 |
+
table_data["content"] = self._extract_content_from_markdown(content)
|
102 |
+
else:
|
103 |
+
table_data["content"] = f"Bảng {table_id} không tìm thấy."
|
104 |
+
logger.debug(f"Không tìm thấy file bảng: {table_path}")
|
105 |
+
|
106 |
+
self.tables.append(table_data)
|
107 |
+
|
108 |
+
# Tải figures
|
109 |
+
for figure_meta in folder_metadata.get("figures", []):
|
110 |
+
figure_id = figure_meta.get("id")
|
111 |
+
figure_path = os.path.join(folder_path, "figures", f"{figure_id}.md")
|
112 |
+
figure_data = figure_meta.copy()
|
113 |
+
|
114 |
+
# Thêm nội dung từ file markdown nếu tồn tại
|
115 |
+
content_loaded = False
|
116 |
+
if os.path.exists(figure_path):
|
117 |
+
with open(figure_path, 'r', encoding='utf-8') as f:
|
118 |
+
content = f.read()
|
119 |
+
figure_data["content"] = self._extract_content_from_markdown(content)
|
120 |
+
content_loaded = True
|
121 |
+
|
122 |
+
# Thêm đường dẫn đến file hình ảnh nếu có
|
123 |
+
image_path = None
|
124 |
+
image_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg']
|
125 |
+
for ext in image_extensions:
|
126 |
+
img_path = os.path.join(folder_path, "figures", f"{figure_id}{ext}")
|
127 |
+
if os.path.exists(img_path):
|
128 |
+
image_path = img_path
|
129 |
+
break
|
130 |
+
|
131 |
+
if image_path:
|
132 |
+
figure_data["image_path"] = image_path
|
133 |
+
# Tạo nội dung mặc định nếu không có file markdown
|
134 |
+
if not content_loaded:
|
135 |
+
figure_caption = figure_meta.get("title", f"Hình {figure_id}")
|
136 |
+
figure_data["content"] = f""
|
137 |
+
elif not content_loaded:
|
138 |
+
# Nếu không có cả file markdown và file hình
|
139 |
+
figure_data["content"] = f"Hình {figure_id} không tìm thấy."
|
140 |
+
logger.debug(f"Không tìm thấy file hình cho {figure_id}")
|
141 |
+
|
142 |
+
self.figures.append(figure_data)
|
143 |
+
|
144 |
+
# Tải data_files (trường hợp phụ lục)
|
145 |
+
if "data_files" in folder_metadata:
|
146 |
+
for data_file_meta in folder_metadata.get("data_files", []):
|
147 |
+
data_id = data_file_meta.get("id")
|
148 |
+
data_path = os.path.join(folder_path, "data", f"{data_id}.md")
|
149 |
+
|
150 |
+
data_file = data_file_meta.copy()
|
151 |
+
|
152 |
+
# Thêm nội dung từ file markdown nếu tồn tại
|
153 |
+
if os.path.exists(data_path):
|
154 |
+
with open(data_path, 'r', encoding='utf-8') as f:
|
155 |
+
content = f.read()
|
156 |
+
data_file["content"] = self._extract_content_from_markdown(content)
|
157 |
+
|
158 |
+
# Xác định loại nội dung
|
159 |
+
content_type = data_file.get("content_type", "table")
|
160 |
+
|
161 |
+
# Thêm vào danh sách phù hợp dựa trên loại nội dung
|
162 |
+
if content_type == "table":
|
163 |
+
self.tables.append(data_file)
|
164 |
+
elif content_type == "text":
|
165 |
+
self.chunks.append(data_file)
|
166 |
+
elif content_type == "figure":
|
167 |
+
self.figures.append(data_file)
|
168 |
+
|
169 |
+
logger.debug(f"Đã tải dữ liệu: {data_id}, loại: {content_type}")
|
170 |
+
else:
|
171 |
+
logger.debug(f"Không tìm thấy file dữ liệu: {data_path}")
|
172 |
+
data_file["content"] = f"Dữ liệu {data_id} không tìm thấy."
|
173 |
+
|
174 |
+
def _extract_content_from_markdown(self, md_content: str) -> str:
|
175 |
+
"""Trích xuất nội dung từ markdown, bỏ qua phần frontmatter"""
|
176 |
+
# Tách frontmatter (nằm giữa "---")
|
177 |
+
if md_content.startswith("---"):
|
178 |
+
parts = md_content.split("---", 2)
|
179 |
+
if len(parts) >= 3:
|
180 |
+
return parts[2].strip()
|
181 |
+
return md_content
|
182 |
+
|
183 |
+
def get_all_items(self) -> Dict[str, List[Dict[str, Any]]]:
|
184 |
+
"""Trả về tất cả các items đã tải"""
|
185 |
+
return {
|
186 |
+
"chunks": self.chunks,
|
187 |
+
"tables": self.tables,
|
188 |
+
"figures": self.figures
|
189 |
+
}
|
190 |
+
|
191 |
+
def get_all_metadata(self) -> Dict[str, Any]:
|
192 |
+
"""Trả về tất cả metadata của các bài học và phụ lục"""
|
193 |
+
return self.metadata
|
194 |
+
|
195 |
+
def get_chunk_by_id(self, chunk_id: str) -> Union[Dict[str, Any], None]:
|
196 |
+
"""Tìm và trả về chunk theo ID"""
|
197 |
+
for chunk in self.chunks:
|
198 |
+
if chunk.get("id") == chunk_id:
|
199 |
+
return chunk
|
200 |
+
return None
|
201 |
+
|
202 |
+
def get_table_by_id(self, table_id: str) -> Union[Dict[str, Any], None]:
|
203 |
+
"""Tìm và trả về bảng theo ID"""
|
204 |
+
for table in self.tables:
|
205 |
+
if table.get("id") == table_id:
|
206 |
+
return table
|
207 |
+
return None
|
208 |
+
|
209 |
+
def get_figure_by_id(self, figure_id: str) -> Union[Dict[str, Any], None]:
|
210 |
+
"""Tìm và trả về hình theo ID"""
|
211 |
+
for figure in self.figures:
|
212 |
+
if figure.get("id") == figure_id:
|
213 |
+
return figure
|
214 |
+
return None
|
215 |
+
|
216 |
+
def find_items_by_age(self, age: int) -> Dict[str, List[Dict[str, Any]]]:
|
217 |
+
"""Tìm các items (chunks, tables, figures) liên quan đến độ tuổi của người dùng"""
|
218 |
+
relevant_chunks = []
|
219 |
+
relevant_tables = []
|
220 |
+
relevant_figures = []
|
221 |
+
|
222 |
+
# Lọc chunks
|
223 |
+
for chunk in self.chunks:
|
224 |
+
age_range = chunk.get("age_range", [0, 100])
|
225 |
+
if len(age_range) == 2 and age_range[0] <= age <= age_range[1]:
|
226 |
+
relevant_chunks.append(chunk)
|
227 |
+
|
228 |
+
# Lọc tables
|
229 |
+
for table in self.tables:
|
230 |
+
age_range = table.get("age_range", [0, 100])
|
231 |
+
if len(age_range) == 2 and age_range[0] <= age <= age_range[1]:
|
232 |
+
relevant_tables.append(table)
|
233 |
+
|
234 |
+
# Lọc figures
|
235 |
+
for figure in self.figures:
|
236 |
+
age_range = figure.get("age_range", [0, 100])
|
237 |
+
if len(age_range) == 2 and age_range[0] <= age <= age_range[1]:
|
238 |
+
relevant_figures.append(figure)
|
239 |
+
|
240 |
+
return {
|
241 |
+
"chunks": relevant_chunks,
|
242 |
+
"tables": relevant_tables,
|
243 |
+
"figures": relevant_figures
|
244 |
+
}
|
245 |
+
|
246 |
+
def get_related_items(self, item_id: str) -> Dict[str, List[Dict[str, Any]]]:
|
247 |
+
"""Tìm các items liên quan đến một item cụ thể dựa vào related_chunks"""
|
248 |
+
related_chunks = []
|
249 |
+
related_tables = []
|
250 |
+
related_figures = []
|
251 |
+
|
252 |
+
# Tìm item gốc
|
253 |
+
source_item = None
|
254 |
+
for item in self.chunks + self.tables + self.figures:
|
255 |
+
if item.get("id") == item_id:
|
256 |
+
source_item = item
|
257 |
+
break
|
258 |
+
|
259 |
+
if not source_item:
|
260 |
+
return {
|
261 |
+
"chunks": [],
|
262 |
+
"tables": [],
|
263 |
+
"figures": []
|
264 |
+
}
|
265 |
+
|
266 |
+
# Lấy danh sách IDs của các items liên quan
|
267 |
+
related_ids = source_item.get("related_chunks", [])
|
268 |
+
|
269 |
+
# Tìm các items liên quan
|
270 |
+
for related_id in related_ids:
|
271 |
+
# Tìm trong chunks
|
272 |
+
for chunk in self.chunks:
|
273 |
+
if chunk.get("id") == related_id:
|
274 |
+
related_chunks.append(chunk)
|
275 |
+
break
|
276 |
+
|
277 |
+
# Tìm trong tables
|
278 |
+
for table in self.tables:
|
279 |
+
if table.get("id") == related_id:
|
280 |
+
related_tables.append(table)
|
281 |
+
break
|
282 |
+
|
283 |
+
# Tìm trong figures
|
284 |
+
for figure in self.figures:
|
285 |
+
if figure.get("id") == related_id:
|
286 |
+
related_figures.append(figure)
|
287 |
+
break
|
288 |
+
|
289 |
+
return {
|
290 |
+
"chunks": related_chunks,
|
291 |
+
"tables": related_tables,
|
292 |
+
"figures": related_figures
|
293 |
+
}
|
294 |
+
|
295 |
+
def preprocess_query(self, query: str) -> str:
|
296 |
+
"""Tiền xử lý câu truy vấn"""
|
297 |
+
# Loại bỏ ký tự đặc biệt
|
298 |
+
query = re.sub(r'[^\w\s\d]', ' ', query)
|
299 |
+
# Loại bỏ khoảng trắng thừa
|
300 |
+
query = re.sub(r'\s+', ' ', query).strip()
|
301 |
+
return query
|
302 |
+
|
303 |
+
def format_context_for_rag(self, items: List[Dict[str, Any]]) -> str:
|
304 |
+
"""Định dạng các items để đưa vào ngữ cảnh cho mô hình RAG"""
|
305 |
+
formatted_contexts = []
|
306 |
+
|
307 |
+
for i, item in enumerate(items, 1):
|
308 |
+
item_id = item.get("id", "")
|
309 |
+
title = item.get("title", "")
|
310 |
+
content = item.get("content", "")
|
311 |
+
content_type = item.get("content_type", "text")
|
312 |
+
|
313 |
+
# Nếu là bảng, thêm tiêu đề "Bảng:"
|
314 |
+
if content_type == "table":
|
315 |
+
title = f"Bảng: {title}"
|
316 |
+
# Nếu là hình, thêm tiêu đề "Hình:"
|
317 |
+
elif content_type == "figure":
|
318 |
+
title = f"Hình: {title}"
|
319 |
+
|
320 |
+
formatted_context = f"[{i}] {title}\n\n{content}\n\n"
|
321 |
+
formatted_contexts.append(formatted_context)
|
322 |
+
|
323 |
+
return "\n".join(formatted_contexts)
|
324 |
+
|
325 |
+
def prepare_for_embedding(self) -> List[Dict[str, Any]]:
|
326 |
+
"""Chuẩn bị dữ liệu cho việc nhúng (embedding)"""
|
327 |
+
all_items = []
|
328 |
+
|
329 |
+
# Thêm chunks
|
330 |
+
for chunk in self.chunks:
|
331 |
+
# Tìm chapter từ chunk ID
|
332 |
+
chunk_id = chunk.get("id", "")
|
333 |
+
chapter = "unknown"
|
334 |
+
if chunk_id.startswith("bai1_"):
|
335 |
+
chapter = "bai1"
|
336 |
+
elif chunk_id.startswith("bai2_"):
|
337 |
+
chapter = "bai2"
|
338 |
+
elif chunk_id.startswith("bai3_"):
|
339 |
+
chapter = "bai3"
|
340 |
+
elif chunk_id.startswith("bai4_"):
|
341 |
+
chapter = "bai4"
|
342 |
+
elif "phuluc" in chunk_id.lower():
|
343 |
+
chapter = "phuluc"
|
344 |
+
|
345 |
+
content = chunk.get("content", "")
|
346 |
+
if chunk.get("title"):
|
347 |
+
content = f"Tiêu đề: {chunk.get('title')}\n\nNội dung: {content}"
|
348 |
+
|
349 |
+
# Xử lý age_range - convert list thành string và tách thành min/max
|
350 |
+
age_range = chunk.get("age_range", [0, 100])
|
351 |
+
age_min = age_range[0] if len(age_range) > 0 else 0
|
352 |
+
age_max = age_range[1] if len(age_range) > 1 else 100
|
353 |
+
age_range_str = f"{age_min}-{age_max}"
|
354 |
+
|
355 |
+
# Xử lý related_chunks - convert list thành string
|
356 |
+
related_chunks = chunk.get("related_chunks", [])
|
357 |
+
related_chunks_str = ",".join(related_chunks) if related_chunks else ""
|
358 |
+
|
359 |
+
embedding_item = {
|
360 |
+
"content": content,
|
361 |
+
"metadata": {
|
362 |
+
"chunk_id": chunk_id,
|
363 |
+
"chapter": chapter,
|
364 |
+
"title": chunk.get("title", ""),
|
365 |
+
"content_type": chunk.get("content_type", "text"),
|
366 |
+
"age_range": age_range_str,
|
367 |
+
"age_min": age_min,
|
368 |
+
"age_max": age_max,
|
369 |
+
"summary": chunk.get("summary", ""),
|
370 |
+
"pages": chunk.get("pages", ""),
|
371 |
+
"related_chunks": related_chunks_str,
|
372 |
+
"word_count": chunk.get("word_count", 0),
|
373 |
+
"token_count": chunk.get("token_count", 0),
|
374 |
+
"contains_table": chunk.get("contains_table", False),
|
375 |
+
"contains_figure": chunk.get("contains_figure", False),
|
376 |
+
"created_at": datetime.datetime.now().isoformat()
|
377 |
+
},
|
378 |
+
"id": chunk_id
|
379 |
+
}
|
380 |
+
all_items.append(embedding_item)
|
381 |
+
|
382 |
+
# Thêm tables
|
383 |
+
for table in self.tables:
|
384 |
+
# Tìm chapter từ table ID
|
385 |
+
table_id = table.get("id", "")
|
386 |
+
chapter = "unknown"
|
387 |
+
if table_id.startswith("bai1_"):
|
388 |
+
chapter = "bai1"
|
389 |
+
elif table_id.startswith("bai2_"):
|
390 |
+
chapter = "bai2"
|
391 |
+
elif table_id.startswith("bai3_"):
|
392 |
+
chapter = "bai3"
|
393 |
+
elif table_id.startswith("bai4_"):
|
394 |
+
chapter = "bai4"
|
395 |
+
elif "phuluc" in table_id.lower():
|
396 |
+
chapter = "phuluc"
|
397 |
+
|
398 |
+
content = table.get("content", "")
|
399 |
+
if table.get("title"):
|
400 |
+
content = f"Bảng: {table.get('title')}\n\nNội dung: {content}"
|
401 |
+
|
402 |
+
# Xử lý age_range
|
403 |
+
age_range = table.get("age_range", [0, 100])
|
404 |
+
age_min = age_range[0] if len(age_range) > 0 else 0
|
405 |
+
age_max = age_range[1] if len(age_range) > 1 else 100
|
406 |
+
age_range_str = f"{age_min}-{age_max}"
|
407 |
+
|
408 |
+
# Xử lý related_chunks và table_columns
|
409 |
+
related_chunks = table.get("related_chunks", [])
|
410 |
+
related_chunks_str = ",".join(related_chunks) if related_chunks else ""
|
411 |
+
table_columns = table.get("table_columns", [])
|
412 |
+
table_columns_str = ",".join(table_columns) if table_columns else ""
|
413 |
+
|
414 |
+
embedding_item = {
|
415 |
+
"content": content,
|
416 |
+
"metadata": {
|
417 |
+
"chunk_id": table_id,
|
418 |
+
"chapter": chapter,
|
419 |
+
"title": table.get("title", ""),
|
420 |
+
"content_type": "table",
|
421 |
+
"age_range": age_range_str,
|
422 |
+
"age_min": age_min,
|
423 |
+
"age_max": age_max,
|
424 |
+
"summary": table.get("summary", ""),
|
425 |
+
"pages": table.get("pages", ""),
|
426 |
+
"related_chunks": related_chunks_str,
|
427 |
+
"table_columns": table_columns_str,
|
428 |
+
"word_count": table.get("word_count", 0),
|
429 |
+
"token_count": table.get("token_count", 0),
|
430 |
+
"created_at": datetime.datetime.now().isoformat()
|
431 |
+
},
|
432 |
+
"id": table_id
|
433 |
+
}
|
434 |
+
all_items.append(embedding_item)
|
435 |
+
|
436 |
+
# Thêm figures
|
437 |
+
for figure in self.figures:
|
438 |
+
# Tìm chapter từ figure ID
|
439 |
+
figure_id = figure.get("id", "")
|
440 |
+
chapter = "unknown"
|
441 |
+
if figure_id.startswith("bai1_"):
|
442 |
+
chapter = "bai1"
|
443 |
+
elif figure_id.startswith("bai2_"):
|
444 |
+
chapter = "bai2"
|
445 |
+
elif figure_id.startswith("bai3_"):
|
446 |
+
chapter = "bai3"
|
447 |
+
elif figure_id.startswith("bai4_"):
|
448 |
+
chapter = "bai4"
|
449 |
+
elif "phuluc" in figure_id.lower():
|
450 |
+
chapter = "phuluc"
|
451 |
+
|
452 |
+
content = figure.get("content", "")
|
453 |
+
if figure.get("title"):
|
454 |
+
content = f"Hình: {figure.get('title')}\n\nMô tả: {content}"
|
455 |
+
|
456 |
+
# Xử lý age_range
|
457 |
+
age_range = figure.get("age_range", [0, 100])
|
458 |
+
age_min = age_range[0] if len(age_range) > 0 else 0
|
459 |
+
age_max = age_range[1] if len(age_range) > 1 else 100
|
460 |
+
age_range_str = f"{age_min}-{age_max}"
|
461 |
+
|
462 |
+
# Xử lý related_chunks
|
463 |
+
related_chunks = figure.get("related_chunks", [])
|
464 |
+
related_chunks_str = ",".join(related_chunks) if related_chunks else ""
|
465 |
+
|
466 |
+
embedding_item = {
|
467 |
+
"content": content,
|
468 |
+
"metadata": {
|
469 |
+
"chunk_id": figure_id,
|
470 |
+
"chapter": chapter,
|
471 |
+
"title": figure.get("title", ""),
|
472 |
+
"content_type": "figure",
|
473 |
+
"age_range": age_range_str,
|
474 |
+
"age_min": age_min,
|
475 |
+
"age_max": age_max,
|
476 |
+
"summary": figure.get("summary", ""),
|
477 |
+
"pages": figure.get("pages", ""),
|
478 |
+
"related_chunks": related_chunks_str,
|
479 |
+
"image_path": figure.get("image_path", ""),
|
480 |
+
"created_at": datetime.datetime.now().isoformat()
|
481 |
+
},
|
482 |
+
"id": figure_id
|
483 |
+
}
|
484 |
+
all_items.append(embedding_item)
|
485 |
+
|
486 |
+
return all_items
|
487 |
+
|
488 |
+
def count_items_by_prefix(self, prefix: str) -> Dict[str, int]:
|
489 |
+
"""Đếm số lượng items theo tiền tố ID"""
|
490 |
+
chunks_count = sum(1 for chunk in self.chunks if chunk.get("id", "").startswith(prefix))
|
491 |
+
tables_count = sum(1 for table in self.tables if table.get("id", "").startswith(prefix))
|
492 |
+
figures_count = sum(1 for figure in self.figures if figure.get("id", "").startswith(prefix))
|
493 |
+
|
494 |
+
return {
|
495 |
+
"chunks": chunks_count,
|
496 |
+
"tables": tables_count,
|
497 |
+
"figures": figures_count,
|
498 |
+
"total": chunks_count + tables_count + figures_count
|
499 |
+
}
|
500 |
+
|
501 |
+
def get_stats(self) -> Dict[str, Any]:
|
502 |
+
"""Lấy thống kê về dữ liệu đã tải"""
|
503 |
+
stats = {
|
504 |
+
"total_chunks": len(self.chunks),
|
505 |
+
"total_tables": len(self.tables),
|
506 |
+
"total_figures": len(self.figures),
|
507 |
+
"total_items": len(self.chunks) + len(self.tables) + len(self.figures),
|
508 |
+
"by_lesson": {},
|
509 |
+
"by_age": {}
|
510 |
+
}
|
511 |
+
|
512 |
+
# Thống kê theo bài
|
513 |
+
for item in os.listdir(self.data_dir):
|
514 |
+
if os.path.isdir(os.path.join(self.data_dir, item)):
|
515 |
+
item_stats = self.count_items_by_prefix(f"{item}_")
|
516 |
+
stats["by_lesson"][item] = item_stats
|
517 |
+
|
518 |
+
# Thống kê theo độ tuổi
|
519 |
+
age_ranges = {}
|
520 |
+
for chunk in self.chunks + self.tables + self.figures:
|
521 |
+
age_range = chunk.get("age_range", [0, 100])
|
522 |
+
if len(age_range) == 2:
|
523 |
+
range_key = f"{age_range[0]}-{age_range[1]}"
|
524 |
+
if range_key not in age_ranges:
|
525 |
+
age_ranges[range_key] = 0
|
526 |
+
age_ranges[range_key] += 1
|
527 |
+
|
528 |
+
stats["by_age"] = age_ranges
|
529 |
+
|
530 |
+
return stats
|
core/embedding_model.py
ADDED
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from sentence_transformers import SentenceTransformer
|
3 |
+
import chromadb
|
4 |
+
from chromadb.config import Settings
|
5 |
+
import uuid
|
6 |
+
from config import EMBEDDING_MODEL, CHROMA_PERSIST_DIRECTORY, COLLECTION_NAME
|
7 |
+
|
8 |
+
# Cấu hình logging
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
# Global instance để implement singleton pattern
|
12 |
+
_embedding_model_instance = None
|
13 |
+
|
14 |
+
def get_embedding_model():
|
15 |
+
"""
|
16 |
+
Singleton pattern để đảm bảo chỉ có một instance của EmbeddingModel
|
17 |
+
"""
|
18 |
+
global _embedding_model_instance
|
19 |
+
if _embedding_model_instance is None:
|
20 |
+
logger.info("Khởi tạo EmbeddingModel instance lần đầu")
|
21 |
+
_embedding_model_instance = EmbeddingModel()
|
22 |
+
else:
|
23 |
+
logger.debug("Sử dụng EmbeddingModel instance đã có")
|
24 |
+
return _embedding_model_instance
|
25 |
+
|
26 |
+
class EmbeddingModel:
|
27 |
+
def __init__(self):
|
28 |
+
"""Khởi tạo embedding model và ChromaDB client"""
|
29 |
+
logger.info(f"Đang khởi tạo embedding model: {EMBEDDING_MODEL}")
|
30 |
+
|
31 |
+
# Khởi tạo sentence transformer
|
32 |
+
self.model = SentenceTransformer(EMBEDDING_MODEL)
|
33 |
+
logger.info("Đã tải sentence transformer model")
|
34 |
+
|
35 |
+
# Khởi tạo ChromaDB client với persistent storage
|
36 |
+
self.chroma_client = chromadb.PersistentClient(
|
37 |
+
path=CHROMA_PERSIST_DIRECTORY,
|
38 |
+
settings=Settings(
|
39 |
+
anonymized_telemetry=False,
|
40 |
+
allow_reset=True
|
41 |
+
)
|
42 |
+
)
|
43 |
+
logger.info(f"Đã kết nối ChromaDB tại: {CHROMA_PERSIST_DIRECTORY}")
|
44 |
+
|
45 |
+
# Lấy hoặc tạo collection
|
46 |
+
try:
|
47 |
+
self.collection = self.chroma_client.get_collection(name=COLLECTION_NAME)
|
48 |
+
logger.info(f"Đã kết nối collection '{COLLECTION_NAME}' với {self.collection.count()} items")
|
49 |
+
except Exception:
|
50 |
+
logger.warning(f"Collection '{COLLECTION_NAME}' không tồn tại, tạo mới...")
|
51 |
+
self.collection = self.chroma_client.create_collection(name=COLLECTION_NAME)
|
52 |
+
logger.info(f"Đã tạo collection mới: {COLLECTION_NAME}")
|
53 |
+
|
54 |
+
def encode(self, texts):
|
55 |
+
"""
|
56 |
+
Encode văn bản thành embeddings
|
57 |
+
|
58 |
+
Args:
|
59 |
+
texts (str or list): Văn bản hoặc danh sách văn bản cần encode
|
60 |
+
|
61 |
+
Returns:
|
62 |
+
list: Embeddings vector
|
63 |
+
"""
|
64 |
+
try:
|
65 |
+
if isinstance(texts, str):
|
66 |
+
texts = [texts]
|
67 |
+
|
68 |
+
logger.debug(f"Đang encode {len(texts)} văn bản")
|
69 |
+
embeddings = self.model.encode(texts, show_progress_bar=False)
|
70 |
+
|
71 |
+
return embeddings.tolist()
|
72 |
+
|
73 |
+
except Exception as e:
|
74 |
+
logger.error(f"Lỗi encode văn bản: {e}")
|
75 |
+
raise
|
76 |
+
|
77 |
+
def search(self, query, top_k=5, age_filter=None):
|
78 |
+
"""
|
79 |
+
Tìm kiếm văn bản tương tự trong ChromaDB
|
80 |
+
|
81 |
+
Args:
|
82 |
+
query (str): Câu hỏi cần tìm kiếm
|
83 |
+
top_k (int): Số lượng kết quả trả về
|
84 |
+
age_filter (int): Lọc theo độ tuổi (optional)
|
85 |
+
|
86 |
+
Returns:
|
87 |
+
list: Danh sách kết quả tìm kiếm
|
88 |
+
"""
|
89 |
+
try:
|
90 |
+
logger.debug(f"Dang tim kiem cho query: {query[:50]}...")
|
91 |
+
|
92 |
+
# Encode query thành embedding
|
93 |
+
query_embedding = self.encode(query)[0]
|
94 |
+
|
95 |
+
# Tạo where clause cho age filter
|
96 |
+
where_clause = None
|
97 |
+
if age_filter:
|
98 |
+
where_clause = {
|
99 |
+
"$and": [
|
100 |
+
{"age_min": {"$lte": age_filter}},
|
101 |
+
{"age_max": {"$gte": age_filter}}
|
102 |
+
]
|
103 |
+
}
|
104 |
+
|
105 |
+
# Thực hiện search trong ChromaDB
|
106 |
+
search_results = self.collection.query(
|
107 |
+
query_embeddings=[query_embedding],
|
108 |
+
n_results=top_k,
|
109 |
+
where=where_clause,
|
110 |
+
include=['documents', 'metadatas', 'distances']
|
111 |
+
)
|
112 |
+
|
113 |
+
if not search_results or not search_results['documents']:
|
114 |
+
logger.warning("Khong tim thay ket qua nao")
|
115 |
+
return []
|
116 |
+
|
117 |
+
# Format kết quả
|
118 |
+
results = []
|
119 |
+
documents = search_results['documents'][0]
|
120 |
+
metadatas = search_results['metadatas'][0]
|
121 |
+
distances = search_results['distances'][0]
|
122 |
+
|
123 |
+
for i, (doc, metadata, distance) in enumerate(zip(documents, metadatas, distances)):
|
124 |
+
results.append({
|
125 |
+
'document': doc,
|
126 |
+
'metadata': metadata or {},
|
127 |
+
'distance': distance,
|
128 |
+
'similarity': 1 - distance, # Chuyển distance thành similarity
|
129 |
+
'rank': i + 1
|
130 |
+
})
|
131 |
+
|
132 |
+
logger.info(f"Tim thay {len(results)} ket qua cho query")
|
133 |
+
return results
|
134 |
+
|
135 |
+
except Exception as e:
|
136 |
+
logger.error(f"Loi tim kiem: {e}")
|
137 |
+
return []
|
138 |
+
|
139 |
+
def add_documents(self, documents, metadatas=None, ids=None):
|
140 |
+
"""
|
141 |
+
Thêm documents vào ChromaDB
|
142 |
+
|
143 |
+
Args:
|
144 |
+
documents (list): Danh sách văn bản
|
145 |
+
metadatas (list): Danh sách metadata tương ứng
|
146 |
+
ids (list): Danh sách ID tương ứng (optional)
|
147 |
+
|
148 |
+
Returns:
|
149 |
+
bool: True nếu thành công
|
150 |
+
"""
|
151 |
+
try:
|
152 |
+
if not documents:
|
153 |
+
logger.warning("Không có documents để thêm")
|
154 |
+
return False
|
155 |
+
|
156 |
+
# Tạo IDs nếu không được cung cấp
|
157 |
+
if not ids:
|
158 |
+
ids = [str(uuid.uuid4()) for _ in documents]
|
159 |
+
|
160 |
+
# Tạo metadatas rỗng nếu không được cung cấp
|
161 |
+
if not metadatas:
|
162 |
+
metadatas = [{} for _ in documents]
|
163 |
+
|
164 |
+
logger.info(f"Đang thêm {len(documents)} documents vào ChromaDB")
|
165 |
+
|
166 |
+
# Encode documents thành embeddings
|
167 |
+
embeddings = self.encode(documents)
|
168 |
+
|
169 |
+
# Thêm vào collection
|
170 |
+
self.collection.add(
|
171 |
+
embeddings=embeddings,
|
172 |
+
documents=documents,
|
173 |
+
metadatas=metadatas,
|
174 |
+
ids=ids
|
175 |
+
)
|
176 |
+
|
177 |
+
logger.info(f"Đã thêm thành công {len(documents)} documents")
|
178 |
+
return True
|
179 |
+
|
180 |
+
except Exception as e:
|
181 |
+
logger.error(f"Lỗi thêm documents: {e}")
|
182 |
+
return False
|
183 |
+
|
184 |
+
def index_chunks(self, chunks):
|
185 |
+
"""
|
186 |
+
Index các chunks dữ liệu vào ChromaDB
|
187 |
+
|
188 |
+
Args:
|
189 |
+
chunks (list): Danh sách chunks với format:
|
190 |
+
[
|
191 |
+
{
|
192 |
+
"content": "nội dung văn bản",
|
193 |
+
"metadata": {"chapter": "bai1", "age_group": "1-3", ...},
|
194 |
+
"id": "unique_id" (optional)
|
195 |
+
}
|
196 |
+
]
|
197 |
+
|
198 |
+
Returns:
|
199 |
+
bool: True nếu thành công
|
200 |
+
"""
|
201 |
+
try:
|
202 |
+
if not chunks:
|
203 |
+
logger.warning("Không có chunks để index")
|
204 |
+
return False
|
205 |
+
|
206 |
+
documents = []
|
207 |
+
metadatas = []
|
208 |
+
ids = []
|
209 |
+
|
210 |
+
for chunk in chunks:
|
211 |
+
if not chunk.get('content'):
|
212 |
+
logger.warning(f"Chunk thiếu content: {chunk}")
|
213 |
+
continue
|
214 |
+
|
215 |
+
documents.append(chunk['content'])
|
216 |
+
|
217 |
+
# Lấy metadata đã được chuẩn bị sẵn
|
218 |
+
metadata = chunk.get('metadata', {})
|
219 |
+
metadatas.append(metadata)
|
220 |
+
|
221 |
+
# Sử dụng ID có sẵn hoặc tạo mới
|
222 |
+
chunk_id = chunk.get('id') or str(uuid.uuid4())
|
223 |
+
ids.append(chunk_id)
|
224 |
+
|
225 |
+
if not documents:
|
226 |
+
logger.warning("Không có documents hợp lệ để index")
|
227 |
+
return False
|
228 |
+
|
229 |
+
# Batch processing để tránh overload
|
230 |
+
batch_size = 100
|
231 |
+
total_batches = (len(documents) + batch_size - 1) // batch_size
|
232 |
+
|
233 |
+
for i in range(0, len(documents), batch_size):
|
234 |
+
batch_docs = documents[i:i + batch_size]
|
235 |
+
batch_metas = metadatas[i:i + batch_size]
|
236 |
+
batch_ids = ids[i:i + batch_size]
|
237 |
+
|
238 |
+
batch_num = (i // batch_size) + 1
|
239 |
+
logger.info(f"Đang xử lý batch {batch_num}/{total_batches} ({len(batch_docs)} items)")
|
240 |
+
|
241 |
+
success = self.add_documents(batch_docs, batch_metas, batch_ids)
|
242 |
+
if not success:
|
243 |
+
logger.error(f"Lỗi xử lý batch {batch_num}")
|
244 |
+
return False
|
245 |
+
|
246 |
+
logger.info(f"Đã index thành công {len(documents)} chunks")
|
247 |
+
return True
|
248 |
+
|
249 |
+
except Exception as e:
|
250 |
+
logger.error(f"Lỗi index chunks: {e}")
|
251 |
+
return False
|
252 |
+
|
253 |
+
def count(self):
|
254 |
+
"""
|
255 |
+
Đếm số lượng documents trong collection
|
256 |
+
|
257 |
+
Returns:
|
258 |
+
int: Số lượng documents
|
259 |
+
"""
|
260 |
+
try:
|
261 |
+
return self.collection.count()
|
262 |
+
except Exception as e:
|
263 |
+
logger.error(f"Lỗi đếm documents: {e}")
|
264 |
+
return 0
|
265 |
+
|
266 |
+
def delete_collection(self):
|
267 |
+
"""
|
268 |
+
Xóa collection hiện tại
|
269 |
+
|
270 |
+
Returns:
|
271 |
+
bool: True nếu thành công
|
272 |
+
"""
|
273 |
+
try:
|
274 |
+
logger.warning(f"Đang xóa collection: {COLLECTION_NAME}")
|
275 |
+
self.chroma_client.delete_collection(name=COLLECTION_NAME)
|
276 |
+
|
277 |
+
# Tạo lại collection mới
|
278 |
+
self.collection = self.chroma_client.create_collection(name=COLLECTION_NAME)
|
279 |
+
logger.info("Đã tạo lại collection mới")
|
280 |
+
|
281 |
+
return True
|
282 |
+
|
283 |
+
except Exception as e:
|
284 |
+
logger.error(f"Lỗi xóa collection: {e}")
|
285 |
+
return False
|
286 |
+
|
287 |
+
def get_stats(self):
|
288 |
+
"""
|
289 |
+
Lấy thống kê về collection
|
290 |
+
|
291 |
+
Returns:
|
292 |
+
dict: Thông tin thống kê
|
293 |
+
"""
|
294 |
+
try:
|
295 |
+
total_count = self.count()
|
296 |
+
|
297 |
+
# Lấy sample để phân tích metadata
|
298 |
+
sample_results = self.collection.get(limit=min(100, total_count))
|
299 |
+
|
300 |
+
# Thống kê content types
|
301 |
+
content_types = {}
|
302 |
+
chapters = {}
|
303 |
+
age_groups = {}
|
304 |
+
|
305 |
+
if sample_results and sample_results.get('metadatas'):
|
306 |
+
for metadata in sample_results['metadatas']:
|
307 |
+
if not metadata:
|
308 |
+
continue
|
309 |
+
|
310 |
+
# Content type stats
|
311 |
+
content_type = metadata.get('content_type', 'unknown')
|
312 |
+
content_types[content_type] = content_types.get(content_type, 0) + 1
|
313 |
+
|
314 |
+
# Chapter stats
|
315 |
+
chapter = metadata.get('chapter', 'unknown')
|
316 |
+
chapters[chapter] = chapters.get(chapter, 0) + 1
|
317 |
+
|
318 |
+
# Age group stats
|
319 |
+
age_group = metadata.get('age_group', 'unknown')
|
320 |
+
age_groups[age_group] = age_groups.get(age_group, 0) + 1
|
321 |
+
|
322 |
+
return {
|
323 |
+
'total_documents': total_count,
|
324 |
+
'content_types': content_types,
|
325 |
+
'chapters': chapters,
|
326 |
+
'age_groups': age_groups,
|
327 |
+
'collection_name': COLLECTION_NAME,
|
328 |
+
'embedding_model': EMBEDDING_MODEL
|
329 |
+
}
|
330 |
+
|
331 |
+
except Exception as e:
|
332 |
+
logger.error(f"Lỗi lấy stats: {e}")
|
333 |
+
return {
|
334 |
+
'total_documents': 0,
|
335 |
+
'error': str(e)
|
336 |
+
}
|
core/rag_pipeline.py
ADDED
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import google.generativeai as genai
|
3 |
+
from core.embedding_model import get_embedding_model
|
4 |
+
from config import GEMINI_API_KEY, HUMAN_PROMPT_TEMPLATE, SYSTEM_PROMPT, TOP_K_RESULTS, TEMPERATURE, MAX_OUTPUT_TOKENS
|
5 |
+
import os
|
6 |
+
import re
|
7 |
+
|
8 |
+
# Cấu hình logging
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
# Cấu hình Gemini
|
12 |
+
genai.configure(api_key=GEMINI_API_KEY)
|
13 |
+
|
14 |
+
class RAGPipeline:
|
15 |
+
def __init__(self):
|
16 |
+
"""Khởi tạo RAG Pipeline chỉ với embedding model"""
|
17 |
+
logger.info("Khởi tạo RAG Pipeline")
|
18 |
+
|
19 |
+
self.embedding_model = get_embedding_model()
|
20 |
+
|
21 |
+
# Khởi tạo Gemini model
|
22 |
+
self.gemini_model = genai.GenerativeModel('gemini-2.0-flash')
|
23 |
+
|
24 |
+
logger.info("RAG Pipeline đã sẵn sàng")
|
25 |
+
|
26 |
+
def generate_response(self, query, age=1):
|
27 |
+
"""
|
28 |
+
Generate response cho user query sử dụng RAG
|
29 |
+
|
30 |
+
Args:
|
31 |
+
query (str): Câu hỏi của người dùng
|
32 |
+
age (int): Tuổi của người dùng (1-19)
|
33 |
+
|
34 |
+
Returns:
|
35 |
+
dict: Response data with success status
|
36 |
+
"""
|
37 |
+
try:
|
38 |
+
logger.info(f"Bắt đầu generate response cho query: {query[:50]}... (age: {age})")
|
39 |
+
|
40 |
+
# SỬA: Chỉ search trong ChromaDB, không load lại dữ liệu
|
41 |
+
logger.info("Đang tìm kiếm thông tin liên quan...")
|
42 |
+
search_results = self.embedding_model.search(query, top_k=TOP_K_RESULTS)
|
43 |
+
|
44 |
+
if not search_results or len(search_results) == 0:
|
45 |
+
logger.warning("Không tìm thấy thông tin liên quan")
|
46 |
+
return {
|
47 |
+
"success": True,
|
48 |
+
"response": "Xin lỗi, tôi không tìm thấy thông tin liên quan đến câu hỏi của bạn trong tài liệu.",
|
49 |
+
"sources": []
|
50 |
+
}
|
51 |
+
|
52 |
+
# Chuẩn bị contexts từ kết quả tìm kiếm
|
53 |
+
contexts = []
|
54 |
+
sources = []
|
55 |
+
|
56 |
+
for result in search_results:
|
57 |
+
# Lấy thông tin từ metadata
|
58 |
+
metadata = result.get('metadata', {})
|
59 |
+
content = result.get('document', '')
|
60 |
+
|
61 |
+
# Thêm context
|
62 |
+
contexts.append({
|
63 |
+
"content": content,
|
64 |
+
"metadata": metadata
|
65 |
+
})
|
66 |
+
|
67 |
+
# Thêm source reference
|
68 |
+
source_info = {
|
69 |
+
"title": metadata.get('title', metadata.get('chapter', 'Tài liệu dinh dưỡng')),
|
70 |
+
"pages": metadata.get('pages'),
|
71 |
+
"content_type": metadata.get('content_type', 'text')
|
72 |
+
}
|
73 |
+
|
74 |
+
if source_info not in sources:
|
75 |
+
sources.append(source_info)
|
76 |
+
|
77 |
+
# Format contexts cho prompt
|
78 |
+
formatted_contexts = self._format_contexts(contexts)
|
79 |
+
|
80 |
+
# Tạo prompt với age context
|
81 |
+
full_prompt = self._create_prompt_with_age_context(query, age, formatted_contexts)
|
82 |
+
|
83 |
+
# Generate response với Gemini
|
84 |
+
logger.info("Đang tạo phản hồi với Gemini...")
|
85 |
+
response = self.gemini_model.generate_content(
|
86 |
+
full_prompt,
|
87 |
+
generation_config=genai.types.GenerationConfig(
|
88 |
+
temperature=TEMPERATURE,
|
89 |
+
max_output_tokens=MAX_OUTPUT_TOKENS
|
90 |
+
)
|
91 |
+
)
|
92 |
+
|
93 |
+
if not response or not response.text:
|
94 |
+
logger.error("Gemini không trả về response")
|
95 |
+
return {
|
96 |
+
"success": False,
|
97 |
+
"error": "Không thể tạo phản hồi"
|
98 |
+
}
|
99 |
+
|
100 |
+
response_text = response.text.strip()
|
101 |
+
|
102 |
+
# Post-process response để xử lý hình ảnh
|
103 |
+
response_text = self._process_image_links(response_text)
|
104 |
+
|
105 |
+
logger.info("Đã tạo phản hồi thành công")
|
106 |
+
|
107 |
+
return {
|
108 |
+
"success": True,
|
109 |
+
"response": response_text,
|
110 |
+
"sources": sources[:3] # Giới hạn 3 sources để không quá dài
|
111 |
+
}
|
112 |
+
|
113 |
+
except Exception as e:
|
114 |
+
logger.error(f"Lỗi generate response: {str(e)}")
|
115 |
+
return {
|
116 |
+
"success": False,
|
117 |
+
"error": f"Lỗi tạo phản hồi: {str(e)}"
|
118 |
+
}
|
119 |
+
|
120 |
+
def _format_contexts(self, contexts):
|
121 |
+
"""Format contexts thành string cho prompt"""
|
122 |
+
formatted = []
|
123 |
+
|
124 |
+
for i, context in enumerate(contexts, 1):
|
125 |
+
content = context['content']
|
126 |
+
metadata = context['metadata']
|
127 |
+
|
128 |
+
# Thêm thông tin metadata
|
129 |
+
context_str = f"[Tài liệu {i}]"
|
130 |
+
if metadata.get('title'):
|
131 |
+
context_str += f" - {metadata['title']}"
|
132 |
+
if metadata.get('pages'):
|
133 |
+
context_str += f" (Trang {metadata['pages']})"
|
134 |
+
|
135 |
+
context_str += f"\n{content}\n"
|
136 |
+
formatted.append(context_str)
|
137 |
+
|
138 |
+
return "\n".join(formatted)
|
139 |
+
|
140 |
+
def _create_prompt_with_age_context(self, query, age, contexts):
|
141 |
+
"""Tạo prompt với age context"""
|
142 |
+
# Xác định age group
|
143 |
+
if age <= 3:
|
144 |
+
age_guidance = "Sử dụng ngôn ngữ đơn giản, dễ hiểu cho phụ huynh có con nhỏ."
|
145 |
+
elif age <= 6:
|
146 |
+
age_guidance = "Tập trung vào dinh dưỡng cho trẻ mầm non, ngôn ngữ phù hợp với phụ huynh."
|
147 |
+
elif age <= 12:
|
148 |
+
age_guidance = "Nội dung phù hợp cho trẻ tiểu học, có thể giải thích đơn giản cho trẻ hiểu."
|
149 |
+
elif age <= 15:
|
150 |
+
age_guidance = "Thông tin chi tiết hơn, phù hợp cho học sinh trung học cơ sở."
|
151 |
+
else:
|
152 |
+
age_guidance = "Thông tin đầy đủ, chi tiết cho học sinh trung học phổ thông."
|
153 |
+
|
154 |
+
# Tạo system prompt với age context
|
155 |
+
age_aware_system_prompt = f"""{SYSTEM_PROMPT}
|
156 |
+
|
157 |
+
QUAN TRỌNG - Hướng dẫn theo độ tuổi:
|
158 |
+
Người dùng hiện tại {age} tuổi. {age_guidance}
|
159 |
+
- Điều chỉnh ngôn ngữ và nội dung cho phù hợp
|
160 |
+
- Đưa ra lời khuyên cụ thể cho độ tuổi này
|
161 |
+
- Tránh thông tin quá phức tạp hoặc không phù hợp
|
162 |
+
"""
|
163 |
+
|
164 |
+
# Tạo human prompt
|
165 |
+
human_prompt = HUMAN_PROMPT_TEMPLATE.format(
|
166 |
+
query=query,
|
167 |
+
age=age,
|
168 |
+
contexts=contexts
|
169 |
+
)
|
170 |
+
|
171 |
+
return f"{age_aware_system_prompt}\n\n{human_prompt}"
|
172 |
+
|
173 |
+
def _process_image_links(self, response_text):
|
174 |
+
"""Xử lý các đường dẫn hình ảnh trong response"""
|
175 |
+
try:
|
176 |
+
import re
|
177 |
+
|
178 |
+
# Tìm các pattern markdown image
|
179 |
+
image_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
|
180 |
+
|
181 |
+
def replace_image_path(match):
|
182 |
+
alt_text = match.group(1)
|
183 |
+
image_path = match.group(2)
|
184 |
+
|
185 |
+
# Xử lý đường dẫn local Windows/Linux
|
186 |
+
if '\\' in image_path or image_path.startswith('/') or ':' in image_path:
|
187 |
+
# Extract filename từ đường dẫn local
|
188 |
+
filename = image_path.split('\\')[-1].split('/')[-1]
|
189 |
+
|
190 |
+
# Tìm bai_id từ filename
|
191 |
+
bai_match = re.match(r'^(bai\d+)_', filename)
|
192 |
+
if bai_match:
|
193 |
+
bai_id = bai_match.group(1)
|
194 |
+
else:
|
195 |
+
bai_id = 'bai1' # default
|
196 |
+
|
197 |
+
# Tạo API URL
|
198 |
+
api_url = f"/api/figures/{bai_id}/{filename}"
|
199 |
+
return f""
|
200 |
+
|
201 |
+
# Nếu đã là đường dẫn API, giữ nguyên
|
202 |
+
elif image_path.startswith('/api/figures/'):
|
203 |
+
return match.group(0)
|
204 |
+
|
205 |
+
# Xử lý đường dẫn tương đối
|
206 |
+
elif '../figures/' in image_path:
|
207 |
+
filename = image_path.split('../figures/')[-1]
|
208 |
+
bai_match = re.match(r'^(bai\d+)_', filename)
|
209 |
+
if bai_match:
|
210 |
+
bai_id = bai_match.group(1)
|
211 |
+
else:
|
212 |
+
bai_id = 'bai1'
|
213 |
+
|
214 |
+
api_url = f"/api/figures/{bai_id}/{filename}"
|
215 |
+
return f""
|
216 |
+
|
217 |
+
# Các trường hợp khác, giữ nguyên
|
218 |
+
return match.group(0)
|
219 |
+
|
220 |
+
# Thay thế tất cả image links
|
221 |
+
processed_text = re.sub(image_pattern, replace_image_path, response_text)
|
222 |
+
|
223 |
+
logger.info(f"Processed {len(re.findall(image_pattern, response_text))} image links")
|
224 |
+
return processed_text
|
225 |
+
|
226 |
+
except Exception as e:
|
227 |
+
logger.error(f"Lỗi xử lý image links: {e}")
|
228 |
+
return response_text
|
229 |
+
|
230 |
+
def generate_follow_up_questions(self, query, answer, age=1):
|
231 |
+
"""
|
232 |
+
Tạo câu hỏi gợi ý dựa trên query và answer
|
233 |
+
|
234 |
+
Args:
|
235 |
+
query (str): Câu hỏi gốc
|
236 |
+
answer (str): Câu trả lời đã được tạo
|
237 |
+
age (int): Tuổi người dùng
|
238 |
+
|
239 |
+
Returns:
|
240 |
+
dict: Response data với danh sách câu hỏi gợi ý
|
241 |
+
"""
|
242 |
+
try:
|
243 |
+
logger.info("Đang tạo câu hỏi follow-up...")
|
244 |
+
|
245 |
+
follow_up_prompt = f"""
|
246 |
+
Dựa trên cuộc hội thoại sau, hãy tạo 3-5 câu hỏi gợi ý phù hợp cho người dùng {age} tuổi về chủ đề dinh dưỡng:
|
247 |
+
|
248 |
+
Câu hỏi gốc: {query}
|
249 |
+
Câu trả lời: {answer}
|
250 |
+
|
251 |
+
Hãy tạo các câu hỏi:
|
252 |
+
1. Liên quan trực tiếp đến chủ đề
|
253 |
+
2. Phù hợp với độ tuổi {age}
|
254 |
+
3. Thực tế và hữu ích
|
255 |
+
4. Ngắn gọn, dễ hiểu
|
256 |
+
|
257 |
+
Trả về danh sách câu hỏi, mỗi câu một dòng, không đánh số.
|
258 |
+
"""
|
259 |
+
|
260 |
+
response = self.gemini_model.generate_content(
|
261 |
+
follow_up_prompt,
|
262 |
+
generation_config=genai.types.GenerationConfig(
|
263 |
+
temperature=0.7,
|
264 |
+
max_output_tokens=500
|
265 |
+
)
|
266 |
+
)
|
267 |
+
|
268 |
+
if not response or not response.text:
|
269 |
+
return {
|
270 |
+
"success": False,
|
271 |
+
"error": "Không thể tạo câu hỏi gợi ý"
|
272 |
+
}
|
273 |
+
|
274 |
+
# Parse response thành list câu hỏi
|
275 |
+
questions = []
|
276 |
+
lines = response.text.strip().split('\n')
|
277 |
+
|
278 |
+
for line in lines:
|
279 |
+
line = line.strip()
|
280 |
+
if line and not line.startswith('#') and len(line) > 10:
|
281 |
+
# Loại bỏ số thứ tự nếu có
|
282 |
+
line = re.sub(r'^\d+[\.\)]\s*', '', line)
|
283 |
+
questions.append(line)
|
284 |
+
|
285 |
+
# Giới hạn 5 câu hỏi
|
286 |
+
questions = questions[:5]
|
287 |
+
|
288 |
+
return {
|
289 |
+
"success": True,
|
290 |
+
"questions": questions
|
291 |
+
}
|
292 |
+
|
293 |
+
except Exception as e:
|
294 |
+
logger.error(f"Lỗi tạo follow-up questions: {str(e)}")
|
295 |
+
return {
|
296 |
+
"success": False,
|
297 |
+
"error": f"Lỗi tạo câu hỏi gợi ý: {str(e)}"
|
298 |
+
}
|
embed_data.log
ADDED
File without changes
|
models/__init__.py
ADDED
File without changes
|
models/admin_model.py
ADDED
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import datetime
|
3 |
+
from pymongo import MongoClient
|
4 |
+
from bson.objectid import ObjectId
|
5 |
+
import bcrypt
|
6 |
+
import logging
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
|
9 |
+
# Cấu hình logging
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
# Tải biến môi trường
|
13 |
+
load_dotenv()
|
14 |
+
|
15 |
+
# Kết nối MongoDB (sử dụng chung với user_model)
|
16 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
|
17 |
+
DATABASE_NAME = os.getenv("MONGO_DB_NAME", "nutribot_db")
|
18 |
+
|
19 |
+
# Singleton pattern cho kết nối MongoDB
|
20 |
+
_mongo_client = None
|
21 |
+
_db = None
|
22 |
+
|
23 |
+
def get_db():
|
24 |
+
"""Trả về instance của MongoDB database (singleton pattern)"""
|
25 |
+
global _mongo_client, _db
|
26 |
+
if _mongo_client is None:
|
27 |
+
try:
|
28 |
+
_mongo_client = MongoClient(MONGO_URI)
|
29 |
+
_db = _mongo_client[DATABASE_NAME]
|
30 |
+
logger.info(f"Đã kết nối đến database: {DATABASE_NAME}")
|
31 |
+
except Exception as e:
|
32 |
+
logger.error(f"Lỗi kết nối MongoDB: {e}")
|
33 |
+
raise
|
34 |
+
return _db
|
35 |
+
|
36 |
+
class AdminUser:
|
37 |
+
def __init__(self, name, email, password, role="admin", permissions=None,
|
38 |
+
user_id=None, created_at=None, updated_at=None, last_login=None):
|
39 |
+
self.user_id = user_id
|
40 |
+
self.name = name
|
41 |
+
self.email = email
|
42 |
+
self.password = password # Mật khẩu đã mã hóa
|
43 |
+
self.role = role # admin, super_admin
|
44 |
+
self.permissions = permissions or self._get_default_permissions(role)
|
45 |
+
self.created_at = created_at or datetime.datetime.now()
|
46 |
+
self.updated_at = updated_at or datetime.datetime.now()
|
47 |
+
self.last_login = last_login
|
48 |
+
self.is_active = True
|
49 |
+
|
50 |
+
def _get_default_permissions(self, role):
|
51 |
+
"""Lấy quyền mặc định theo role"""
|
52 |
+
if role == "super_admin":
|
53 |
+
return {
|
54 |
+
"users": {"read": True, "write": True, "delete": True},
|
55 |
+
"documents": {"read": True, "write": True, "delete": True},
|
56 |
+
"conversations": {"read": True, "write": True, "delete": True},
|
57 |
+
"system": {"read": True, "write": True, "delete": True},
|
58 |
+
"analytics": {"read": True, "write": True, "delete": True}
|
59 |
+
}
|
60 |
+
elif role == "admin":
|
61 |
+
return {
|
62 |
+
"users": {"read": True, "write": True, "delete": False},
|
63 |
+
"documents": {"read": True, "write": True, "delete": True},
|
64 |
+
"conversations": {"read": True, "write": False, "delete": True},
|
65 |
+
"system": {"read": True, "write": False, "delete": False},
|
66 |
+
"analytics": {"read": True, "write": False, "delete": False}
|
67 |
+
}
|
68 |
+
else:
|
69 |
+
return {}
|
70 |
+
|
71 |
+
@staticmethod
|
72 |
+
def hash_password(password):
|
73 |
+
"""Mã hóa mật khẩu sử dụng bcrypt"""
|
74 |
+
salt = bcrypt.gensalt()
|
75 |
+
hashed_pw = bcrypt.hashpw(password.encode('utf-8'), salt)
|
76 |
+
return hashed_pw
|
77 |
+
|
78 |
+
@staticmethod
|
79 |
+
def check_password(hashed_password, password):
|
80 |
+
"""Kiểm tra mật khẩu"""
|
81 |
+
return bcrypt.checkpw(password.encode('utf-8'), hashed_password)
|
82 |
+
|
83 |
+
def to_dict(self):
|
84 |
+
"""Chuyển đổi thông tin admin thành dictionary"""
|
85 |
+
admin_dict = {
|
86 |
+
"name": self.name,
|
87 |
+
"email": self.email,
|
88 |
+
"password": self.password,
|
89 |
+
"role": self.role,
|
90 |
+
"permissions": self.permissions,
|
91 |
+
"created_at": self.created_at,
|
92 |
+
"updated_at": self.updated_at,
|
93 |
+
"last_login": self.last_login,
|
94 |
+
"is_active": self.is_active
|
95 |
+
}
|
96 |
+
if self.user_id:
|
97 |
+
admin_dict["_id"] = self.user_id
|
98 |
+
return admin_dict
|
99 |
+
|
100 |
+
@classmethod
|
101 |
+
def from_dict(cls, admin_dict):
|
102 |
+
"""Tạo đối tượng AdminUser từ dictionary"""
|
103 |
+
if not admin_dict:
|
104 |
+
return None
|
105 |
+
|
106 |
+
return cls(
|
107 |
+
user_id=admin_dict.get("_id"),
|
108 |
+
name=admin_dict.get("name"),
|
109 |
+
email=admin_dict.get("email"),
|
110 |
+
password=admin_dict.get("password"),
|
111 |
+
role=admin_dict.get("role", "admin"),
|
112 |
+
permissions=admin_dict.get("permissions"),
|
113 |
+
created_at=admin_dict.get("created_at"),
|
114 |
+
updated_at=admin_dict.get("updated_at"),
|
115 |
+
last_login=admin_dict.get("last_login")
|
116 |
+
)
|
117 |
+
|
118 |
+
@classmethod
|
119 |
+
def find_by_email(cls, email):
|
120 |
+
"""Tìm admin theo email"""
|
121 |
+
try:
|
122 |
+
db = get_db()
|
123 |
+
admins_collection = db.admins
|
124 |
+
admin_data = admins_collection.find_one({"email": email, "is_active": True})
|
125 |
+
return cls.from_dict(admin_data)
|
126 |
+
except Exception as e:
|
127 |
+
logger.error(f"Lỗi tìm admin theo email: {e}")
|
128 |
+
return None
|
129 |
+
|
130 |
+
@classmethod
|
131 |
+
def find_by_id(cls, user_id):
|
132 |
+
"""Tìm admin theo ID"""
|
133 |
+
try:
|
134 |
+
db = get_db()
|
135 |
+
admins_collection = db.admins
|
136 |
+
admin_data = admins_collection.find_one({"_id": ObjectId(user_id), "is_active": True})
|
137 |
+
return cls.from_dict(admin_data)
|
138 |
+
except Exception as e:
|
139 |
+
logger.error(f"Lỗi tìm admin theo ID: {e}")
|
140 |
+
return None
|
141 |
+
|
142 |
+
@classmethod
|
143 |
+
def get_all(cls, page=1, per_page=10):
|
144 |
+
"""Lấy danh sách tất cả admin với phân trang"""
|
145 |
+
try:
|
146 |
+
db = get_db()
|
147 |
+
admins_collection = db.admins
|
148 |
+
|
149 |
+
skip = (page - 1) * per_page
|
150 |
+
admins_data = admins_collection.find(
|
151 |
+
{"is_active": True}
|
152 |
+
).sort("created_at", -1).skip(skip).limit(per_page)
|
153 |
+
|
154 |
+
total = admins_collection.count_documents({"is_active": True})
|
155 |
+
|
156 |
+
return [cls.from_dict(admin) for admin in admins_data], total
|
157 |
+
except Exception as e:
|
158 |
+
logger.error(f"Lỗi lấy danh sách admin: {e}")
|
159 |
+
return [], 0
|
160 |
+
|
161 |
+
def save(self):
|
162 |
+
"""Lưu thông tin admin vào database"""
|
163 |
+
try:
|
164 |
+
db = get_db()
|
165 |
+
admins_collection = db.admins
|
166 |
+
|
167 |
+
if not self.user_id:
|
168 |
+
# Đây là admin mới
|
169 |
+
insert_result = admins_collection.insert_one(self.to_dict())
|
170 |
+
self.user_id = insert_result.inserted_id
|
171 |
+
logger.info(f"Đã tạo admin mới với ID: {self.user_id}")
|
172 |
+
return self.user_id
|
173 |
+
else:
|
174 |
+
# Cập nhật admin đã tồn tại
|
175 |
+
self.updated_at = datetime.datetime.now()
|
176 |
+
admins_collection.update_one(
|
177 |
+
{"_id": self.user_id},
|
178 |
+
{"$set": self.to_dict()}
|
179 |
+
)
|
180 |
+
logger.info(f"Đã cập nhật thông tin admin: {self.user_id}")
|
181 |
+
return self.user_id
|
182 |
+
except Exception as e:
|
183 |
+
logger.error(f"Lỗi khi lưu thông tin admin: {e}")
|
184 |
+
raise
|
185 |
+
|
186 |
+
def update_last_login(self):
|
187 |
+
"""Cập nhật thời gian đăng nhập cuối"""
|
188 |
+
try:
|
189 |
+
self.last_login = datetime.datetime.now()
|
190 |
+
self.save()
|
191 |
+
except Exception as e:
|
192 |
+
logger.error(f"Lỗi cập nhật last_login: {e}")
|
193 |
+
|
194 |
+
def has_permission(self, resource, action):
|
195 |
+
"""Kiểm tra quyền truy cập"""
|
196 |
+
if not self.permissions:
|
197 |
+
return False
|
198 |
+
|
199 |
+
resource_perms = self.permissions.get(resource, {})
|
200 |
+
return resource_perms.get(action, False)
|
201 |
+
|
202 |
+
def delete(self):
|
203 |
+
"""Xóa mềm admin (đánh dấu is_active = False)"""
|
204 |
+
try:
|
205 |
+
self.is_active = False
|
206 |
+
self.save()
|
207 |
+
logger.info(f"Đã xóa admin: {self.user_id}")
|
208 |
+
return True
|
209 |
+
except Exception as e:
|
210 |
+
logger.error(f"Lỗi khi xóa admin: {e}")
|
211 |
+
return False
|
212 |
+
|
213 |
+
@staticmethod
|
214 |
+
def login(email, password):
|
215 |
+
"""Đăng nhập admin"""
|
216 |
+
try:
|
217 |
+
if not email or not password:
|
218 |
+
return False, "Vui lòng nhập đầy đủ thông tin đăng nhập"
|
219 |
+
|
220 |
+
admin = AdminUser.find_by_email(email)
|
221 |
+
if not admin:
|
222 |
+
return False, "Email không tồn tại hoặc không có quyền admin"
|
223 |
+
|
224 |
+
if not AdminUser.check_password(admin.password, password):
|
225 |
+
return False, "Mật khẩu không chính xác"
|
226 |
+
|
227 |
+
# Cập nhật thời gian đăng nhập
|
228 |
+
admin.update_last_login()
|
229 |
+
|
230 |
+
return True, admin
|
231 |
+
except Exception as e:
|
232 |
+
logger.error(f"Lỗi đăng nhập admin: {e}")
|
233 |
+
return False, f"Lỗi đăng nhập: {str(e)}"
|
234 |
+
|
235 |
+
@staticmethod
|
236 |
+
def create_admin(name, email, password, role="admin", permissions=None):
|
237 |
+
"""Tạo admin mới"""
|
238 |
+
try:
|
239 |
+
# Kiểm tra email đã tồn tại chưa
|
240 |
+
existing_admin = AdminUser.find_by_email(email)
|
241 |
+
if existing_admin:
|
242 |
+
return False, "Email đã được sử dụng"
|
243 |
+
|
244 |
+
# Mã hóa mật khẩu
|
245 |
+
hashed_password = AdminUser.hash_password(password)
|
246 |
+
|
247 |
+
# Tạo admin mới
|
248 |
+
new_admin = AdminUser(
|
249 |
+
name=name,
|
250 |
+
email=email,
|
251 |
+
password=hashed_password,
|
252 |
+
role=role,
|
253 |
+
permissions=permissions
|
254 |
+
)
|
255 |
+
|
256 |
+
# Lưu vào database
|
257 |
+
admin_id = new_admin.save()
|
258 |
+
|
259 |
+
return True, {"admin_id": str(admin_id)}
|
260 |
+
except Exception as e:
|
261 |
+
logger.error(f"Lỗi tạo admin: {e}")
|
262 |
+
return False, f"Lỗi tạo admin: {str(e)}"
|
263 |
+
|
264 |
+
@staticmethod
|
265 |
+
def create_default_super_admin():
|
266 |
+
"""Tạo super admin mặc định nếu chưa có"""
|
267 |
+
try:
|
268 |
+
db = get_db()
|
269 |
+
admins_collection = db.admins
|
270 |
+
|
271 |
+
# Kiểm tra xem đã có super admin chưa
|
272 |
+
existing_super_admin = admins_collection.find_one({
|
273 |
+
"role": "super_admin",
|
274 |
+
"is_active": True
|
275 |
+
})
|
276 |
+
|
277 |
+
if not existing_super_admin:
|
278 |
+
# Tạo super admin mặc định
|
279 |
+
default_email = "[email protected]"
|
280 |
+
default_password = "NutribotAdmin2024!"
|
281 |
+
|
282 |
+
success, result = AdminUser.create_admin(
|
283 |
+
name="Super Admin",
|
284 |
+
email=default_email,
|
285 |
+
password=default_password,
|
286 |
+
role="super_admin"
|
287 |
+
)
|
288 |
+
|
289 |
+
if success:
|
290 |
+
logger.info(f"Đã tạo super admin mặc định: {default_email}")
|
291 |
+
logger.info(f"Mật khẩu mặc định: {default_password}")
|
292 |
+
return True, {
|
293 |
+
"email": default_email,
|
294 |
+
"password": default_password,
|
295 |
+
"admin_id": result["admin_id"]
|
296 |
+
}
|
297 |
+
else:
|
298 |
+
logger.error(f"Lỗi tạo super admin mặc định: {result}")
|
299 |
+
return False, result
|
300 |
+
else:
|
301 |
+
logger.info("Super admin đã tồn tại")
|
302 |
+
return True, {"message": "Super admin đã tồn tại"}
|
303 |
+
|
304 |
+
except Exception as e:
|
305 |
+
logger.error(f"Lỗi tạo super admin mặc định: {e}")
|
306 |
+
return False, str(e)
|
models/conversation_model.py
ADDED
@@ -0,0 +1,731 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import datetime
|
3 |
+
from bson.objectid import ObjectId
|
4 |
+
import logging
|
5 |
+
from pymongo import MongoClient, DESCENDING
|
6 |
+
from dotenv import load_dotenv
|
7 |
+
import copy
|
8 |
+
|
9 |
+
# Cấu hình logging
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
# Tải biến môi trường
|
13 |
+
load_dotenv()
|
14 |
+
|
15 |
+
# Kết nối MongoDB
|
16 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
|
17 |
+
DATABASE_NAME = os.getenv("MONGO_DB_NAME", "nutribot_db")
|
18 |
+
|
19 |
+
# Singleton pattern cho kết nối MongoDB
|
20 |
+
_mongo_client = None
|
21 |
+
_db = None
|
22 |
+
|
23 |
+
def get_db():
|
24 |
+
"""Trả về instance của MongoDB database (singleton pattern)"""
|
25 |
+
global _mongo_client, _db
|
26 |
+
if _mongo_client is None:
|
27 |
+
try:
|
28 |
+
_mongo_client = MongoClient(MONGO_URI)
|
29 |
+
_db = _mongo_client[DATABASE_NAME]
|
30 |
+
logger.info(f"Đã kết nối đến database: {DATABASE_NAME}")
|
31 |
+
except Exception as e:
|
32 |
+
logger.error(f"Lỗi kết nối MongoDB: {e}")
|
33 |
+
raise
|
34 |
+
return _db
|
35 |
+
|
36 |
+
def safe_isoformat(timestamp_obj):
|
37 |
+
"""Safely convert timestamp to isoformat string"""
|
38 |
+
if timestamp_obj is None:
|
39 |
+
return None
|
40 |
+
|
41 |
+
if isinstance(timestamp_obj, str):
|
42 |
+
return timestamp_obj
|
43 |
+
|
44 |
+
if hasattr(timestamp_obj, 'isoformat'):
|
45 |
+
return timestamp_obj.isoformat()
|
46 |
+
|
47 |
+
try:
|
48 |
+
return str(timestamp_obj)
|
49 |
+
except:
|
50 |
+
return None
|
51 |
+
|
52 |
+
def safe_datetime(timestamp_obj):
|
53 |
+
"""Safely convert various timestamp formats to datetime object"""
|
54 |
+
if timestamp_obj is None:
|
55 |
+
return datetime.datetime.now()
|
56 |
+
|
57 |
+
if isinstance(timestamp_obj, datetime.datetime):
|
58 |
+
return timestamp_obj
|
59 |
+
|
60 |
+
if isinstance(timestamp_obj, str):
|
61 |
+
try:
|
62 |
+
return datetime.datetime.fromisoformat(timestamp_obj.replace('Z', '+00:00'))
|
63 |
+
except:
|
64 |
+
return datetime.datetime.now()
|
65 |
+
|
66 |
+
return datetime.datetime.now()
|
67 |
+
|
68 |
+
class Conversation:
|
69 |
+
def __init__(self, user_id=None, title="Cuộc trò chuyện mới", age_context=None,
|
70 |
+
created_at=None, updated_at=None, is_archived=False, messages=None,
|
71 |
+
conversation_id=None):
|
72 |
+
self.conversation_id = conversation_id
|
73 |
+
self.user_id = user_id
|
74 |
+
self.title = title
|
75 |
+
self.age_context = age_context
|
76 |
+
self.created_at = safe_datetime(created_at)
|
77 |
+
self.updated_at = safe_datetime(updated_at)
|
78 |
+
self.is_archived = is_archived
|
79 |
+
self.messages = messages or []
|
80 |
+
|
81 |
+
@classmethod
|
82 |
+
def create(cls, user_id, title="Cuộc trò chuyện mới", age_context=None):
|
83 |
+
"""Tạo và lưu conversation mới vào database"""
|
84 |
+
try:
|
85 |
+
if isinstance(user_id, str):
|
86 |
+
user_id = ObjectId(user_id)
|
87 |
+
|
88 |
+
conversation = cls(
|
89 |
+
user_id=user_id,
|
90 |
+
title=title,
|
91 |
+
age_context=age_context
|
92 |
+
)
|
93 |
+
|
94 |
+
conversation_id = conversation.save()
|
95 |
+
conversation.conversation_id = conversation_id
|
96 |
+
|
97 |
+
logger.info(f"Created new conversation: {conversation_id}")
|
98 |
+
return conversation_id
|
99 |
+
|
100 |
+
except Exception as e:
|
101 |
+
logger.error(f"Error creating conversation: {e}")
|
102 |
+
raise
|
103 |
+
|
104 |
+
def serialize_message_for_following(self, message):
|
105 |
+
"""Serialize message để lưu vào following_messages"""
|
106 |
+
serialized = {
|
107 |
+
"role": message["role"],
|
108 |
+
"content": message["content"],
|
109 |
+
"timestamp": safe_datetime(message.get("timestamp")),
|
110 |
+
"current_version": message.get("current_version", 1),
|
111 |
+
"is_edited": message.get("is_edited", False)
|
112 |
+
}
|
113 |
+
|
114 |
+
if "sources" in message:
|
115 |
+
serialized["sources"] = message["sources"]
|
116 |
+
if "metadata" in message:
|
117 |
+
serialized["metadata"] = message["metadata"]
|
118 |
+
|
119 |
+
# Serialize versions
|
120 |
+
if "versions" in message and message["versions"]:
|
121 |
+
serialized["versions"] = []
|
122 |
+
for version in message["versions"]:
|
123 |
+
version_data = {
|
124 |
+
"version": version["version"],
|
125 |
+
"content": version["content"],
|
126 |
+
"timestamp": safe_datetime(version.get("timestamp")),
|
127 |
+
"following_messages": version.get("following_messages", [])
|
128 |
+
}
|
129 |
+
if "sources" in version:
|
130 |
+
version_data["sources"] = version["sources"]
|
131 |
+
if "metadata" in version:
|
132 |
+
version_data["metadata"] = version["metadata"]
|
133 |
+
serialized["versions"].append(version_data)
|
134 |
+
|
135 |
+
return serialized
|
136 |
+
|
137 |
+
def deserialize_message_from_following(self, serialized_message):
|
138 |
+
"""Deserialize message từ following_messages"""
|
139 |
+
message = {
|
140 |
+
"_id": ObjectId(),
|
141 |
+
"role": serialized_message["role"],
|
142 |
+
"content": serialized_message["content"],
|
143 |
+
"timestamp": safe_datetime(serialized_message.get("timestamp")),
|
144 |
+
"current_version": serialized_message.get("current_version", 1),
|
145 |
+
"is_edited": serialized_message.get("is_edited", False)
|
146 |
+
}
|
147 |
+
|
148 |
+
if "sources" in serialized_message:
|
149 |
+
message["sources"] = serialized_message["sources"]
|
150 |
+
if "metadata" in serialized_message:
|
151 |
+
message["metadata"] = serialized_message["metadata"]
|
152 |
+
|
153 |
+
# Deserialize versions
|
154 |
+
if "versions" in serialized_message and serialized_message["versions"]:
|
155 |
+
message["versions"] = []
|
156 |
+
for version_data in serialized_message["versions"]:
|
157 |
+
version = {
|
158 |
+
"version": version_data["version"],
|
159 |
+
"content": version_data["content"],
|
160 |
+
"timestamp": safe_datetime(version_data.get("timestamp")),
|
161 |
+
"following_messages": version_data.get("following_messages", [])
|
162 |
+
}
|
163 |
+
if "sources" in version_data:
|
164 |
+
version["sources"] = version_data["sources"]
|
165 |
+
if "metadata" in version_data:
|
166 |
+
version["metadata"] = version_data["metadata"]
|
167 |
+
message["versions"].append(version)
|
168 |
+
else:
|
169 |
+
# Tạo version mặc định
|
170 |
+
message["versions"] = [{
|
171 |
+
"version": 1,
|
172 |
+
"content": message["content"],
|
173 |
+
"timestamp": message["timestamp"],
|
174 |
+
"following_messages": []
|
175 |
+
}]
|
176 |
+
if "sources" in message:
|
177 |
+
message["versions"][0]["sources"] = message["sources"]
|
178 |
+
if "metadata" in message:
|
179 |
+
message["versions"][0]["metadata"] = message["metadata"]
|
180 |
+
|
181 |
+
return message
|
182 |
+
|
183 |
+
def capture_following_messages(self, from_message_index):
|
184 |
+
"""Capture tất cả messages sau from_message_index"""
|
185 |
+
following_messages = []
|
186 |
+
|
187 |
+
for i in range(from_message_index + 1, len(self.messages)):
|
188 |
+
message = self.messages[i]
|
189 |
+
serialized = self.serialize_message_for_following(message)
|
190 |
+
following_messages.append(serialized)
|
191 |
+
|
192 |
+
logger.info(f"Captured {len(following_messages)} following messages from index {from_message_index}")
|
193 |
+
return following_messages
|
194 |
+
|
195 |
+
def restore_following_messages(self, target_message_index, following_messages):
|
196 |
+
"""Restore following messages sau target_message_index"""
|
197 |
+
# Cắt conversation tại target message
|
198 |
+
self.messages = self.messages[:target_message_index + 1]
|
199 |
+
|
200 |
+
# Restore messages
|
201 |
+
restored_count = 0
|
202 |
+
for serialized_message in following_messages:
|
203 |
+
message = self.deserialize_message_from_following(serialized_message)
|
204 |
+
self.messages.append(message)
|
205 |
+
restored_count += 1
|
206 |
+
|
207 |
+
logger.info(f"Restored {restored_count} following messages after index {target_message_index}")
|
208 |
+
return restored_count
|
209 |
+
|
210 |
+
def to_dict(self):
|
211 |
+
"""Convert conversation object sang dictionary cho JSON serialization"""
|
212 |
+
try:
|
213 |
+
result = {
|
214 |
+
"id": str(self.conversation_id),
|
215 |
+
"user_id": str(self.user_id),
|
216 |
+
"title": self.title,
|
217 |
+
"age_context": self.age_context,
|
218 |
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
219 |
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
220 |
+
"is_archived": self.is_archived,
|
221 |
+
"messages": []
|
222 |
+
}
|
223 |
+
|
224 |
+
for message in self.messages:
|
225 |
+
timestamp_str = None
|
226 |
+
if "timestamp" in message:
|
227 |
+
if hasattr(message["timestamp"], 'isoformat'):
|
228 |
+
timestamp_str = message["timestamp"].isoformat()
|
229 |
+
else:
|
230 |
+
timestamp_str = str(message["timestamp"])
|
231 |
+
|
232 |
+
message_data = {
|
233 |
+
"id": str(message["_id"]),
|
234 |
+
"_id": str(message["_id"]),
|
235 |
+
"role": message["role"],
|
236 |
+
"content": message["content"],
|
237 |
+
"timestamp": timestamp_str,
|
238 |
+
"current_version": message.get("current_version", 1),
|
239 |
+
"is_edited": message.get("is_edited", False)
|
240 |
+
}
|
241 |
+
|
242 |
+
if "versions" in message and message["versions"]:
|
243 |
+
message_data["versions"] = []
|
244 |
+
for version in message["versions"]:
|
245 |
+
version_timestamp = None
|
246 |
+
if "timestamp" in version:
|
247 |
+
if hasattr(version["timestamp"], 'isoformat'):
|
248 |
+
version_timestamp = version["timestamp"].isoformat()
|
249 |
+
else:
|
250 |
+
version_timestamp = str(version["timestamp"])
|
251 |
+
|
252 |
+
version_data = {
|
253 |
+
"content": version["content"],
|
254 |
+
"timestamp": version_timestamp,
|
255 |
+
"version": version["version"]
|
256 |
+
}
|
257 |
+
if "sources" in version:
|
258 |
+
version_data["sources"] = version["sources"]
|
259 |
+
message_data["versions"].append(version_data)
|
260 |
+
|
261 |
+
if "sources" in message:
|
262 |
+
message_data["sources"] = message["sources"]
|
263 |
+
|
264 |
+
result["messages"].append(message_data)
|
265 |
+
|
266 |
+
return result
|
267 |
+
except Exception as e:
|
268 |
+
logger.error(f"Lỗi khi convert conversation to dict: {e}")
|
269 |
+
return None
|
270 |
+
|
271 |
+
@classmethod
|
272 |
+
def from_dict(cls, conversation_dict):
|
273 |
+
"""Tạo đối tượng Conversation từ dictionary"""
|
274 |
+
if not conversation_dict:
|
275 |
+
return None
|
276 |
+
|
277 |
+
return cls(
|
278 |
+
conversation_id=conversation_dict.get("_id"),
|
279 |
+
user_id=conversation_dict.get("user_id"),
|
280 |
+
title=conversation_dict.get("title"),
|
281 |
+
age_context=conversation_dict.get("age_context"),
|
282 |
+
created_at=conversation_dict.get("created_at"),
|
283 |
+
updated_at=conversation_dict.get("updated_at"),
|
284 |
+
is_archived=conversation_dict.get("is_archived", False),
|
285 |
+
messages=conversation_dict.get("messages", [])
|
286 |
+
)
|
287 |
+
|
288 |
+
def save(self):
|
289 |
+
"""Lưu thông tin cuộc hội thoại vào database"""
|
290 |
+
try:
|
291 |
+
db = get_db()
|
292 |
+
conversations_collection = db.conversations
|
293 |
+
|
294 |
+
self.updated_at = datetime.datetime.now()
|
295 |
+
|
296 |
+
save_dict = {
|
297 |
+
"user_id": self.user_id,
|
298 |
+
"title": self.title,
|
299 |
+
"age_context": self.age_context,
|
300 |
+
"created_at": self.created_at,
|
301 |
+
"updated_at": self.updated_at,
|
302 |
+
"is_archived": self.is_archived,
|
303 |
+
"messages": []
|
304 |
+
}
|
305 |
+
|
306 |
+
for message in self.messages:
|
307 |
+
message_copy = message.copy()
|
308 |
+
message_copy["timestamp"] = safe_datetime(message_copy.get("timestamp"))
|
309 |
+
|
310 |
+
if "versions" in message_copy:
|
311 |
+
for version in message_copy["versions"]:
|
312 |
+
version["timestamp"] = safe_datetime(version.get("timestamp"))
|
313 |
+
|
314 |
+
save_dict["messages"].append(message_copy)
|
315 |
+
|
316 |
+
if not self.conversation_id:
|
317 |
+
insert_result = conversations_collection.insert_one(save_dict)
|
318 |
+
self.conversation_id = insert_result.inserted_id
|
319 |
+
logger.info(f"Saved new conversation with ID: {self.conversation_id}")
|
320 |
+
return self.conversation_id
|
321 |
+
else:
|
322 |
+
conversations_collection.update_one(
|
323 |
+
{"_id": self.conversation_id},
|
324 |
+
{"$set": save_dict}
|
325 |
+
)
|
326 |
+
logger.info(f"Updated conversation: {self.conversation_id}")
|
327 |
+
return self.conversation_id
|
328 |
+
except Exception as e:
|
329 |
+
logger.error(f"Error saving conversation: {e}")
|
330 |
+
raise
|
331 |
+
|
332 |
+
def add_message(self, role, content, sources=None, metadata=None, parent_message_id=None):
|
333 |
+
"""Thêm tin nhắn mới vào cuộc hội thoại"""
|
334 |
+
timestamp = datetime.datetime.now()
|
335 |
+
|
336 |
+
message = {
|
337 |
+
"_id": ObjectId(),
|
338 |
+
"role": role,
|
339 |
+
"content": content,
|
340 |
+
"timestamp": timestamp,
|
341 |
+
"versions": [{
|
342 |
+
"content": content,
|
343 |
+
"timestamp": timestamp,
|
344 |
+
"version": 1,
|
345 |
+
"following_messages": []
|
346 |
+
}],
|
347 |
+
"current_version": 1,
|
348 |
+
"parent_message_id": parent_message_id,
|
349 |
+
"is_edited": False
|
350 |
+
}
|
351 |
+
|
352 |
+
if sources:
|
353 |
+
message["sources"] = sources
|
354 |
+
message["versions"][0]["sources"] = sources
|
355 |
+
|
356 |
+
if metadata:
|
357 |
+
message["metadata"] = metadata
|
358 |
+
message["versions"][0]["metadata"] = metadata
|
359 |
+
|
360 |
+
self.messages.append(message)
|
361 |
+
self.updated_at = timestamp
|
362 |
+
self.save()
|
363 |
+
|
364 |
+
logger.info(f"Added message to conversation {self.conversation_id}")
|
365 |
+
return message["_id"]
|
366 |
+
|
367 |
+
def edit_message(self, message_id, new_content):
|
368 |
+
"""Chỉnh sửa tin nhắn và lưu following messages vào version hiện tại"""
|
369 |
+
try:
|
370 |
+
message_index = None
|
371 |
+
for i, message in enumerate(self.messages):
|
372 |
+
if str(message["_id"]) == str(message_id):
|
373 |
+
message_index = i
|
374 |
+
break
|
375 |
+
|
376 |
+
if message_index is None:
|
377 |
+
return False, "Không tìm thấy tin nhắn"
|
378 |
+
|
379 |
+
message = self.messages[message_index]
|
380 |
+
|
381 |
+
if message["role"] != "user":
|
382 |
+
return False, "Chỉ có thể chỉnh sửa tin nhắn của người dùng"
|
383 |
+
|
384 |
+
timestamp = datetime.datetime.now()
|
385 |
+
|
386 |
+
# Capture following messages cho version hiện tại
|
387 |
+
following_messages = self.capture_following_messages(message_index)
|
388 |
+
|
389 |
+
# Khởi tạo versions nếu chưa có
|
390 |
+
if "versions" not in message:
|
391 |
+
message["versions"] = [{
|
392 |
+
"content": message["content"],
|
393 |
+
"timestamp": safe_datetime(message.get("timestamp", timestamp)),
|
394 |
+
"version": 1,
|
395 |
+
"following_messages": following_messages
|
396 |
+
}]
|
397 |
+
if "sources" in message:
|
398 |
+
message["versions"][0]["sources"] = message["sources"]
|
399 |
+
if "metadata" in message:
|
400 |
+
message["versions"][0]["metadata"] = message["metadata"]
|
401 |
+
else:
|
402 |
+
# Cập nhật following_messages cho version hiện tại
|
403 |
+
current_version_index = message.get("current_version", 1) - 1
|
404 |
+
if current_version_index < len(message["versions"]):
|
405 |
+
message["versions"][current_version_index]["following_messages"] = following_messages
|
406 |
+
|
407 |
+
# Tạo version mới
|
408 |
+
new_version = len(message["versions"]) + 1
|
409 |
+
new_version_data = {
|
410 |
+
"content": new_content,
|
411 |
+
"timestamp": timestamp,
|
412 |
+
"version": new_version,
|
413 |
+
"following_messages": []
|
414 |
+
}
|
415 |
+
|
416 |
+
message["versions"].append(new_version_data)
|
417 |
+
message["current_version"] = new_version
|
418 |
+
message["content"] = new_content
|
419 |
+
message["is_edited"] = True
|
420 |
+
|
421 |
+
# Xóa tất cả messages sau message được edit
|
422 |
+
self.messages = self.messages[:message_index + 1]
|
423 |
+
|
424 |
+
self.updated_at = timestamp
|
425 |
+
self.save()
|
426 |
+
|
427 |
+
logger.info(f"Edited message {message_id}, created version {new_version} with {len(following_messages)} following messages")
|
428 |
+
return True, "Đã chỉnh sửa tin nhắn thành công"
|
429 |
+
|
430 |
+
except Exception as e:
|
431 |
+
logger.error(f"Error editing message: {e}")
|
432 |
+
return False, f"Lỗi: {str(e)}"
|
433 |
+
|
434 |
+
def regenerate_bot_response_after_edit(self, user_message_id, new_response, sources=None):
|
435 |
+
"""Thêm phản hồi bot mới sau khi edit tin nhắn user"""
|
436 |
+
try:
|
437 |
+
user_message_index = None
|
438 |
+
for i, message in enumerate(self.messages):
|
439 |
+
if str(message["_id"]) == str(user_message_id):
|
440 |
+
user_message_index = i
|
441 |
+
break
|
442 |
+
|
443 |
+
if user_message_index is None:
|
444 |
+
return False, "Không tìm thấy tin nhắn user"
|
445 |
+
|
446 |
+
timestamp = datetime.datetime.now()
|
447 |
+
bot_message = {
|
448 |
+
"_id": ObjectId(),
|
449 |
+
"role": "bot",
|
450 |
+
"content": new_response,
|
451 |
+
"timestamp": timestamp,
|
452 |
+
"versions": [{
|
453 |
+
"content": new_response,
|
454 |
+
"timestamp": timestamp,
|
455 |
+
"version": 1,
|
456 |
+
"following_messages": []
|
457 |
+
}],
|
458 |
+
"current_version": 1,
|
459 |
+
"parent_message_id": self.messages[user_message_index]["_id"],
|
460 |
+
"is_edited": False
|
461 |
+
}
|
462 |
+
|
463 |
+
if sources:
|
464 |
+
bot_message["sources"] = sources
|
465 |
+
bot_message["versions"][0]["sources"] = sources
|
466 |
+
|
467 |
+
self.messages.append(bot_message)
|
468 |
+
|
469 |
+
# Cập nhật following_messages cho version hiện tại của user message
|
470 |
+
user_message = self.messages[user_message_index]
|
471 |
+
if "versions" in user_message:
|
472 |
+
current_version_index = user_message.get("current_version", 1) - 1
|
473 |
+
if current_version_index < len(user_message["versions"]):
|
474 |
+
# Capture lại following messages bao gồm bot response mới
|
475 |
+
following_messages = self.capture_following_messages(user_message_index)
|
476 |
+
user_message["versions"][current_version_index]["following_messages"] = following_messages
|
477 |
+
|
478 |
+
self.updated_at = timestamp
|
479 |
+
self.save()
|
480 |
+
|
481 |
+
logger.info(f"Added bot response after edit for user message {user_message_id}")
|
482 |
+
return True, "Đã tạo phản hồi mới"
|
483 |
+
|
484 |
+
except Exception as e:
|
485 |
+
logger.error(f"Error adding bot response: {e}")
|
486 |
+
return False, f"Lỗi: {str(e)}"
|
487 |
+
|
488 |
+
def regenerate_response(self, message_id, new_response, sources=None):
|
489 |
+
"""Tạo version mới cho phản hồi bot"""
|
490 |
+
try:
|
491 |
+
message_index = None
|
492 |
+
for i, message in enumerate(self.messages):
|
493 |
+
if str(message["_id"]) == str(message_id):
|
494 |
+
message_index = i
|
495 |
+
break
|
496 |
+
|
497 |
+
if message_index is None:
|
498 |
+
return False, "Không tìm thấy tin nhắn"
|
499 |
+
|
500 |
+
message = self.messages[message_index]
|
501 |
+
|
502 |
+
if message["role"] != "bot":
|
503 |
+
return False, "Chỉ có thể regenerate phản hồi của bot"
|
504 |
+
|
505 |
+
timestamp = datetime.datetime.now()
|
506 |
+
|
507 |
+
# Capture following messages cho version hiện tại
|
508 |
+
following_messages = self.capture_following_messages(message_index)
|
509 |
+
|
510 |
+
# Khởi tạo versions nếu chưa có
|
511 |
+
if "versions" not in message:
|
512 |
+
message["versions"] = [{
|
513 |
+
"content": message["content"],
|
514 |
+
"timestamp": safe_datetime(message.get("timestamp", timestamp)),
|
515 |
+
"version": 1,
|
516 |
+
"sources": message.get("sources", []),
|
517 |
+
"following_messages": following_messages
|
518 |
+
}]
|
519 |
+
if "metadata" in message:
|
520 |
+
message["versions"][0]["metadata"] = message["metadata"]
|
521 |
+
else:
|
522 |
+
# Cập nhật following_messages cho version hiện tại
|
523 |
+
current_version_index = message.get("current_version", 1) - 1
|
524 |
+
if current_version_index < len(message["versions"]):
|
525 |
+
message["versions"][current_version_index]["following_messages"] = following_messages
|
526 |
+
|
527 |
+
# Tạo version mới
|
528 |
+
new_version = len(message["versions"]) + 1
|
529 |
+
new_version_data = {
|
530 |
+
"content": new_response,
|
531 |
+
"timestamp": timestamp,
|
532 |
+
"version": new_version,
|
533 |
+
"following_messages": []
|
534 |
+
}
|
535 |
+
|
536 |
+
if sources:
|
537 |
+
new_version_data["sources"] = sources
|
538 |
+
|
539 |
+
message["versions"].append(new_version_data)
|
540 |
+
message["current_version"] = new_version
|
541 |
+
message["content"] = new_response
|
542 |
+
message["is_edited"] = True
|
543 |
+
|
544 |
+
if sources:
|
545 |
+
message["sources"] = sources
|
546 |
+
|
547 |
+
# Xóa tất cả messages sau regenerate message
|
548 |
+
self.messages = self.messages[:message_index + 1]
|
549 |
+
|
550 |
+
self.updated_at = datetime.datetime.now()
|
551 |
+
self.save()
|
552 |
+
|
553 |
+
logger.info(f"Regenerated response for message {message_id}, version {new_version} with {len(following_messages)} following messages")
|
554 |
+
return True, "Đã tạo phản hồi mới thành công"
|
555 |
+
|
556 |
+
except Exception as e:
|
557 |
+
logger.error(f"Error regenerating response: {e}")
|
558 |
+
return False, f"Lỗi: {str(e)}"
|
559 |
+
|
560 |
+
def switch_message_version(self, message_id, version_number):
|
561 |
+
"""Chuyển đổi version của tin nhắn và restore following messages"""
|
562 |
+
try:
|
563 |
+
message_index = None
|
564 |
+
for i, message in enumerate(self.messages):
|
565 |
+
if str(message["_id"]) == str(message_id):
|
566 |
+
message_index = i
|
567 |
+
break
|
568 |
+
|
569 |
+
if message_index is None:
|
570 |
+
logger.error(f"Message not found: {message_id}")
|
571 |
+
return False
|
572 |
+
|
573 |
+
message = self.messages[message_index]
|
574 |
+
|
575 |
+
if not message.get("versions") or version_number > len(message["versions"]) or version_number < 1:
|
576 |
+
logger.error(f"Version {version_number} not found for message {message_id}")
|
577 |
+
return False
|
578 |
+
|
579 |
+
current_version = message.get("current_version", 1)
|
580 |
+
logger.info(f"Switching message {message_id} from version {current_version} to version {version_number}")
|
581 |
+
|
582 |
+
selected_version = message["versions"][version_number - 1]
|
583 |
+
|
584 |
+
# Update message content to selected version
|
585 |
+
message["current_version"] = version_number
|
586 |
+
message["content"] = selected_version["content"]
|
587 |
+
|
588 |
+
# Update sources and metadata
|
589 |
+
if "sources" in selected_version:
|
590 |
+
message["sources"] = selected_version["sources"]
|
591 |
+
elif "sources" in message:
|
592 |
+
del message["sources"]
|
593 |
+
|
594 |
+
if "metadata" in selected_version:
|
595 |
+
message["metadata"] = selected_version["metadata"]
|
596 |
+
elif "metadata" in message:
|
597 |
+
del message["metadata"]
|
598 |
+
|
599 |
+
# Restore following messages từ version được chọn
|
600 |
+
following_messages = selected_version.get("following_messages", [])
|
601 |
+
if following_messages:
|
602 |
+
restored_count = self.restore_following_messages(message_index, following_messages)
|
603 |
+
logger.info(f"Restored {restored_count} following messages from version {version_number}")
|
604 |
+
else:
|
605 |
+
# Nếu không có following messages, chỉ cắt conversation tại message này
|
606 |
+
self.messages = self.messages[:message_index + 1]
|
607 |
+
logger.info(f"No following messages in version {version_number}, truncated conversation at message index {message_index}")
|
608 |
+
|
609 |
+
self.updated_at = datetime.datetime.now()
|
610 |
+
self.save()
|
611 |
+
|
612 |
+
logger.info(f"Successfully switched to version {version_number} for message {message_id}")
|
613 |
+
return True
|
614 |
+
|
615 |
+
except Exception as e:
|
616 |
+
logger.error(f"Error switching message version: {e}")
|
617 |
+
return False
|
618 |
+
|
619 |
+
def delete_message_and_following(self, message_id):
|
620 |
+
"""Xóa tin nhắn và tất cả tin nhắn sau nó"""
|
621 |
+
try:
|
622 |
+
message_index = None
|
623 |
+
for i, message in enumerate(self.messages):
|
624 |
+
if str(message["_id"]) == str(message_id):
|
625 |
+
message_index = i
|
626 |
+
break
|
627 |
+
|
628 |
+
if message_index is None:
|
629 |
+
return False, "Không tìm thấy tin nhắn"
|
630 |
+
|
631 |
+
self.messages = self.messages[:message_index]
|
632 |
+
self.updated_at = datetime.datetime.now()
|
633 |
+
self.save()
|
634 |
+
|
635 |
+
return True, "Đã xóa tin nhắn và các tin nhắn sau nó"
|
636 |
+
|
637 |
+
except Exception as e:
|
638 |
+
logger.error(f"Error deleting message: {e}")
|
639 |
+
return False, f"Lỗi: {str(e)}"
|
640 |
+
|
641 |
+
def delete(self):
|
642 |
+
"""Xóa cuộc hội thoại từ database"""
|
643 |
+
try:
|
644 |
+
if self.conversation_id:
|
645 |
+
db = get_db()
|
646 |
+
conversations_collection = db.conversations
|
647 |
+
conversations_collection.delete_one({"_id": self.conversation_id})
|
648 |
+
logger.info(f"Đã xóa cuộc hội thoại: {self.conversation_id}")
|
649 |
+
return True
|
650 |
+
return False
|
651 |
+
except Exception as e:
|
652 |
+
logger.error(f"Lỗi khi xóa cuộc hội thoại: {e}")
|
653 |
+
return False
|
654 |
+
|
655 |
+
@classmethod
|
656 |
+
def find_by_id(cls, conversation_id):
|
657 |
+
"""Tìm cuộc hội thoại theo ID"""
|
658 |
+
try:
|
659 |
+
db = get_db()
|
660 |
+
conversations_collection = db.conversations
|
661 |
+
|
662 |
+
if isinstance(conversation_id, str):
|
663 |
+
conversation_id = ObjectId(conversation_id)
|
664 |
+
|
665 |
+
conversation_dict = conversations_collection.find_one({"_id": conversation_id})
|
666 |
+
|
667 |
+
if conversation_dict:
|
668 |
+
return cls.from_dict(conversation_dict)
|
669 |
+
return None
|
670 |
+
except Exception as e:
|
671 |
+
logger.error(f"Lỗi khi tìm cuộc hội thoại: {e}")
|
672 |
+
return None
|
673 |
+
|
674 |
+
@classmethod
|
675 |
+
def find_by_user(cls, user_id, limit=50, skip=0, include_archived=False):
|
676 |
+
"""Tìm cuộc hội thoại theo user_id"""
|
677 |
+
try:
|
678 |
+
db = get_db()
|
679 |
+
conversations_collection = db.conversations
|
680 |
+
|
681 |
+
if isinstance(user_id, str):
|
682 |
+
user_id = ObjectId(user_id)
|
683 |
+
|
684 |
+
query_filter = {"user_id": user_id}
|
685 |
+
if not include_archived:
|
686 |
+
query_filter["is_archived"] = {"$ne": True}
|
687 |
+
|
688 |
+
logger.info(f"Querying conversations with filter: {query_filter}, limit: {limit}, skip: {skip}")
|
689 |
+
|
690 |
+
conversations_cursor = conversations_collection.find(query_filter)\
|
691 |
+
.sort("updated_at", DESCENDING)\
|
692 |
+
.skip(skip)\
|
693 |
+
.limit(limit)
|
694 |
+
|
695 |
+
conversations_list = list(conversations_cursor)
|
696 |
+
logger.info(f"Found {len(conversations_list)} conversations for user {user_id}")
|
697 |
+
|
698 |
+
result = []
|
699 |
+
for conv_dict in conversations_list:
|
700 |
+
conv_obj = cls.from_dict(conv_dict)
|
701 |
+
if conv_obj:
|
702 |
+
result.append(conv_obj)
|
703 |
+
|
704 |
+
logger.info(f"Returning {len(result)} conversation objects")
|
705 |
+
return result
|
706 |
+
|
707 |
+
except Exception as e:
|
708 |
+
logger.error(f"Error finding conversations by user: {e}")
|
709 |
+
return []
|
710 |
+
|
711 |
+
@classmethod
|
712 |
+
def count_by_user(cls, user_id, include_archived=False):
|
713 |
+
"""Đếm số cuộc hội thoại của user"""
|
714 |
+
try:
|
715 |
+
db = get_db()
|
716 |
+
conversations_collection = db.conversations
|
717 |
+
|
718 |
+
if isinstance(user_id, str):
|
719 |
+
user_id = ObjectId(user_id)
|
720 |
+
|
721 |
+
query_filter = {"user_id": user_id}
|
722 |
+
if not include_archived:
|
723 |
+
query_filter["is_archived"] = {"$ne": True}
|
724 |
+
|
725 |
+
count = conversations_collection.count_documents(query_filter)
|
726 |
+
logger.info(f"User {user_id} has {count} conversations (include_archived: {include_archived})")
|
727 |
+
|
728 |
+
return count
|
729 |
+
except Exception as e:
|
730 |
+
logger.error(f"Error counting conversations: {e}")
|
731 |
+
return 0
|
models/feedback_model.py
ADDED
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import datetime
|
3 |
+
from pymongo import MongoClient
|
4 |
+
from bson.objectid import ObjectId
|
5 |
+
import logging
|
6 |
+
from dotenv import load_dotenv
|
7 |
+
|
8 |
+
# Cấu hình logging
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
# Tải biến môi trường
|
12 |
+
load_dotenv()
|
13 |
+
|
14 |
+
# Kết nối MongoDB
|
15 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
|
16 |
+
DATABASE_NAME = os.getenv("MONGO_DB_NAME", "nutribot_db")
|
17 |
+
|
18 |
+
# Singleton pattern cho kết nối MongoDB
|
19 |
+
_mongo_client = None
|
20 |
+
_db = None
|
21 |
+
|
22 |
+
def get_db():
|
23 |
+
"""Trả về instance của MongoDB database (singleton pattern)"""
|
24 |
+
global _mongo_client, _db
|
25 |
+
if _mongo_client is None:
|
26 |
+
try:
|
27 |
+
_mongo_client = MongoClient(MONGO_URI)
|
28 |
+
_db = _mongo_client[DATABASE_NAME]
|
29 |
+
logger.info(f"Đã kết nối đến database: {DATABASE_NAME}")
|
30 |
+
except Exception as e:
|
31 |
+
logger.error(f"Lỗi kết nối MongoDB: {e}")
|
32 |
+
raise
|
33 |
+
return _db
|
34 |
+
|
35 |
+
def ensure_indexes():
|
36 |
+
"""Tạo các index cần thiết cho feedback collection"""
|
37 |
+
try:
|
38 |
+
db = get_db()
|
39 |
+
feedback_collection = db.feedback
|
40 |
+
|
41 |
+
# Index cho user_id
|
42 |
+
feedback_collection.create_index("user_id")
|
43 |
+
|
44 |
+
# Index cho status
|
45 |
+
feedback_collection.create_index("status")
|
46 |
+
|
47 |
+
# Index cho category
|
48 |
+
feedback_collection.create_index("category")
|
49 |
+
|
50 |
+
# Index cho created_at (để sắp xếp)
|
51 |
+
feedback_collection.create_index([("created_at", -1)])
|
52 |
+
|
53 |
+
logger.info("Đã tạo indexes cho feedback collection")
|
54 |
+
|
55 |
+
except Exception as e:
|
56 |
+
logger.error(f"Lỗi tạo indexes: {e}")
|
57 |
+
|
58 |
+
class Feedback:
|
59 |
+
def __init__(self, user_id=None, rating=5, category='', title='', content='',
|
60 |
+
status='pending', admin_response='', created_at=None, updated_at=None,
|
61 |
+
feedback_id=None):
|
62 |
+
self.feedback_id = feedback_id
|
63 |
+
self.user_id = user_id
|
64 |
+
self.rating = rating
|
65 |
+
self.category = category
|
66 |
+
self.title = title
|
67 |
+
self.content = content
|
68 |
+
self.status = status # pending, reviewed, resolved
|
69 |
+
self.admin_response = admin_response
|
70 |
+
self.created_at = created_at or datetime.datetime.now()
|
71 |
+
self.updated_at = updated_at or datetime.datetime.now()
|
72 |
+
|
73 |
+
def to_dict(self):
|
74 |
+
"""Chuyển đổi thông tin feedback thành dictionary"""
|
75 |
+
feedback_dict = {
|
76 |
+
"user_id": self.user_id,
|
77 |
+
"rating": self.rating,
|
78 |
+
"category": self.category,
|
79 |
+
"title": self.title,
|
80 |
+
"content": self.content,
|
81 |
+
"status": self.status,
|
82 |
+
"admin_response": self.admin_response,
|
83 |
+
"created_at": self.created_at,
|
84 |
+
"updated_at": self.updated_at
|
85 |
+
}
|
86 |
+
if self.feedback_id:
|
87 |
+
feedback_dict["_id"] = self.feedback_id
|
88 |
+
return feedback_dict
|
89 |
+
|
90 |
+
@classmethod
|
91 |
+
def from_dict(cls, feedback_dict):
|
92 |
+
"""Tạo đối tượng Feedback từ dictionary"""
|
93 |
+
if not feedback_dict:
|
94 |
+
return None
|
95 |
+
|
96 |
+
return cls(
|
97 |
+
feedback_id=feedback_dict.get("_id"),
|
98 |
+
user_id=feedback_dict.get("user_id"),
|
99 |
+
rating=feedback_dict.get("rating", 5),
|
100 |
+
category=feedback_dict.get("category", ""),
|
101 |
+
title=feedback_dict.get("title", ""),
|
102 |
+
content=feedback_dict.get("content", ""),
|
103 |
+
status=feedback_dict.get("status", "pending"),
|
104 |
+
admin_response=feedback_dict.get("admin_response", ""),
|
105 |
+
created_at=feedback_dict.get("created_at"),
|
106 |
+
updated_at=feedback_dict.get("updated_at")
|
107 |
+
)
|
108 |
+
|
109 |
+
def save(self):
|
110 |
+
"""Lưu thông tin feedback vào database"""
|
111 |
+
try:
|
112 |
+
db = get_db()
|
113 |
+
feedback_collection = db.feedback
|
114 |
+
|
115 |
+
self.updated_at = datetime.datetime.now()
|
116 |
+
|
117 |
+
if not self.feedback_id:
|
118 |
+
insert_result = feedback_collection.insert_one(self.to_dict())
|
119 |
+
self.feedback_id = insert_result.inserted_id
|
120 |
+
logger.info(f"Đã tạo feedback mới với ID: {self.feedback_id}")
|
121 |
+
return self.feedback_id
|
122 |
+
else:
|
123 |
+
feedback_collection.update_one(
|
124 |
+
{"_id": self.feedback_id},
|
125 |
+
{"$set": self.to_dict()}
|
126 |
+
)
|
127 |
+
logger.info(f"Đã cập nhật feedback: {self.feedback_id}")
|
128 |
+
return self.feedback_id
|
129 |
+
except Exception as e:
|
130 |
+
logger.error(f"Lỗi khi lưu feedback: {e}")
|
131 |
+
raise
|
132 |
+
|
133 |
+
@classmethod
|
134 |
+
def find_by_id(cls, feedback_id):
|
135 |
+
"""Tìm feedback theo ID"""
|
136 |
+
try:
|
137 |
+
db = get_db()
|
138 |
+
feedback_collection = db.feedback
|
139 |
+
|
140 |
+
if isinstance(feedback_id, str):
|
141 |
+
feedback_id = ObjectId(feedback_id)
|
142 |
+
|
143 |
+
feedback_dict = feedback_collection.find_one({"_id": feedback_id})
|
144 |
+
|
145 |
+
if feedback_dict:
|
146 |
+
return cls.from_dict(feedback_dict)
|
147 |
+
return None
|
148 |
+
except Exception as e:
|
149 |
+
logger.error(f"Lỗi khi tìm feedback: {e}")
|
150 |
+
return None
|
151 |
+
|
152 |
+
@classmethod
|
153 |
+
def find_by_user(cls, user_id, limit=10, skip=0):
|
154 |
+
"""Tìm feedback theo user_id"""
|
155 |
+
try:
|
156 |
+
db = get_db()
|
157 |
+
feedback_collection = db.feedback
|
158 |
+
|
159 |
+
if isinstance(user_id, str):
|
160 |
+
user_id = ObjectId(user_id)
|
161 |
+
|
162 |
+
feedbacks_cursor = feedback_collection.find({"user_id": user_id})\
|
163 |
+
.sort("created_at", -1)\
|
164 |
+
.skip(skip)\
|
165 |
+
.limit(limit)
|
166 |
+
|
167 |
+
result = []
|
168 |
+
for feedback_dict in feedbacks_cursor:
|
169 |
+
feedback_obj = cls.from_dict(feedback_dict)
|
170 |
+
if feedback_obj:
|
171 |
+
result.append(feedback_obj)
|
172 |
+
|
173 |
+
return result
|
174 |
+
|
175 |
+
except Exception as e:
|
176 |
+
logger.error(f"Lỗi tìm feedback theo user: {e}")
|
177 |
+
return []
|
178 |
+
|
179 |
+
@classmethod
|
180 |
+
def get_all_for_admin(cls, limit=20, skip=0, status_filter=None):
|
181 |
+
"""Lấy tất cả feedback cho admin với join user info"""
|
182 |
+
try:
|
183 |
+
db = get_db()
|
184 |
+
feedback_collection = db.feedback
|
185 |
+
|
186 |
+
# Tạo query filter
|
187 |
+
query_filter = {}
|
188 |
+
if status_filter:
|
189 |
+
query_filter["status"] = status_filter
|
190 |
+
|
191 |
+
# Aggregate để join với user collection
|
192 |
+
pipeline = [
|
193 |
+
{"$match": query_filter},
|
194 |
+
{
|
195 |
+
"$lookup": {
|
196 |
+
"from": "users",
|
197 |
+
"localField": "user_id",
|
198 |
+
"foreignField": "_id",
|
199 |
+
"as": "user_info"
|
200 |
+
}
|
201 |
+
},
|
202 |
+
{"$sort": {"created_at": -1}},
|
203 |
+
{"$skip": skip},
|
204 |
+
{"$limit": limit}
|
205 |
+
]
|
206 |
+
|
207 |
+
result = []
|
208 |
+
for feedback_doc in feedback_collection.aggregate(pipeline):
|
209 |
+
feedback = cls.from_dict(feedback_doc)
|
210 |
+
|
211 |
+
# Thêm thông tin user nếu có
|
212 |
+
if feedback and feedback_doc.get("user_info"):
|
213 |
+
user_info = feedback_doc["user_info"][0]
|
214 |
+
feedback.user_name = user_info.get("name", "Ẩn danh")
|
215 |
+
feedback.user_email = user_info.get("email", "")
|
216 |
+
else:
|
217 |
+
feedback.user_name = "Ẩn danh"
|
218 |
+
feedback.user_email = ""
|
219 |
+
|
220 |
+
result.append(feedback)
|
221 |
+
|
222 |
+
return result
|
223 |
+
|
224 |
+
except Exception as e:
|
225 |
+
logger.error(f"Lỗi lấy feedback cho admin: {e}")
|
226 |
+
return []
|
227 |
+
|
228 |
+
def update_admin_response(self, response, status):
|
229 |
+
"""Cập nhật phản hồi từ admin"""
|
230 |
+
try:
|
231 |
+
self.admin_response = response
|
232 |
+
self.status = status
|
233 |
+
self.save()
|
234 |
+
return True
|
235 |
+
except Exception as e:
|
236 |
+
logger.error(f"Lỗi cập nhật admin response: {e}")
|
237 |
+
return False
|
238 |
+
|
239 |
+
@staticmethod
|
240 |
+
def get_stats():
|
241 |
+
"""Lấy thống kê về feedback"""
|
242 |
+
try:
|
243 |
+
db = get_db()
|
244 |
+
feedback_collection = db.feedback
|
245 |
+
|
246 |
+
# Tổng số feedback
|
247 |
+
total_feedback = feedback_collection.count_documents({})
|
248 |
+
|
249 |
+
# Feedback pending
|
250 |
+
pending_feedback = feedback_collection.count_documents({"status": "pending"})
|
251 |
+
|
252 |
+
# Feedback trong tháng này
|
253 |
+
now = datetime.datetime.now()
|
254 |
+
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
255 |
+
this_month_feedback = feedback_collection.count_documents({
|
256 |
+
"created_at": {"$gte": month_start}
|
257 |
+
})
|
258 |
+
|
259 |
+
# Đánh giá trung bình
|
260 |
+
pipeline = [
|
261 |
+
{"$group": {"_id": None, "avg_rating": {"$avg": "$rating"}}}
|
262 |
+
]
|
263 |
+
rating_result = list(feedback_collection.aggregate(pipeline))
|
264 |
+
average_rating = rating_result[0]["avg_rating"] if rating_result else 0
|
265 |
+
|
266 |
+
return {
|
267 |
+
"total_feedback": total_feedback,
|
268 |
+
"pending_feedback": pending_feedback,
|
269 |
+
"this_month_feedback": this_month_feedback,
|
270 |
+
"average_rating": average_rating
|
271 |
+
}
|
272 |
+
|
273 |
+
except Exception as e:
|
274 |
+
logger.error(f"Lỗi lấy thống kê feedback: {e}")
|
275 |
+
return {
|
276 |
+
"total_feedback": 0,
|
277 |
+
"pending_feedback": 0,
|
278 |
+
"this_month_feedback": 0,
|
279 |
+
"average_rating": 0
|
280 |
+
}
|
281 |
+
|
282 |
+
@staticmethod
|
283 |
+
def create_feedback(user_id, rating, category, title, content):
|
284 |
+
"""Tạo feedback mới"""
|
285 |
+
try:
|
286 |
+
feedback = Feedback(
|
287 |
+
user_id=ObjectId(user_id) if isinstance(user_id, str) else user_id,
|
288 |
+
rating=rating,
|
289 |
+
category=category,
|
290 |
+
title=title,
|
291 |
+
content=content
|
292 |
+
)
|
293 |
+
|
294 |
+
feedback_id = feedback.save()
|
295 |
+
return True, {"feedback_id": str(feedback_id)}
|
296 |
+
|
297 |
+
except Exception as e:
|
298 |
+
logger.error(f"Lỗi tạo feedback: {e}")
|
299 |
+
return False, str(e)
|
models/user_model.py
ADDED
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import datetime
|
3 |
+
from pymongo import MongoClient
|
4 |
+
from bson.objectid import ObjectId
|
5 |
+
import bcrypt
|
6 |
+
import logging
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
|
9 |
+
# Cấu hình logging
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
# Tải biến môi trường
|
13 |
+
load_dotenv()
|
14 |
+
|
15 |
+
# Kết nối MongoDB
|
16 |
+
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
|
17 |
+
DATABASE_NAME = os.getenv("MONGO_DB_NAME", "nutribot_db")
|
18 |
+
|
19 |
+
# Singleton pattern cho kết nối MongoDB
|
20 |
+
_mongo_client = None
|
21 |
+
_db = None
|
22 |
+
|
23 |
+
def get_db():
|
24 |
+
"""Trả về instance của MongoDB database (singleton pattern)"""
|
25 |
+
global _mongo_client, _db
|
26 |
+
if _mongo_client is None:
|
27 |
+
try:
|
28 |
+
_mongo_client = MongoClient(MONGO_URI)
|
29 |
+
_db = _mongo_client[DATABASE_NAME]
|
30 |
+
logger.info(f"Đã kết nối đến database: {DATABASE_NAME}")
|
31 |
+
except Exception as e:
|
32 |
+
logger.error(f"Lỗi kết nối MongoDB: {e}")
|
33 |
+
raise
|
34 |
+
return _db
|
35 |
+
|
36 |
+
class User:
|
37 |
+
def __init__(self, name, email, password, gender=None, role="user", permissions=None,
|
38 |
+
user_id=None, created_at=None, updated_at=None, last_login=None):
|
39 |
+
self.user_id = user_id
|
40 |
+
self.name = name
|
41 |
+
self.email = email
|
42 |
+
self.password = password # Mật khẩu đã mã hóa
|
43 |
+
self.gender = gender
|
44 |
+
self.role = role # "user" hoặc "admin"
|
45 |
+
self.permissions = permissions or self._get_default_permissions(role)
|
46 |
+
self.created_at = created_at or datetime.datetime.now()
|
47 |
+
self.updated_at = updated_at or datetime.datetime.now()
|
48 |
+
self.last_login = last_login
|
49 |
+
|
50 |
+
def _get_default_permissions(self, role):
|
51 |
+
"""Lấy quyền mặc định theo role"""
|
52 |
+
if role == "admin":
|
53 |
+
return {
|
54 |
+
"users": {"read": True, "write": True, "delete": True},
|
55 |
+
"documents": {"read": True, "write": True, "delete": True},
|
56 |
+
"conversations": {"read": True, "write": True, "delete": True},
|
57 |
+
"system": {"read": True, "write": True, "delete": False},
|
58 |
+
"analytics": {"read": True, "write": False, "delete": False}
|
59 |
+
}
|
60 |
+
else: # role == "user"
|
61 |
+
return {
|
62 |
+
"conversations": {"read": True, "write": True, "delete": True},
|
63 |
+
"profile": {"read": True, "write": True, "delete": False}
|
64 |
+
}
|
65 |
+
|
66 |
+
@staticmethod
|
67 |
+
def hash_password(password):
|
68 |
+
"""Mã hóa mật khẩu sử dụng bcrypt"""
|
69 |
+
salt = bcrypt.gensalt()
|
70 |
+
hashed_pw = bcrypt.hashpw(password.encode('utf-8'), salt)
|
71 |
+
return hashed_pw
|
72 |
+
|
73 |
+
@staticmethod
|
74 |
+
def check_password(hashed_password, password):
|
75 |
+
"""Kiểm tra mật khẩu"""
|
76 |
+
return bcrypt.checkpw(password.encode('utf-8'), hashed_password)
|
77 |
+
|
78 |
+
def is_admin(self):
|
79 |
+
"""Kiểm tra xem user có phải admin không"""
|
80 |
+
return self.role == "admin"
|
81 |
+
|
82 |
+
def has_permission(self, resource, action):
|
83 |
+
"""Kiểm tra quyền truy cập"""
|
84 |
+
if not self.permissions:
|
85 |
+
return False
|
86 |
+
|
87 |
+
resource_perms = self.permissions.get(resource, {})
|
88 |
+
return resource_perms.get(action, False)
|
89 |
+
|
90 |
+
def to_dict(self):
|
91 |
+
"""Chuyển đổi thông tin user thành dictionary"""
|
92 |
+
user_dict = {
|
93 |
+
"name": self.name,
|
94 |
+
"email": self.email,
|
95 |
+
"password": self.password,
|
96 |
+
"gender": self.gender,
|
97 |
+
"role": self.role,
|
98 |
+
"permissions": self.permissions,
|
99 |
+
"created_at": self.created_at,
|
100 |
+
"updated_at": self.updated_at,
|
101 |
+
"last_login": self.last_login
|
102 |
+
}
|
103 |
+
if self.user_id:
|
104 |
+
user_dict["_id"] = self.user_id
|
105 |
+
return user_dict
|
106 |
+
|
107 |
+
@classmethod
|
108 |
+
def from_dict(cls, user_dict):
|
109 |
+
"""Tạo đối tượng User từ dictionary"""
|
110 |
+
if not user_dict:
|
111 |
+
return None
|
112 |
+
|
113 |
+
return cls(
|
114 |
+
user_id=user_dict.get("_id"),
|
115 |
+
name=user_dict.get("name"),
|
116 |
+
email=user_dict.get("email"),
|
117 |
+
password=user_dict.get("password"),
|
118 |
+
gender=user_dict.get("gender"),
|
119 |
+
role=user_dict.get("role", "user"), # Mặc định là user
|
120 |
+
permissions=user_dict.get("permissions"),
|
121 |
+
created_at=user_dict.get("created_at"),
|
122 |
+
updated_at=user_dict.get("updated_at"),
|
123 |
+
last_login=user_dict.get("last_login")
|
124 |
+
)
|
125 |
+
|
126 |
+
@classmethod
|
127 |
+
def find_by_email(cls, email):
|
128 |
+
"""Tìm người dùng theo email"""
|
129 |
+
try:
|
130 |
+
db = get_db()
|
131 |
+
users_collection = db.users
|
132 |
+
user_data = users_collection.find_one({"email": email})
|
133 |
+
return cls.from_dict(user_data)
|
134 |
+
except Exception as e:
|
135 |
+
logger.error(f"Lỗi tìm người dùng theo email: {e}")
|
136 |
+
return None
|
137 |
+
|
138 |
+
@classmethod
|
139 |
+
def find_by_id(cls, user_id):
|
140 |
+
"""Tìm người dùng theo ID"""
|
141 |
+
try:
|
142 |
+
db = get_db()
|
143 |
+
users_collection = db.users
|
144 |
+
user_data = users_collection.find_one({"_id": ObjectId(user_id)})
|
145 |
+
return cls.from_dict(user_data)
|
146 |
+
except Exception as e:
|
147 |
+
logger.error(f"Lỗi tìm người dùng theo ID: {e}")
|
148 |
+
return None
|
149 |
+
|
150 |
+
@classmethod
|
151 |
+
def get_all_admins(cls, page=1, per_page=10):
|
152 |
+
"""Lấy danh sách tất cả admin với phân trang"""
|
153 |
+
try:
|
154 |
+
db = get_db()
|
155 |
+
users_collection = db.users
|
156 |
+
|
157 |
+
skip = (page - 1) * per_page
|
158 |
+
admins_data = users_collection.find(
|
159 |
+
{"role": "admin"}
|
160 |
+
).sort("created_at", -1).skip(skip).limit(per_page)
|
161 |
+
|
162 |
+
total = users_collection.count_documents({"role": "admin"})
|
163 |
+
|
164 |
+
return [cls.from_dict(admin) for admin in admins_data], total
|
165 |
+
except Exception as e:
|
166 |
+
logger.error(f"Lỗi lấy danh sách admin: {e}")
|
167 |
+
return [], 0
|
168 |
+
|
169 |
+
def save(self):
|
170 |
+
"""Lưu thông tin người dùng vào database"""
|
171 |
+
try:
|
172 |
+
db = get_db()
|
173 |
+
users_collection = db.users
|
174 |
+
|
175 |
+
if not self.user_id:
|
176 |
+
# Đây là người dùng mới
|
177 |
+
insert_result = users_collection.insert_one(self.to_dict())
|
178 |
+
self.user_id = insert_result.inserted_id
|
179 |
+
logger.info(f"Đã tạo người dùng mới với ID: {self.user_id}")
|
180 |
+
return self.user_id
|
181 |
+
else:
|
182 |
+
# Cập nhật người dùng đã tồn tại
|
183 |
+
self.updated_at = datetime.datetime.now()
|
184 |
+
users_collection.update_one(
|
185 |
+
{"_id": self.user_id},
|
186 |
+
{"$set": self.to_dict()}
|
187 |
+
)
|
188 |
+
logger.info(f"Đã cập nhật thông tin người dùng: {self.user_id}")
|
189 |
+
return self.user_id
|
190 |
+
except Exception as e:
|
191 |
+
logger.error(f"Lỗi khi lưu thông tin người dùng: {e}")
|
192 |
+
raise
|
193 |
+
|
194 |
+
def update_last_login(self):
|
195 |
+
"""Cập nhật thời gian đăng nhập cuối"""
|
196 |
+
try:
|
197 |
+
self.last_login = datetime.datetime.now()
|
198 |
+
self.save()
|
199 |
+
except Exception as e:
|
200 |
+
logger.error(f"Lỗi cập nhật last_login: {e}")
|
201 |
+
|
202 |
+
def delete(self):
|
203 |
+
"""Xóa người dùng từ database"""
|
204 |
+
try:
|
205 |
+
if self.user_id:
|
206 |
+
db = get_db()
|
207 |
+
users_collection = db.users
|
208 |
+
users_collection.delete_one({"_id": self.user_id})
|
209 |
+
logger.info(f"Đã xóa người dùng: {self.user_id}")
|
210 |
+
return True
|
211 |
+
return False
|
212 |
+
except Exception as e:
|
213 |
+
logger.error(f"Lỗi khi xóa người dùng: {e}")
|
214 |
+
return False
|
215 |
+
|
216 |
+
@staticmethod
|
217 |
+
def register(name, email, password, gender=None, role="user"):
|
218 |
+
"""Đăng ký người dùng mới"""
|
219 |
+
try:
|
220 |
+
# Kiểm tra email đã tồn tại chưa
|
221 |
+
existing_user = User.find_by_email(email)
|
222 |
+
if existing_user:
|
223 |
+
return False, "Email đã được sử dụng"
|
224 |
+
|
225 |
+
# Mã hóa mật khẩu
|
226 |
+
hashed_password = User.hash_password(password)
|
227 |
+
|
228 |
+
# Tạo người dùng mới
|
229 |
+
new_user = User(
|
230 |
+
name=name,
|
231 |
+
email=email,
|
232 |
+
password=hashed_password,
|
233 |
+
gender=gender,
|
234 |
+
role=role
|
235 |
+
)
|
236 |
+
|
237 |
+
# Lưu vào database
|
238 |
+
user_id = new_user.save()
|
239 |
+
|
240 |
+
return True, {"user_id": str(user_id)}
|
241 |
+
except Exception as e:
|
242 |
+
logger.error(f"Lỗi đăng ký người dùng: {e}")
|
243 |
+
return False, f"Lỗi đăng ký: {str(e)}"
|
244 |
+
|
245 |
+
@staticmethod
|
246 |
+
def login(email, password):
|
247 |
+
"""Đăng nhập người dùng"""
|
248 |
+
try:
|
249 |
+
if not email or not password:
|
250 |
+
return False, "Vui lòng nhập đầy đủ thông tin đăng nhập"
|
251 |
+
|
252 |
+
user = User.find_by_email(email)
|
253 |
+
if not user:
|
254 |
+
return False, "Email không tồn tại"
|
255 |
+
|
256 |
+
if not User.check_password(user.password, password):
|
257 |
+
return False, "Mật khẩu không chính xác"
|
258 |
+
|
259 |
+
# Cập nhật thời gian đăng nhập
|
260 |
+
user.update_last_login()
|
261 |
+
|
262 |
+
return True, user
|
263 |
+
except Exception as e:
|
264 |
+
logger.error(f"Lỗi đăng nhập: {e}")
|
265 |
+
return False, f"Lỗi đăng nhập: {str(e)}"
|
266 |
+
|
267 |
+
@staticmethod
|
268 |
+
def create_admin(name, email, password, gender=None):
|
269 |
+
"""Tạo admin mới"""
|
270 |
+
return User.register(name, email, password, gender, role="admin")
|
271 |
+
|
272 |
+
@staticmethod
|
273 |
+
def create_default_admin():
|
274 |
+
"""Tạo admin mặc định nếu chưa có"""
|
275 |
+
try:
|
276 |
+
db = get_db()
|
277 |
+
users_collection = db.users
|
278 |
+
|
279 |
+
# Kiểm tra xem đã có admin chưa
|
280 |
+
existing_admin = users_collection.find_one({"role": "admin"})
|
281 |
+
|
282 |
+
if not existing_admin:
|
283 |
+
# Tạo admin mặc định
|
284 |
+
default_email = "[email protected]"
|
285 |
+
default_password = "NutribotAdmin2024!"
|
286 |
+
|
287 |
+
success, result = User.create_admin(
|
288 |
+
name="Administrator",
|
289 |
+
email=default_email,
|
290 |
+
password=default_password
|
291 |
+
)
|
292 |
+
|
293 |
+
if success:
|
294 |
+
logger.info(f"Đã tạo admin mặc định: {default_email}")
|
295 |
+
logger.info(f"Mật khẩu mặc định: {default_password}")
|
296 |
+
return True, {
|
297 |
+
"email": default_email,
|
298 |
+
"password": default_password,
|
299 |
+
"user_id": result["user_id"]
|
300 |
+
}
|
301 |
+
else:
|
302 |
+
logger.error(f"Lỗi tạo admin mặc định: {result}")
|
303 |
+
return False, result
|
304 |
+
else:
|
305 |
+
logger.info("Admin đã tồn tại")
|
306 |
+
return True, {"message": "Admin đã tồn tại"}
|
307 |
+
|
308 |
+
except Exception as e:
|
309 |
+
logger.error(f"Lỗi tạo admin mặc định: {e}")
|
310 |
+
return False, str(e)
|
311 |
+
|
312 |
+
@staticmethod
|
313 |
+
def get_stats():
|
314 |
+
"""Lấy thống kê về users"""
|
315 |
+
try:
|
316 |
+
db = get_db()
|
317 |
+
users_collection = db.users
|
318 |
+
|
319 |
+
# Tổng số users
|
320 |
+
total_users = users_collection.count_documents({})
|
321 |
+
|
322 |
+
# Số admin
|
323 |
+
total_admins = users_collection.count_documents({"role": "admin"})
|
324 |
+
|
325 |
+
# Users đăng ký hôm nay
|
326 |
+
today_start = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
327 |
+
new_users_today = users_collection.count_documents({
|
328 |
+
"created_at": {"$gte": today_start}
|
329 |
+
})
|
330 |
+
|
331 |
+
# Users có hoạt động (có last_login)
|
332 |
+
active_users = users_collection.count_documents({
|
333 |
+
"last_login": {"$exists": True}
|
334 |
+
})
|
335 |
+
|
336 |
+
return {
|
337 |
+
"total_users": total_users,
|
338 |
+
"total_admins": total_admins,
|
339 |
+
"active_users": active_users,
|
340 |
+
"new_users_today": new_users_today
|
341 |
+
}
|
342 |
+
except Exception as e:
|
343 |
+
logger.error(f"Lỗi lấy thống kê users: {e}")
|
344 |
+
return {
|
345 |
+
"total_users": 0,
|
346 |
+
"total_admins": 0,
|
347 |
+
"active_users": 0,
|
348 |
+
"new_users_today": 0
|
349 |
+
}
|
requirements.txt
ADDED
Binary file (5.63 kB). View file
|
|
scripts/__init__.py
ADDED
File without changes
|
scripts/embed_data.py
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import sys
|
3 |
+
import time
|
4 |
+
import argparse
|
5 |
+
import logging
|
6 |
+
|
7 |
+
# Set UTF-8 encoding cho console
|
8 |
+
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
9 |
+
|
10 |
+
# Thêm thư mục cha vào sys.path để import các module từ thư mục backend
|
11 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
12 |
+
|
13 |
+
from core.data_processor import DataProcessor
|
14 |
+
from core.embedding_model import get_embedding_model
|
15 |
+
|
16 |
+
# Cấu hình logging với UTF-8
|
17 |
+
logging.basicConfig(
|
18 |
+
level=logging.INFO,
|
19 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
20 |
+
handlers=[
|
21 |
+
logging.StreamHandler(),
|
22 |
+
logging.FileHandler("embed_data.log", encoding='utf-8')
|
23 |
+
]
|
24 |
+
)
|
25 |
+
logger = logging.getLogger("embed_data")
|
26 |
+
|
27 |
+
def embed_all_data(data_dir, force=False):
|
28 |
+
"""
|
29 |
+
Embedding tất cả dữ liệu từ thư mục data
|
30 |
+
|
31 |
+
Args:
|
32 |
+
data_dir: Đường dẫn đến thư mục chứa dữ liệu
|
33 |
+
force: Nếu True, sẽ xóa và tạo lại chỉ mục hiện có
|
34 |
+
"""
|
35 |
+
logger.info(f"Bat dau qua trinh embedding du lieu tu {data_dir}")
|
36 |
+
start_time = time.time()
|
37 |
+
|
38 |
+
# Khởi tạo các components
|
39 |
+
data_processor = DataProcessor(data_dir=data_dir)
|
40 |
+
embedding_model = get_embedding_model()
|
41 |
+
|
42 |
+
# Kiểm tra xem có chỉ mục hiện có không
|
43 |
+
collection_size = embedding_model.count()
|
44 |
+
if collection_size > 0 and not force:
|
45 |
+
logger.info(f"Da ton tai chi muc voi {collection_size} items")
|
46 |
+
end_time = time.time()
|
47 |
+
logger.info(f"Hoan thanh kiem tra chi muc trong {end_time - start_time:.2f} giay")
|
48 |
+
logger.info("Su dung --force de tao lai chi muc")
|
49 |
+
return
|
50 |
+
|
51 |
+
# Nếu buộc tạo lại hoặc chưa có chỉ mục, tạo mới
|
52 |
+
if force:
|
53 |
+
logger.info("Xoa chi muc cu va tao lai...")
|
54 |
+
try:
|
55 |
+
embedding_model.chroma_client.delete_collection(name=embedding_model.collection.name)
|
56 |
+
# Tạo lại collection mới
|
57 |
+
embedding_model.collection = embedding_model.chroma_client.create_collection(name=embedding_model.collection.name)
|
58 |
+
logger.info("Da xoa va tao lai collection")
|
59 |
+
except Exception as e:
|
60 |
+
logger.error(f"Loi khi xoa collection: {e}")
|
61 |
+
|
62 |
+
# Chuẩn bị dữ liệu cho embedding
|
63 |
+
logger.info("Dang chuan bi du lieu cho qua trinh embedding...")
|
64 |
+
all_items = data_processor.prepare_for_embedding()
|
65 |
+
logger.info(f"Da chuan bi {len(all_items)} items de embedding")
|
66 |
+
|
67 |
+
# Thống kê các loại dữ liệu
|
68 |
+
text_chunks = len([item for item in all_items if item.get("metadata", {}).get("content_type") == "text"])
|
69 |
+
tables = len([item for item in all_items if item.get("metadata", {}).get("content_type") == "table"])
|
70 |
+
figures = len([item for item in all_items if item.get("metadata", {}).get("content_type") == "figure"])
|
71 |
+
|
72 |
+
logger.info(f"Bao gom: {text_chunks} van ban, {tables} bang bieu, {figures} hinh anh")
|
73 |
+
|
74 |
+
# Thực hiện embedding
|
75 |
+
logger.info("Bat dau qua trinh embedding...")
|
76 |
+
batch_size = 50 # Batch size phù hợp, tùy chỉnh nếu cần
|
77 |
+
|
78 |
+
# Chia thành các batch nhỏ để xử lý
|
79 |
+
for i in range(0, len(all_items), batch_size):
|
80 |
+
batch = all_items[i:i + batch_size]
|
81 |
+
logger.info(f"Dang xu ly batch {i//batch_size + 1}/{(len(all_items) + batch_size - 1)//batch_size}, kich thuoc: {len(batch)}")
|
82 |
+
success = embedding_model.index_chunks(batch)
|
83 |
+
if not success:
|
84 |
+
logger.error(f"Loi xu ly batch {i//batch_size + 1}")
|
85 |
+
return
|
86 |
+
|
87 |
+
end_time = time.time()
|
88 |
+
elapsed_time = end_time - start_time
|
89 |
+
|
90 |
+
# Kiểm tra kết quả cuối cùng
|
91 |
+
final_count = embedding_model.count()
|
92 |
+
logger.info(f"Hoan thanh qua trinh embedding {final_count} items trong {elapsed_time:.2f} giay")
|
93 |
+
|
94 |
+
if __name__ == "__main__":
|
95 |
+
parser = argparse.ArgumentParser(description="Embedding du lieu cho he thong Nutribot")
|
96 |
+
parser.add_argument("--data-dir", type=str, default="data",
|
97 |
+
help="Duong dan den thu muc chua du lieu (mac dinh: data)")
|
98 |
+
parser.add_argument("--force", action="store_true",
|
99 |
+
help="Xoa va tao lai chi muc neu da ton tai")
|
100 |
+
|
101 |
+
args = parser.parse_args()
|
102 |
+
|
103 |
+
# Chuẩn hóa đường dẫn
|
104 |
+
data_dir = os.path.abspath(args.data_dir)
|
105 |
+
|
106 |
+
# Kiểm tra thư mục data có tồn tại không
|
107 |
+
if not os.path.exists(data_dir):
|
108 |
+
logger.error(f"Thu muc {data_dir} khong ton tai!")
|
109 |
+
sys.exit(1)
|
110 |
+
|
111 |
+
# Thực hiện embedding
|
112 |
+
embed_all_data(data_dir, args.force)
|
scripts/init_admin.py
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python3
|
2 |
+
"""
|
3 |
+
Script khởi tạo admin mặc định cho hệ thống Nutribot
|
4 |
+
Chạy script này để tạo tài khoản admin đầu tiên
|
5 |
+
"""
|
6 |
+
|
7 |
+
import os
|
8 |
+
import sys
|
9 |
+
import logging
|
10 |
+
|
11 |
+
# Thêm thư mục cha vào sys.path
|
12 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
13 |
+
|
14 |
+
from models.user_model import User
|
15 |
+
|
16 |
+
# Cấu hình logging
|
17 |
+
logging.basicConfig(
|
18 |
+
level=logging.INFO,
|
19 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
20 |
+
)
|
21 |
+
logger = logging.getLogger(__name__)
|
22 |
+
|
23 |
+
def create_default_admin():
|
24 |
+
"""Tạo admin mặc định"""
|
25 |
+
try:
|
26 |
+
print("=" * 50)
|
27 |
+
print("KHỞI TẠO ADMIN NUTRIBOT")
|
28 |
+
print("=" * 50)
|
29 |
+
|
30 |
+
# Kiểm tra xem đã có admin chưa
|
31 |
+
from models.user_model import get_db
|
32 |
+
db = get_db()
|
33 |
+
existing_admin = db.users.find_one({"role": "admin"})
|
34 |
+
|
35 |
+
if existing_admin:
|
36 |
+
print("❌ Đã có admin trong hệ thống!")
|
37 |
+
print(f"Admin hiện tại: {existing_admin.get('name')} ({existing_admin.get('email')})")
|
38 |
+
|
39 |
+
choice = input("\nBạn có muốn tạo admin mới không? (y/N): ").lower().strip()
|
40 |
+
if choice != 'y':
|
41 |
+
print("Hủy bỏ tạo admin.")
|
42 |
+
return
|
43 |
+
|
44 |
+
# Nhập thông tin admin
|
45 |
+
print("\nNhập thông tin cho admin mới:")
|
46 |
+
|
47 |
+
name = input("Họ tên: ").strip()
|
48 |
+
if not name:
|
49 |
+
name = "Administrator"
|
50 |
+
print(f"Sử dụng tên mặc định: {name}")
|
51 |
+
|
52 |
+
email = input("Email: ").strip()
|
53 |
+
if not email:
|
54 |
+
email = "[email protected]"
|
55 |
+
print(f"Sử dụng email mặc định: {email}")
|
56 |
+
|
57 |
+
# Kiểm tra email đã tồn tại
|
58 |
+
if User.find_by_email(email):
|
59 |
+
print(f"❌ Email {email} đã được sử dụng!")
|
60 |
+
return
|
61 |
+
|
62 |
+
password = input("Mật khẩu (tối thiểu 6 ký tự): ").strip()
|
63 |
+
if not password or len(password) < 6:
|
64 |
+
password = "Admin123!"
|
65 |
+
print(f"Sử dụng mật khẩu mặc định: {password}")
|
66 |
+
|
67 |
+
gender = input("Giới tính (male/female/other, có thể bỏ trống): ").strip()
|
68 |
+
if gender and gender not in ['male', 'female', 'other']:
|
69 |
+
gender = None
|
70 |
+
|
71 |
+
# Tạo admin
|
72 |
+
print("\nĐang tạo admin...")
|
73 |
+
success, result = User.create_admin(name, email, password, gender)
|
74 |
+
|
75 |
+
if success:
|
76 |
+
print("✅ Tạo admin thành công!")
|
77 |
+
print("\n" + "=" * 50)
|
78 |
+
print("THÔNG TIN ĐĂNG NHẬP ADMIN")
|
79 |
+
print("=" * 50)
|
80 |
+
print(f"Email: {email}")
|
81 |
+
print(f"Mật khẩu: {password}")
|
82 |
+
print(f"Tên: {name}")
|
83 |
+
print(f"ID: {result['user_id']}")
|
84 |
+
print("=" * 50)
|
85 |
+
print("\n⚠️ LƯU Ý:")
|
86 |
+
print("- Hãy ghi nhớ thông tin đăng nhập này")
|
87 |
+
print("- Nên đổi mật khẩu sau lần đăng nhập đầu tiên")
|
88 |
+
print("- Truy cập admin panel tại: http://localhost:5173/admin")
|
89 |
+
print()
|
90 |
+
else:
|
91 |
+
print(f"❌ Lỗi tạo admin: {result}")
|
92 |
+
|
93 |
+
except Exception as e:
|
94 |
+
logger.error(f"Lỗi tạo admin: {e}")
|
95 |
+
print(f"❌ Có lỗi xảy ra: {e}")
|
96 |
+
|
97 |
+
def main():
|
98 |
+
"""Hàm main"""
|
99 |
+
try:
|
100 |
+
create_default_admin()
|
101 |
+
except KeyboardInterrupt:
|
102 |
+
print("\n\nĐã hủy bỏ tạo admin.")
|
103 |
+
except Exception as e:
|
104 |
+
print(f"\nLỗi: {e}")
|
105 |
+
|
106 |
+
if __name__ == "__main__":
|
107 |
+
main()
|
testapi.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from openai import OpenAI
|
2 |
+
import os
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
load_dotenv()
|
5 |
+
OPENAI_API_KEY=os.getenv("OPENAI_API_KEY")
|
6 |
+
client = OpenAI(
|
7 |
+
api_key = OPENAI_API_KEY
|
8 |
+
)
|
9 |
+
completion = client.chat.completions.create(
|
10 |
+
model="gpt-4o-mini",
|
11 |
+
store=True,
|
12 |
+
messages=[
|
13 |
+
{"role": "user", "content": "What is the capital of Vietnam?"}
|
14 |
+
]
|
15 |
+
)
|
16 |
+
|
17 |
+
print(completion.choices[0].message)
|