them web app va README

This commit is contained in:
2026-06-12 22:13:19 +07:00
parent abddb932aa
commit c512d60160
8 changed files with 1216 additions and 155 deletions
+257 -146
View File
@@ -1,215 +1,326 @@
# Blog RAG Toolkit
A complete RAG (Retrieval-Augmented Generation) pipeline: **crawl** any blog, **extract** keywords, **chunk** content, and **query** with an LLM.
Bộ công cụ RAG (Retrieval-Augmented Generation) hoàn chỉnh: **crawl** blog, **trích xuất** từ khóa, **chia nhỏ** nội dung, **truy vấn** bằng LLM, và **giao diện web** để chat.
## Components
---
| File | Purpose |
|------|---------|
| `crawl_blog.py` | Generic blog crawler (sitemap + Firecrawl) |
| `crawl_orangepi_blog.py` | OrangePi.vn-specific crawler |
| `rag_app.py` | RAG query application (FAISS + LLM) |
| `keywords_example.json` | Sample keyword dictionary |
## Mục lục
## Quick Start
1. [Cài đặt](#1-cài-đặt)
2. [Cấu hình API](#2-cấu-hình-api)
3. [Bước 1 — Crawl dữ liệu blog](#bước-1--crawl-dữ-liệu-blog)
4. [Bước 2 — Xây dựng chỉ mục & truy vấn](#bước-2--xây-dựng-chỉ-mục--truy-vấn)
5. [Bước 3 — Giao diện web](#bước-3--giao-diện-web)
6. [Tham khảo](#tham-khảo)
### 1. Install
---
## 1. Cài đặt
```bash
git clone <repo-url>
cd orangepi-rag
pip install -r requirements.txt
```
### 2. Set API key
```bash
export FIRECRAWL_API_KEY="fc-..."
# or put in .env file:
echo "FIRECRAWL_API_KEY=fc-..." > .env
```
### 3. Crawl a blog
```bash
# Crawl 5 articles from any WordPress blog
python crawl_blog.py --sitemap https://example.com/post-sitemap.xml --limit 5
# Crawl all articles with custom keywords
python crawl_blog.py --sitemap https://example.com/post-sitemap.xml --all --keywords keywords.json
# Output to custom directory
python crawl_blog.py --sitemap https://example.com/post-sitemap.xml --all --out-dir ./my_blog_data
```
### 4. Build index & query
```bash
# Build FAISS index
python rag_app.py --build --data-dir ./my_blog_data --index-dir ./my_index
# Query (requires OPENAI_API_KEY)
export OPENAI_API_KEY="sk-..."
python rag_app.py --query "How to install Docker?" --data-dir ./my_blog_data --index-dir ./my_index
# Interactive chat
python rag_app.py --interactive --data-dir ./my_blog_data --index-dir ./my_index
```
**Yêu cầu:** Python 3.10+, tài khoản [Firecrawl](https://www.firecrawl.dev) (cho crawl), tài khoản LLM — OpenAI / Together.ai / Groq / Ollama (cho truy vấn).
---
## crawl_blog.py — Generic Blog Crawler
## 2. Cấu hình API
Crawls any blog that exposes a sitemap (WordPress, Yoast, etc.).
### Usage
Tạo file `.env` ở thư mục gốc dự án:
```bash
python crawl_blog.py --sitemap <SITEMAP_URL> [options]
# ─── BẮT BUỘC cho crawl ───
FIRECRAWL_API_KEY=fc-...
# ─── BẮT BUỘC cho RAG query ───
OPENAI_API_KEY=sk-...
# ─── TÙY CHỌN ───
# Thay đổi LLM provider (mặc định: OpenAI)
# LLM_BASE_URL=https://api.together.xyz/v1
# LLM_MODEL=meta-llama/Llama-3-70b-chat-hf
```
### Options
| Argument | Default | Description |
|----------|---------|-------------|
| `--sitemap` | (required) | Sitemap URL |
| `--out-dir` | `./blog_data` | Output directory |
| `--keywords` | `<out-dir>/keywords.json` | Keywords JSON path |
| `--limit N` | 5 | Process first N articles |
| `--all` | — | Process all articles |
| `--sleep SEC` | 1.0 | Delay between Firecrawl calls |
| `--force` | — | Re-scrape cached articles |
| `--max-words N` | 650 | Target words per chunk |
| `--overlap-words N` | 100 | Overlap words between chunks |
| `--language` | `en` | Default language code |
### Output files
| File | Description |
|------|-------------|
| `articles.jsonl` | Article records with keyword mentions |
| `chunks.jsonl` | Chunked content for embedding |
| `keywords.json` | Keyword dictionary used |
| `urls.json` | Discovered URLs |
| `raw/<slug>.json` | Raw Firecrawl responses |
| `markdown/<slug>.md` | Cleaned markdown |
| `errors.jsonl` | Failed URLs |
| `summary.json` | Crawl summary |
> Lấy Firecrawl key tại: https://www.firecrawl.dev
> Lấy OpenAI key tại: https://platform.openai.com/api-keys
---
## keywords.json — Keyword Dictionary
## Bước 1 — Crawl dữ liệu blog
Defines keywords to extract from crawled content. Supports categorized or flat format.
### 1.1 Tạo file từ khóa
### Categorized format (recommended)
Tạo file `keywords.json` chứa các từ khóa cần trích xuất từ blog:
```json
[
{
"category": "hardware",
"keywords": ["Raspberry Pi", "Arduino", "ESP32"]
"keywords": ["Raspberry Pi", "Orange Pi", "Arduino", "ESP32"]
},
{
"category": "software",
"keywords": ["Docker", "Ubuntu", "Home Assistant"]
"keywords": ["Docker", "Ubuntu", "Home Assistant", "MQTT"]
}
]
```
### Flat format
Xem file mẫu tại `keywords_example.json`.
```json
["Raspberry Pi", "Docker", "Home Assistant", "MQTT"]
### 1.2 Tìm sitemap URL
Blog WordPress thường có sitemap tại:
- `https://example.com/post-sitemap.xml` (Yoast SEO)
- `https://example.com/sitemap.xml` (generic)
### 1.3 Chạy crawl
```bash
# Test thử 5 bài viết
python crawl_blog.py \
--sitemap https://example.com/post-sitemap.xml \
--limit 5 \
--out-dir ./blog_data
# Crawl toàn bộ blog
python crawl_blog.py \
--sitemap https://example.com/post-sitemap.xml \
--all \
--keywords keywords.json \
--out-dir ./blog_data
# Crawl với tùy chỉnh
python crawl_blog.py \
--sitemap https://example.com/post-sitemap.xml \
--all \
--keywords keywords.json \
--out-dir ./blog_data \
--sleep 1.5 \
--max-words 500 \
--overlap-words 80 \
--language vi
```
See `keywords_example.json` for a complete template.
### 1.4 Kết quả
Sau khi crawl xong, thư mục `blog_data/` sẽ chứa:
```
blog_data/
├── articles.jsonl # Mỗi dòng = 1 bài viết (title, text, keywords, ...)
├── chunks.jsonl # Mỗi dòng = 1 đoạn nhỏ (~650 từ) cho embedding
├── keywords.json # File từ khóa đã dùng
├── urls.json # Danh sách URL tìm được từ sitemap
├── raw/<slug>.json # Response gốc từ Firecrawl
├── markdown/<slug>.md # Markdown đã làm sạch
├── errors.jsonl # Các URL lỗi
└── summary.json # Tổng kết crawl
```
### 1.5 Tham số đầy đủ
| Tham số | Mặc định | Mô tả |
|---------|----------|-------|
| `--sitemap` | (bắt buộc) | URL sitemap |
| `--out-dir` | `./blog_data` | Thư mục output |
| `--keywords` | `<out-dir>/keywords.json` | File từ khóa JSON |
| `--limit N` | 5 | Crawl N bài đầu tiên |
| `--all` | — | Crawl toàn bộ |
| `--sleep SEC` | 1.0 | Nghỉ giữa mỗi request (giây) |
| `--force` | — | Crawl lại kể cả đã có cache |
| `--max-words N` | 650 | Số từ tối đa mỗi chunk |
| `--overlap-words N` | 100 | Số từ overlap giữa các chunk |
| `--language` | `en` | Mã ngôn ngữ mặc định |
---
## rag_app.py — RAG Query Application
## Bước 2 — Xây dựng chỉ mục & truy vấn
FAISS-based vector search + LLM generation.
### Usage
### 2.1 Xây dựng chỉ mục FAISS
```bash
# Build index (one-time)
python rag_app.py --build --data-dir ./blog_data --index-dir ./index
# Single query
python rag_app.py --query "Câu hỏi của bạn" --data-dir ./blog_data --index-dir ./index
# Interactive chat
python rag_app.py --interactive --data-dir ./blog_data --index-dir ./index
# Test retrieval only (no LLM needed)
python rag_app.py --query "test" --retrieve-only --data-dir ./blog_data --index-dir ./index
python rag_app.py \
--build \
--data-dir ./blog_data \
--index-dir ./rag_index
```
### Options
Kết quả:
| Argument | Default | Description |
|----------|---------|-------------|
| `--data-dir` | `.` | Directory with chunks.jsonl |
| `--index-dir` | `./rag_index` | FAISS index directory |
| `--build` | — | Build index from chunks |
| `--query` | — | Query to answer |
| `--interactive` | — | Interactive chat mode |
| `--retrieve-only` | — | Test retrieval without LLM |
| `--top-k` | 5 | Number of chunks to retrieve |
| `--embed-model` | `paraphrase-multilingual-MiniLM-L12-v2` | Embedding model |
| `--llm-model` | `gpt-4o-mini` | LLM model name |
| `--llm-base-url` | `https://api.openai.com/v1` | LLM API base URL |
```
rag_index/
├── faiss.index # Chỉ mục vector FAISS
└── chunks.jsonl # Bản sao chunks cho retrieval
```
### LLM API configuration
Set in `.env`:
### 2.2 Truy vấn đơn lẻ
```bash
OPENAI_API_KEY=sk-...
# Or for other providers:
# LLM_BASE_URL=https://api.together.xyz/v1
# LLM_MODEL=meta-llama/Llama-3-70b-chat-hf
python rag_app.py \
--query "Cài Docker trên Raspberry Pi như thế nào?" \
--data-dir ./blog_data \
--index-dir ./rag_index
```
Compatible with any OpenAI-format API: OpenAI, Together.ai, Groq, Ollama, etc.
### 2.3 Chat interactive (terminal)
```bash
python rag_app.py \
--interactive \
--data-dir ./blog_data \
--index-dir ./rag_index
```
Gõ câu hỏi, nhận câu trả lời. Nhấn `Ctrl+C` để thoát.
### 2.4 Kiểm tra retrieval không cần LLM
```bash
python rag_app.py \
--query "Home Assistant" \
--retrieve-only \
--data-dir ./blog_data \
--index-dir ./rag_index
```
Chỉ hiển thị các chunk liên quan nhất, không gọi LLM.
### 2.5 Tham số đầy đủ
| Tham số | Mặc định | Mô tả |
|---------|----------|-------|
| `--data-dir` | `.` | Thư mục chứa chunks.jsonl |
| `--index-dir` | `./rag_index` | Thư mục chỉ mục FAISS |
| `--build` | — | Xây dựng chỉ mục |
| `--query` | — | Câu hỏi cần trả lời |
| `--interactive` | — | Chế độ chat terminal |
| `--retrieve-only` | — | Chỉ test retrieval, không dùng LLM |
| `--top-k` | 5 | Số chunk trả về |
| `--embed-model` | `paraphrase-multilingual-MiniLM-L12-v2` | Mô hình embedding |
| `--llm-model` | `gpt-4o-mini` | Tên mô hình LLM |
| `--llm-base-url` | `https://api.openai.com/v1` | URL API LLM |
---
## crawl_orangepi_blog.py — OrangePi-specific Crawler
## Bước 3 — Giao diện web
Specialized crawler for orangepi.vn with Orange Pi model detection.
### 3.1 Khởi động server
```bash
python crawl_orangepi_blog.py --limit 5
python crawl_orangepi_blog.py --all
python web_app.py \
--data-dir ./blog_data \
--index-dir ./rag_index \
--port 5000
```
Uses `orangepi_models.json` for product mention detection (36 Orange Pi models with aliases).
Mở trình duyệt: **http://localhost:5000**
### 3.2 Sử dụng
1. Nhấn **+** để tạo phiên chat mới
2. Gõ câu hỏi vào ô nhập, nhấn **Enter** để gửi
3. Xem câu trả lời + nguồn bài viết
4. Tạo nhiều phiên để hỏi nhiều chủ đề khác nhau
5. Xóa lịch sử hoặc xóa phiên bằng nút trên header
### 3.3 Tính năng
| Tính năng | Mô tả |
|-----------|-------|
| **Quản lý phiên** | Tạo, chuyển đổi, xóa nhiều phiên chat |
| **Lịch sử chat** | Lưu vào SQLite, giữ lại khi reload trang |
| **Nhớ ngữ cảnh** | 10 tin nhắn cuối được đưa vào prompt để giữ context |
| **Tránh lạc đề** | LLM được hướng dẫn chỉ trả lời trong phạm vi dữ liệu |
| **Trích nguồn** | Mỗi câu trả lời có link đến bài viết gốc |
| **Responsive** | Giao diện thích ứng desktop và mobile |
### 3.4 Tham số
| Tham số | Mặc định | Mô tả |
|---------|----------|-------|
| `--host` | `0.0.0.0` | Host để bind |
| `--port` | `5000` | Port |
| `--debug` | — | Chế độ debug |
| `--data-dir` | `.` | Thư mục dữ liệu |
| `--index-dir` | `./rag_index` | Thư mục chỉ mục |
### 3.5 Biến môi trường web
```bash
# Trong file .env
RAG_DATA_DIR=./blog_data
RAG_INDEX_DIR=./rag_index
RAG_LLM_MODEL=gpt-4o-mini
RAG_LLM_BASE_URL=https://api.openai.com/v1
RAG_TOP_K=5
RAG_MAX_HISTORY=10 # Số tin nhắn giữ context
```
### 3.6 API endpoints
| Method | Path | Mô tả |
|--------|------|-------|
| `GET` | `/api/sessions` | Danh sách phiên |
| `POST` | `/api/sessions` | Tạo phiên mới |
| `DELETE` | `/api/sessions/<id>` | Xóa phiên |
| `GET` | `/api/sessions/<id>/messages` | Lịch sử tin nhắn |
| `POST` | `/api/sessions/<id>/messages` | Gửi tin nhắn, nhận câu trả lời |
| `POST` | `/api/sessions/<id>/clear` | Xóa lịch sử phiên |
| `GET` | `/api/stats` | Thống kê hệ thống |
---
## Architecture
## Tham khảo
### Cấu trúc thư mục hoàn chỉnh
```
Blog (sitemap)
crawl_blog.py ──► Firecrawl API ──► articles.jsonl
│ chunks.jsonl
│ keywords.json
│ raw/*.json
│ markdown/*.md
rag_app.py
├──► SentenceTransformer (embeddings)
──► FAISS (vector index)
└──► LLM API (generation)
Answer + sources
orangepi-rag/
├── .env # API keys (FIRECRAWL, OPENAI)
├── requirements.txt # Python dependencies
├── crawl_blog.py # Crawler tổng quát
├── crawl_orangepi_blog.py # Crawler orangepi.vn
├── rag_app.py # RAG query (CLI)
├── web_app.py # Giao diện web (Flask)
├── keywords_example.json # Mẫu file từ khóa
├── templates/
│ └── index.html # HTML template
├── static/
├── style.css # CSS
── app.js # JavaScript
├── blog_data/ # Dữ liệu crawl được
│ ├── articles.jsonl
│ ├── chunks.jsonl
│ ├── keywords.json
│ ├── urls.json
│ ├── raw/
│ ├── markdown/
│ ├── errors.jsonl
│ └── summary.json
├── rag_index/ # Chỉ mục FAISS
│ ├── faiss.index
│ └── chunks.jsonl
└── rag_chat.db # SQLite chat history
```
## License
### Lưu ý khi dùng LLM provider khác
Data sourced from respective blogs. Check each site for content usage terms.
```bash
# Together.ai
LLM_BASE_URL=https://api.together.xyz/v1
LLM_MODEL=meta-llama/Llama-3-70b-chat-hf
OPENAI_API_KEY=...
# Groq
LLM_BASE_URL=https://api.groq.com/openai/v1
LLM_MODEL=llama-3.1-70b-versatile
OPENAI_API_KEY=...
# Ollama (chạy local)
LLM_BASE_URL=http://localhost:11434/v1
LLM_MODEL=llama3
OPENAI_API_KEY=ollama
```
+11 -8
View File
@@ -118,6 +118,16 @@ class LLMClient:
"Content-Type": "application/json",
}
def _post(self, payload: dict) -> dict:
resp = requests.post(
f"{self.base_url}/chat/completions",
headers=self.headers,
json=payload,
timeout=60,
)
resp.raise_for_status()
return resp.json()
def generate(self, prompt: str, temperature: float = 0.1, max_tokens: int = 1000) -> str:
payload = {
"model": self.model,
@@ -128,14 +138,7 @@ class LLMClient:
"temperature": temperature,
"max_tokens": max_tokens,
}
resp = requests.post(
f"{self.base_url}/chat/completions",
headers=self.headers,
json=payload,
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()
return self._post(payload)["choices"][0]["message"]["content"].strip()
def _system_prompt(self) -> str:
return """Bạn là một trợ lý AI chuyên về Orange Pi, sử dụng dữ liệu từ blog orangepi.vn (nhà phân phối chính thức Orange Pi tại Việt Nam).
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -3,4 +3,5 @@ faiss-cpu>=1.7.4
numpy>=1.24.0
requests>=2.31.0
python-dotenv>=1.0.0
tqdm>=4.65.0
tqdm>=4.65.0
flask>=3.0.0
+221
View File
@@ -0,0 +1,221 @@
const API = '';
let currentSession = null;
// DOM
const sessionList = document.getElementById('session-list');
const messages = document.getElementById('messages');
const emptyState = document.getElementById('empty-state');
const inputArea = document.getElementById('input-area');
const inputMessage = document.getElementById('input-message');
const btnSend = document.getElementById('btn-send');
const btnNewSession = document.getElementById('btn-new-session');
const btnClear = document.getElementById('btn-clear');
const btnDelete = document.getElementById('btn-delete');
const btnMenu = document.getElementById('btn-menu');
const chatTitle = document.getElementById('chat-title');
const statsEl = document.getElementById('stats');
// ---- API helpers ----
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return res.json();
}
// ---- Sessions ----
async function loadSessions() {
const sessions = await api('/api/sessions');
sessionList.innerHTML = sessions.map(s => `
<div class="session-item ${currentSession?.id === s.id ? 'active' : ''}"
data-id="${s.id}" onclick="selectSession('${s.id}')">
<span class="title">${esc(s.title)}</span>
<span class="time">${formatTime(s.updated_at)}</span>
</div>
`).join('');
}
async function createSession() {
const s = await api('/api/sessions', { method: 'POST', body: '{}' });
currentSession = s;
await loadSessions();
showChat();
}
async function selectSession(id) {
const sessions = await api('/api/sessions');
currentSession = sessions.find(s => s.id === id) || null;
if (!currentSession) return;
await loadSessions();
await loadMessages(id);
showChat();
}
async function deleteSession() {
if (!currentSession) return;
if (!confirm('Xóa phiên này?')) return;
await api(`/api/sessions/${currentSession.id}`, { method: 'DELETE' });
currentSession = null;
await loadSessions();
showEmpty();
}
async function clearMessages() {
if (!currentSession) return;
if (!confirm('Xóa lịch sử chat trong phiên này?')) return;
await api(`/api/sessions/${currentSession.id}/clear`, { method: 'POST' });
await loadMessages(currentSession.id);
}
async function loadMessages(sessionId) {
const msgs = await api(`/api/sessions/${sessionId}/messages`);
messages.innerHTML = '';
if (msgs.length === 0) {
messages.innerHTML = `<div class="empty-state"><p>Gõ câu hỏi bên dưới để bắt đầu</p></div>`;
return;
}
msgs.forEach(m => appendMessage(m.role, m.content, m.sources));
scrollToBottom();
}
// ---- Chat ----
async function sendMessage() {
const text = inputMessage.value.trim();
if (!text || !currentSession) return;
inputMessage.value = '';
autoResize();
appendMessage('user', text);
scrollToBottom();
// Typing indicator
const typingEl = document.createElement('div');
typingEl.className = 'message assistant';
typingEl.innerHTML = `<div class="bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>`;
messages.appendChild(typingEl);
scrollToBottom();
btnSend.disabled = true;
try {
const data = await api(`/api/sessions/${currentSession.id}/messages`, {
method: 'POST',
body: JSON.stringify({ content: text }),
});
typingEl.remove();
appendMessage('assistant', data.content, data.sources);
scrollToBottom();
loadSessions();
} catch (e) {
typingEl.remove();
appendMessage('assistant', 'Lỗi kết nối: ' + e.message);
scrollToBottom();
} finally {
btnSend.disabled = false;
inputMessage.focus();
}
}
function appendMessage(role, content, sources = null) {
// Remove empty state if present
const empty = messages.querySelector('.empty-state');
if (empty) empty.remove();
const div = document.createElement('div');
div.className = `message ${role}`;
let html = `<div class="bubble">${esc(content)}</div>`;
if (sources && sources.length > 0) {
html += `<div class="sources">`;
sources.forEach(s => {
html += `<a class="source-chip" href="${esc(s.url)}" target="_blank" title="${esc(s.title)}">${esc(s.title.substring(0, 40))}</a>`;
});
html += `</div>`;
}
div.innerHTML = html;
messages.appendChild(div);
}
// ---- UI state ----
function showChat() {
emptyState.style.display = 'none';
inputArea.style.display = 'block';
chatTitle.textContent = currentSession?.title || '';
btnClear.style.display = currentSession ? '' : 'none';
btnDelete.style.display = currentSession ? '' : 'none';
}
function showEmpty() {
emptyState.style.display = 'flex';
inputArea.style.display = 'none';
chatTitle.textContent = 'Chọn hoặc tạo phiên mới';
btnClear.style.display = 'none';
btnDelete.style.display = 'none';
messages.innerHTML = '';
messages.appendChild(emptyState);
}
function scrollToBottom() {
messages.scrollTop = messages.scrollHeight;
}
function autoResize() {
inputMessage.style.height = 'auto';
inputMessage.style.height = Math.min(inputMessage.scrollHeight, 120) + 'px';
}
// ---- Stats ----
async function loadStats() {
try {
const s = await api('/api/stats');
statsEl.innerHTML = `Chunks: ${s.chunks_indexed} | Phiên: ${s.sessions} | Tin nhắn: ${s.messages}`;
} catch {}
}
// ---- Util ----
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diff = now - d;
if (diff < 60000) return 'vừa xong';
if (diff < 3600000) return Math.floor(diff / 60000) + ' phút';
if (diff < 86400000) return Math.floor(diff / 3600000) + ' giờ';
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' });
}
// ---- Events ----
btnNewSession.onclick = createSession;
btnClear.onclick = clearMessages;
btnDelete.onclick = deleteSession;
btnSend.onclick = sendMessage;
btnMenu.onclick = () => {
document.getElementById('sidebar').classList.toggle('open');
};
inputMessage.addEventListener('input', autoResize);
inputMessage.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Close sidebar on mobile when clicking outside
document.querySelector('.main').addEventListener('click', () => {
document.getElementById('sidebar').classList.remove('open');
});
// Init
loadSessions().then(loadStats);
+341
View File
@@ -0,0 +1,341 @@
:root {
--bg: #0f0f0f;
--bg-sidebar: #161616;
--bg-chat: #0f0f0f;
--bg-input: #1e1e1e;
--bg-user: #2563eb;
--bg-assistant: #1a1a1a;
--bg-hover: #1e1e1e;
--bg-active: #2563eb20;
--border: #2a2a2a;
--text: #e5e5e5;
--text-dim: #888;
--text-muted: #555;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--danger: #dc2626;
--danger-hover: #b91c1c;
--radius: 12px;
--radius-sm: 8px;
--sidebar-w: 280px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-w);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: transform 0.2s;
}
.sidebar-header {
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 16px;
font-weight: 600;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-item {
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
margin-bottom: 2px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.15s;
}
.session-item:hover {
background: var(--bg-hover);
}
.session-item.active {
background: var(--bg-active);
border: 1px solid var(--accent);
}
.session-item .title {
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item .time {
font-size: 11px;
color: var(--text-muted);
margin-left: 8px;
flex-shrink: 0;
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.stats {
font-size: 11px;
color: var(--text-muted);
line-height: 1.6;
}
/* Main */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-sidebar);
}
.chat-title {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.chat-actions {
display: flex;
gap: 4px;
}
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-dim);
gap: 8px;
}
.empty-icon { opacity: 0.3; margin-bottom: 8px; }
.empty-state h3 { font-size: 20px; color: var(--text); }
.empty-state p { font-size: 14px; }
.empty-state .hint { font-size: 12px; color: var(--text-muted); margin-top: 8px; }
.message {
max-width: 800px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.message.user .bubble {
background: var(--bg-user);
color: white;
align-self: flex-end;
border-radius: var(--radius) var(--radius) 4px var(--radius);
}
.message.assistant .bubble {
background: var(--bg-assistant);
border: 1px solid var(--border);
border-radius: var(--radius) var(--radius) var(--radius) 4px;
}
.bubble {
padding: 12px 16px;
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 4px;
}
.source-chip {
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-dim);
text-decoration: none;
transition: all 0.15s;
}
.source-chip:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px 16px;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: bounce 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
/* Input */
.input-area {
padding: 12px 16px 16px;
border-top: 1px solid var(--border);
background: var(--bg-sidebar);
}
.input-wrapper {
display: flex;
align-items: flex-end;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
transition: border-color 0.15s;
}
.input-wrapper:focus-within {
border-color: var(--accent);
}
#input-message {
flex: 1;
background: none;
border: none;
color: var(--text);
font-size: 14px;
padding: 8px 12px;
resize: none;
outline: none;
font-family: inherit;
max-height: 120px;
line-height: 1.5;
}
#input-message::placeholder { color: var(--text-muted); }
.btn-send {
width: 36px;
height: 36px;
border: none;
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s;
}
.btn-send:hover { background: var(--accent-hover); }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
.input-hint {
font-size: 11px;
color: var(--text-muted);
text-align: center;
margin-top: 6px;
}
/* Buttons */
.btn-icon {
width: 32px;
height: 32px;
border: none;
background: none;
color: var(--text-dim);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-icon:hover { background: var(--bg-hover); color: var(--text); }
.btn-icon.btn-danger:hover { background: var(--danger); color: white; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* Responsive */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
transform: translateX(-100%);
}
.sidebar.open { transform: translateX(0); }
.btn-menu { display: flex !important; }
}
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog RAG Chat</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h2>Blog RAG</h2>
<button class="btn-icon" id="btn-new-session" title="Phiên mới">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="session-list" id="session-list"></div>
<div class="sidebar-footer">
<div class="stats" id="stats"></div>
</div>
</aside>
<!-- Main chat area -->
<main class="main">
<div class="chat-header" id="chat-header">
<button class="btn-icon btn-menu" id="btn-menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="chat-title" id="chat-title">Chọn hoặc tạo phiên mới</span>
<div class="chat-actions">
<button class="btn-icon" id="btn-clear" title="Xóa lịch sử phiên" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<button class="btn-icon btn-danger" id="btn-delete" title="Xóa phiên" style="display:none">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
</div>
</div>
<div class="messages" id="messages">
<div class="empty-state" id="empty-state">
<div class="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</div>
<h3>Blog RAG Chat</h3>
<p>Hỏi bất cứ điều gì về nội dung blog</p>
<p class="hint">Nhấn <strong>+</strong> để tạo phiên mới</p>
</div>
</div>
<div class="input-area" id="input-area" style="display:none">
<div class="input-wrapper">
<textarea id="input-message" placeholder="Nhập câu hỏi..." rows="1"></textarea>
<button class="btn-send" id="btn-send" title="Gửi">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<div class="input-hint">Enter để gửi, Shift+Enter xuống dòng</div>
</div>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""Web interface for the RAG application with session management and chat history."""
import os
import json
import uuid
import sqlite3
import datetime as dt
from pathlib import Path
from typing import Optional
from flask import Flask, render_template, request, jsonify, Response, stream_with_context
from dotenv import load_dotenv
from rag_app import Embedder, LLMClient, RAGPipeline, VectorStore, Chunk
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
load_dotenv()
DATA_DIR = Path(os.environ.get("RAG_DATA_DIR", "."))
INDEX_DIR = Path(os.environ.get("RAG_INDEX_DIR", "./rag_index"))
DB_DIR = Path(os.environ.get("RAG_DB_DIR", "."))
EMBED_MODEL = os.environ.get("RAG_EMBED_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
LLM_MODEL = os.environ.get("RAG_LLM_MODEL", "gpt-4o-mini")
LLM_BASE_URL = os.environ.get("RAG_LLM_BASE_URL", "https://api.openai.com/v1")
LLM_API_KEY = os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY", "")
TOP_K = int(os.environ.get("RAG_TOP_K", "5"))
MAX_HISTORY = int(os.environ.get("RAG_MAX_HISTORY", "10"))
app = Flask(__name__)
app.secret_key = os.urandom(24)
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
DB_PATH = DB_DIR / "rag_chat.db"
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
sources TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
""")
conn.close()
# ---------------------------------------------------------------------------
# RAG pipeline (singleton)
# ---------------------------------------------------------------------------
_pipeline: Optional[RAGPipeline] = None
def get_pipeline() -> RAGPipeline:
global _pipeline
if _pipeline is None:
embedder = Embedder(EMBED_MODEL)
llm = LLMClient(LLM_API_KEY, LLM_BASE_URL, LLM_MODEL) if LLM_API_KEY else None
_pipeline = RAGPipeline(DATA_DIR, INDEX_DIR, embedder, llm, TOP_K)
_pipeline.load_index()
return _pipeline
# ---------------------------------------------------------------------------
# System prompt builder
# ---------------------------------------------------------------------------
def build_system_prompt(session_history: list[dict]) -> str:
history_text = ""
if session_history:
lines = []
for msg in session_history[-MAX_HISTORY:]:
role = "Người dùng" if msg["role"] == "user" else "Trợ lý"
lines.append(f"{role}: {msg['content'][:200]}")
history_text = "\n".join(lines)
return f"""Bạn là một trợ lý AI thông minh, hỗ trợ trả lời câu hỏi dựa trên dữ liệu được cung cấp.
NHIỆM VỤ:
- Trả lời câu hỏi của người dùng CHỈ DỰA TRÊN THÔNG TIN trong phần "NGUYÊN LIỆU".
- Nếu thông tin không có trong nguyên liệu, hãy trả lời: "Không có thông tin trong dữ liệu."
- KHÔNG được bịa đặt, suy diễn hoặc sử dụng kiến thức bên ngoài nguyên liệu.
- Trả lời ngắn gọn, chính xác, bằng ngôn ngữ của người dùng (tiếng Việt hoặc tiếng Anh).
- Trích nguồn (tên bài viết + URL) khi có thể.
- Nếu câu hỏi không liên quan đến nội dung dữ liệu (ví dụ: hỏi về thời tiết, nấu ăn, etc.), hãy trả lời: "Câu hỏi này nằm ngoài phạm vi dữ liệu. Vui lòng hỏi về các chủ đề liên quan đến nội dung blog."
ĐƯỜNG DẪN NGỮ CẢNH:
{history_text if history_text else "(Đây là câu hỏi đầu tiên trong phiên)"}
"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/sessions", methods=["GET"])
def list_sessions():
conn = get_db()
rows = conn.execute(
"SELECT id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
).fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
@app.route("/api/sessions", methods=["POST"])
def create_session():
data = request.json or {}
session_id = str(uuid.uuid4())[:8]
title = data.get("title", f"Phiên {dt.datetime.now().strftime('%H:%M %d/%m')}")
now = dt.datetime.now(dt.timezone.utc).isoformat()
conn = get_db()
conn.execute(
"INSERT INTO sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
(session_id, title, now, now),
)
conn.commit()
conn.close()
return jsonify({"id": session_id, "title": title, "created_at": now, "updated_at": now})
@app.route("/api/sessions/<session_id>", methods=["DELETE"])
def delete_session(session_id):
conn = get_db()
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.commit()
conn.close()
return jsonify({"ok": True})
@app.route("/api/sessions/<session_id>/messages", methods=["GET"])
def get_messages(session_id):
conn = get_db()
rows = conn.execute(
"SELECT id, role, content, sources, created_at FROM messages WHERE session_id = ? ORDER BY id",
(session_id,),
).fetchall()
conn.close()
result = []
for r in rows:
msg = dict(r)
if msg["sources"]:
msg["sources"] = json.loads(msg["sources"])
result.append(msg)
return jsonify(result)
@app.route("/api/sessions/<session_id>/messages", methods=["POST"])
def send_message(session_id):
data = request.json or {}
question = (data.get("content") or "").strip()
if not question:
return jsonify({"error": "Empty message"}), 400
pipeline = get_pipeline()
# Get session history for context
conn = get_db()
history_rows = conn.execute(
"SELECT role, content FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?",
(session_id, MAX_HISTORY),
).fetchall()
session_history = [dict(r) for r in reversed(history_rows)]
# Save user message
now = dt.datetime.now(dt.timezone.utc).isoformat()
conn.execute(
"INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?)",
(session_id, "user", question, now),
)
conn.commit()
# Retrieve relevant chunks
query_emb = pipeline.embedder.embed_query(question)
results = pipeline.store.search(query_emb, top_k=pipeline.top_k)
if not results:
answer = "Không có thông tin trong dữ liệu."
sources = []
else:
# Build context
context_parts = []
sources = []
for i, chunk in enumerate(results, 1):
source = f"[{i}] {chunk.title} ({chunk.url})"
if chunk.section:
source += f" - Section: {chunk.section}"
context_parts.append(f"{source}\n{chunk.content}")
sources.append({
"title": chunk.title,
"url": chunk.url,
"section": chunk.section,
"score": round(chunk.metadata.get("similarity_score", 0), 4),
})
context = "\n\n---\n\n".join(context_parts)
system_prompt = build_system_prompt(session_history)
prompt = f"NGUYÊN LIỆU:\n{context}\n\nCÂU HỎI: {question}"
# Generate answer
try:
payload = {
"model": pipeline.llm.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
"temperature": 0.1,
"max_tokens": 1000,
}
resp = pipeline.llm._post(payload)
answer = resp["choices"][0]["message"]["content"].strip()
except Exception as e:
answer = f"Lỗi khi tạo câu trả lời: {e}"
# Save assistant message
conn.execute(
"INSERT INTO messages (session_id, role, content, sources, created_at) VALUES (?, ?, ?, ?, ?)",
(session_id, "assistant", answer, json.dumps(sources, ensure_ascii=False), now),
)
# Update session timestamp
conn.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (now, session_id))
conn.commit()
conn.close()
return jsonify({
"role": "assistant",
"content": answer,
"sources": sources,
"created_at": now,
})
@app.route("/api/sessions/<session_id>/clear", methods=["POST"])
def clear_messages(session_id):
conn = get_db()
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
return jsonify({"ok": True})
@app.route("/api/stats", methods=["GET"])
def get_stats():
conn = get_db()
session_count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
message_count = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
conn.close()
pipeline = get_pipeline()
chunk_count = len(pipeline.store.chunks) if pipeline.store else 0
return jsonify({
"sessions": session_count,
"messages": message_count,
"chunks_indexed": chunk_count,
"llm_model": LLM_MODEL,
"data_dir": str(DATA_DIR),
"index_dir": str(INDEX_DIR),
})
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="RAG Web Interface")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind")
parser.add_argument("--port", type=int, default=5000, help="Port to bind")
parser.add_argument("--debug", action="store_true", help="Debug mode")
parser.add_argument("--data-dir", type=Path, default=DATA_DIR)
parser.add_argument("--index-dir", type=Path, default=INDEX_DIR)
args = parser.parse_args()
DATA_DIR = args.data_dir
INDEX_DIR = args.index_dir
init_db()
print(f"Starting RAG Web Interface on http://{args.host}:{args.port}")
print(f"Data dir: {DATA_DIR}")
print(f"Index dir: {INDEX_DIR}")
app.run(host=args.host, port=args.port, debug=args.debug)