From c512d60160087321c5ae7e1188d2c9237e21b8ca Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Fri, 12 Jun 2026 22:13:19 +0700 Subject: [PATCH] them web app va README --- README.md | 403 +++++++++++++++++++++++++++---------------- rag_app.py | 19 +- rag_chat.db | Bin 0 -> 24576 bytes requirements.txt | 3 +- static/app.js | 221 ++++++++++++++++++++++++ static/style.css | 341 ++++++++++++++++++++++++++++++++++++ templates/index.html | 67 +++++++ web_app.py | 317 ++++++++++++++++++++++++++++++++++ 8 files changed, 1216 insertions(+), 155 deletions(-) create mode 100644 rag_chat.db create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 templates/index.html create mode 100644 web_app.py diff --git a/README.md b/README.md index 88bbf73..c632a45 100644 --- a/README.md +++ b/README.md @@ -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 +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 [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` | `/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/.json` | Raw Firecrawl responses | -| `markdown/.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/.json # Response gốc từ Firecrawl +├── markdown/.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` | `/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/` | Xóa phiên | +| `GET` | `/api/sessions//messages` | Lịch sử tin nhắn | +| `POST` | `/api/sessions//messages` | Gửi tin nhắn, nhận câu trả lời | +| `POST` | `/api/sessions//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 +``` diff --git a/rag_app.py b/rag_app.py index b511bb2..a140356 100644 --- a/rag_app.py +++ b/rag_app.py @@ -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). diff --git a/rag_chat.db b/rag_chat.db new file mode 100644 index 0000000000000000000000000000000000000000..9276daeae56e63e9923d0c2411f9fb58a51c8a9b GIT binary patch literal 24576 zcmeI&v2N2q7{GDcO#-S2?SPai(n&0dT7eK^W56WWDr)K!$Ca8XGO+V~Mq z!oX|s47>?!oZTFaO9>22(f=gdch23}-|xE&r)2o5A7!C&r(8TWtee64?}KUY->0tg_000IagfB*sr zAn^YL{A#W7tZ9CT;vl?;f{XDiOjG}Dn2u9biIP~iYaLhGo)kT&E6>DjJ;lHgx_4_i zdK?AqlS-|zw`Yzr|MfIX&!=i5r6xaLgz@BNv$}kUXTR)Aaiec5_GjU~8ri;lw{@q{ zY?_}Q7IW%{>T9Jmi@qyb+hV0efeOWwhtd@%ZtvK3N8(71gnjA_dXAdlSUTQ*X~cC% z!jos7a0co)?f1*Q=E*c%_xU7=voOxqbxD)OT+tL=%9Bs#p`V4pSnWaAyu!i2mAyl! zh-Wns!j%Wol}<+v#WI+*r4p1Ut}FXeWw&DwJ9bz8io5;s$SkNYqpS2?Es9hRw@TFd z?(fva+paj5!^)!UFKGW1_u_qUtIl;akF8px(=?4@wJ(>KzsQoJtyg?Hexd`(_wsl1 zFIzo7?dB&60tg_000IagfB*srAbPLIe;%009ILKmY**5I_I{1o;0y ZYXAWR5I_I{1Q0*~0R#|00D;XH_z4_m@p}LO literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index f9f9790..9ae27b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +tqdm>=4.65.0 +flask>=3.0.0 \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..3be4b3a --- /dev/null +++ b/static/app.js @@ -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 => ` +
+ ${esc(s.title)} + ${formatTime(s.updated_at)} +
+ `).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 = `

Gõ câu hỏi bên dưới để bắt đầu

`; + 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 = `
`; + 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 = `
${esc(content)}
`; + + if (sources && sources.length > 0) { + html += `
`; + sources.forEach(s => { + html += `${esc(s.title.substring(0, 40))}`; + }); + html += `
`; + } + + 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); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..5e476a6 --- /dev/null +++ b/static/style.css @@ -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; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..3683f67 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,67 @@ + + + + + + Blog RAG Chat + + + +
+ + + + +
+
+ + Chọn hoặc tạo phiên mới +
+ + +
+
+ +
+
+
+ +
+

Blog RAG Chat

+

Hỏi bất cứ điều gì về nội dung blog

+

Nhấn + để tạo phiên mới

+
+
+ + +
+
+ + + + diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..f3346b6 --- /dev/null +++ b/web_app.py @@ -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/", 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//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//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//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)