Upload 7 files
Browse files- .gitignore +6 -0
- LICENSE +21 -0
- README.md +54 -7
- app.py +467 -0
- deploy_to_hf.py +58 -0
- deploy_windows.cmd +7 -0
- requirements.txt +5 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.sqlite
|
| 4 |
+
*.sqlite-*
|
| 5 |
+
.venv/
|
| 6 |
+
.env
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,14 +1,61 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.15.2
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
license:
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Chatbot Luật Việt Nam - Bộ pháp điển
|
| 3 |
+
emoji: ⚖️
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
|
|
|
|
|
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# ⚖️ Chatbot Luật Việt Nam — RAG từ Bộ pháp điển
|
| 13 |
+
|
| 14 |
+
Space này xây dựng chatbot tra cứu pháp luật Việt Nam dựa trên dataset Hugging Face:
|
| 15 |
+
|
| 16 |
+
- Dataset: `tmquan/phapdien-moj-gov-vn`
|
| 17 |
+
- Subset chính: `articles`
|
| 18 |
+
- Nguồn dữ liệu gốc: Bộ pháp điển, Bộ Tư pháp Việt Nam
|
| 19 |
+
- License dataset: CC BY 4.0 theo dataset card
|
| 20 |
+
|
| 21 |
+
## Cách hoạt động
|
| 22 |
+
|
| 23 |
+
Ứng dụng dùng mô hình RAG theo hướng an toàn pháp lý:
|
| 24 |
+
|
| 25 |
+
1. Nạp dataset `articles` từ Hugging Face Datasets.
|
| 26 |
+
2. Tạo chỉ mục SQLite FTS5 cho các trường: tiêu đề điều, chủ đề, đề mục, chương, ghi chú nguồn và nội dung.
|
| 27 |
+
3. Khi người dùng hỏi, hệ thống tìm điều/khoản liên quan nhất.
|
| 28 |
+
4. Trả lời bằng cách tổng hợp trích đoạn liên quan, luôn kèm tiêu đề điều, đề mục và URL nguồn.
|
| 29 |
+
|
| 30 |
+
Ứng dụng mặc định **không dùng LLM bên ngoài**, nhằm giảm rủi ro bịa luật. Đây là chatbot tra cứu/tổng hợp có dẫn nguồn, không phải công cụ tư vấn pháp lý chính thức.
|
| 31 |
+
|
| 32 |
+
## Chạy local
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
python -m venv .venv
|
| 36 |
+
. .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 37 |
+
pip install -r requirements.txt
|
| 38 |
+
python app.py
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
Lần chạy đầu tiên sẽ tải dataset và tạo chỉ mục SQLite. Các lần sau dùng lại chỉ mục đã tạo.
|
| 42 |
+
|
| 43 |
+
## Biến môi trường tùy chọn
|
| 44 |
+
|
| 45 |
+
| Biến | Mặc định | Ý nghĩa |
|
| 46 |
+
|---|---:|---|
|
| 47 |
+
| `DATASET_ID` | `tmquan/phapdien-moj-gov-vn` | Dataset Hugging Face |
|
| 48 |
+
| `DATASET_CONFIG` | `articles` | Subset dataset |
|
| 49 |
+
| `DATASET_SPLIT` | `train` | Split dataset |
|
| 50 |
+
| `MAX_INDEX_CONTENT_CHARS` | `50000` | Số ký tự tối đa được index mỗi dòng |
|
| 51 |
+
| `DEFAULT_TOP_K` | `5` | Số nguồn trả về mặc định |
|
| 52 |
+
|
| 53 |
+
## Khuyến cáo
|
| 54 |
+
|
| 55 |
+
- Kết quả chỉ có giá trị tham khảo, cần kiểm tra lại văn bản gốc và hiệu lực pháp lý.
|
| 56 |
+
- Không dùng chatbot để thay thế luật sư, công chứng viên, tòa án, cơ quan quản lý hoặc chuyên gia pháp lý.
|
| 57 |
+
- Khi xử lý vụ việc thật, cần xét đầy đủ thời điểm áp dụng, hiệu lực văn bản, tình tiết, thẩm quyền và chứng cứ.
|
| 58 |
+
|
| 59 |
+
## Attribution
|
| 60 |
+
|
| 61 |
+
Dữ liệu thuộc dataset `tmquan/phapdien-moj-gov-vn` trên Hugging Face, được công bố theo CC BY 4.0. Ứng dụng này ghi nhận nguồn Bộ pháp điển của Bộ Tư pháp Việt Nam và dataset card tương ứng.
|
app.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Chatbot tra cứu Bộ pháp điển Việt Nam trên Hugging Face Spaces.
|
| 4 |
+
Dataset mặc định: tmquan/phapdien-moj-gov-vn / subset articles.
|
| 5 |
+
|
| 6 |
+
Thiết kế an toàn pháp lý:
|
| 7 |
+
- Không tự bịa quy định.
|
| 8 |
+
- Luôn trả lời kèm điều/đề mục/nguồn/URL.
|
| 9 |
+
- Có thể chạy hoàn toàn không cần API key: SQLite FTS5 + Gradio.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import html
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import re
|
| 18 |
+
import sqlite3
|
| 19 |
+
import textwrap
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any, Dict, Iterable, List, Sequence
|
| 23 |
+
|
| 24 |
+
import gradio as gr
|
| 25 |
+
from datasets import load_dataset
|
| 26 |
+
from tqdm import tqdm
|
| 27 |
+
|
| 28 |
+
DATASET_ID = os.getenv("DATASET_ID", "tmquan/phapdien-moj-gov-vn")
|
| 29 |
+
DATASET_CONFIG = os.getenv("DATASET_CONFIG", "articles")
|
| 30 |
+
DATASET_SPLIT = os.getenv("DATASET_SPLIT", "train")
|
| 31 |
+
|
| 32 |
+
# Trên Hugging Face Spaces, /data có thể được mount persistent nếu bật persistent storage.
|
| 33 |
+
# Nếu không có, dùng thư mục cache chuẩn của HF.
|
| 34 |
+
CACHE_ROOT = Path(os.getenv("SPACE_CACHE_DIR", os.getenv("HF_HOME", "/tmp"))) / "phapdien_law_chatbot"
|
| 35 |
+
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
INDEX_PATH = CACHE_ROOT / "phapdien_fts.sqlite"
|
| 37 |
+
|
| 38 |
+
MAX_INDEX_CONTENT_CHARS = int(os.getenv("MAX_INDEX_CONTENT_CHARS", "50000"))
|
| 39 |
+
MAX_ANSWER_CONTEXT_CHARS = int(os.getenv("MAX_ANSWER_CONTEXT_CHARS", "1200"))
|
| 40 |
+
DEFAULT_TOP_K = int(os.getenv("DEFAULT_TOP_K", "5"))
|
| 41 |
+
|
| 42 |
+
STOPWORDS = {
|
| 43 |
+
"và", "hoặc", "là", "của", "có", "cho", "về", "theo", "được", "trong", "khi",
|
| 44 |
+
"với", "một", "các", "những", "này", "đó", "tôi", "muốn", "hỏi", "quy", "định",
|
| 45 |
+
"pháp", "luật", "điều", "khoản", "nếu", "thì", "không", "ai", "gì", "như", "nào",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
APP_CSS = """
|
| 49 |
+
.gradio-container { max-width: 1180px !important; }
|
| 50 |
+
.source-card {
|
| 51 |
+
border: 1px solid #e5e7eb;
|
| 52 |
+
border-radius: 14px;
|
| 53 |
+
padding: 12px 14px;
|
| 54 |
+
margin: 10px 0;
|
| 55 |
+
background: #ffffff;
|
| 56 |
+
}
|
| 57 |
+
.source-title { font-weight: 700; font-size: 1.02rem; }
|
| 58 |
+
.source-meta { color: #4b5563; font-size: 0.92rem; margin-top: 4px; }
|
| 59 |
+
.source-text { margin-top: 8px; line-height: 1.55; }
|
| 60 |
+
.notice { padding: 12px 14px; border-radius: 14px; background: #fff7ed; border: 1px solid #fed7aa; }
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class SearchResult:
|
| 66 |
+
id: int
|
| 67 |
+
subject_title: str
|
| 68 |
+
topic_title: str
|
| 69 |
+
article_title: str
|
| 70 |
+
chapter_title: str
|
| 71 |
+
source_note_text: str
|
| 72 |
+
content_text: str
|
| 73 |
+
source_url: str
|
| 74 |
+
score: float
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def clean_text(value: Any) -> str:
|
| 78 |
+
if value is None:
|
| 79 |
+
return ""
|
| 80 |
+
if isinstance(value, (dict, list)):
|
| 81 |
+
return json.dumps(value, ensure_ascii=False)
|
| 82 |
+
return str(value).strip()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def compact_text(value: str) -> str:
|
| 86 |
+
return re.sub(r"\s+", " ", clean_text(value)).strip()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def tokenize_query(query: str, *, max_terms: int = 14) -> List[str]:
|
| 90 |
+
text = query.lower().strip()
|
| 91 |
+
tokens = re.findall(r"[0-9a-zA-ZÀ-ỹĐđ]+", text, flags=re.UNICODE)
|
| 92 |
+
filtered: List[str] = []
|
| 93 |
+
for token in tokens:
|
| 94 |
+
token = token.strip()
|
| 95 |
+
if len(token) < 2:
|
| 96 |
+
continue
|
| 97 |
+
if token in STOPWORDS:
|
| 98 |
+
continue
|
| 99 |
+
if token not in filtered:
|
| 100 |
+
filtered.append(token)
|
| 101 |
+
return filtered[:max_terms]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def build_fts_query(query: str) -> str:
|
| 105 |
+
"""Tạo query FTS5 đơn giản, an toàn hơn cho tiếng Việt.
|
| 106 |
+
|
| 107 |
+
FTS5 hỗ trợ prefix query dạng token*. Ta lọc ký tự đặc biệt để tránh lỗi MATCH.
|
| 108 |
+
"""
|
| 109 |
+
tokens = tokenize_query(query)
|
| 110 |
+
if not tokens:
|
| 111 |
+
fallback = re.sub(r'[^0-9a-zA-ZÀ-ỹĐđ\s]', ' ', query, flags=re.UNICODE).strip()
|
| 112 |
+
tokens = tokenize_query(fallback, max_terms=6)
|
| 113 |
+
if not tokens:
|
| 114 |
+
return '""'
|
| 115 |
+
return " OR ".join(f"{token}*" for token in tokens)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def connect_db() -> sqlite3.Connection:
|
| 119 |
+
conn = sqlite3.connect(str(INDEX_PATH), check_same_thread=False)
|
| 120 |
+
conn.row_factory = sqlite3.Row
|
| 121 |
+
conn.execute("PRAGMA journal_mode=WAL;")
|
| 122 |
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
| 123 |
+
return conn
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def index_exists() -> bool:
|
| 127 |
+
if not INDEX_PATH.exists() or INDEX_PATH.stat().st_size < 4096:
|
| 128 |
+
return False
|
| 129 |
+
try:
|
| 130 |
+
conn = connect_db()
|
| 131 |
+
count = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0]
|
| 132 |
+
conn.close()
|
| 133 |
+
return count > 100
|
| 134 |
+
except Exception:
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def init_schema(conn: sqlite3.Connection) -> None:
|
| 139 |
+
conn.executescript(
|
| 140 |
+
"""
|
| 141 |
+
DROP TABLE IF EXISTS articles;
|
| 142 |
+
DROP TABLE IF EXISTS articles_fts;
|
| 143 |
+
|
| 144 |
+
CREATE TABLE articles (
|
| 145 |
+
id INTEGER PRIMARY KEY,
|
| 146 |
+
subject_id TEXT,
|
| 147 |
+
topic_id TEXT,
|
| 148 |
+
topic_number INTEGER,
|
| 149 |
+
topic_title TEXT,
|
| 150 |
+
subject_number INTEGER,
|
| 151 |
+
subject_title TEXT,
|
| 152 |
+
article_anchor TEXT,
|
| 153 |
+
article_title TEXT,
|
| 154 |
+
chapter_title TEXT,
|
| 155 |
+
source_note_text TEXT,
|
| 156 |
+
source_links TEXT,
|
| 157 |
+
related_note_text TEXT,
|
| 158 |
+
content_text TEXT,
|
| 159 |
+
content_char_len INTEGER,
|
| 160 |
+
content_word_count INTEGER,
|
| 161 |
+
source_url TEXT,
|
| 162 |
+
scraped_at TEXT
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
CREATE VIRTUAL TABLE articles_fts USING fts5(
|
| 166 |
+
article_title,
|
| 167 |
+
subject_title,
|
| 168 |
+
topic_title,
|
| 169 |
+
chapter_title,
|
| 170 |
+
source_note_text,
|
| 171 |
+
content_text,
|
| 172 |
+
content='articles',
|
| 173 |
+
content_rowid='id',
|
| 174 |
+
tokenize='unicode61 remove_diacritics 2'
|
| 175 |
+
);
|
| 176 |
+
"""
|
| 177 |
+
)
|
| 178 |
+
conn.commit()
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def build_index(force: bool = False) -> str:
|
| 182 |
+
if index_exists() and not force:
|
| 183 |
+
return f"Đã có chỉ mục tại: {INDEX_PATH}"
|
| 184 |
+
|
| 185 |
+
conn = connect_db()
|
| 186 |
+
init_schema(conn)
|
| 187 |
+
|
| 188 |
+
ds = load_dataset(DATASET_ID, DATASET_CONFIG, split=DATASET_SPLIT)
|
| 189 |
+
total = len(ds) if hasattr(ds, "__len__") else None
|
| 190 |
+
|
| 191 |
+
insert_article_sql = """
|
| 192 |
+
INSERT INTO articles (
|
| 193 |
+
id, subject_id, topic_id, topic_number, topic_title,
|
| 194 |
+
subject_number, subject_title, article_anchor, article_title,
|
| 195 |
+
chapter_title, source_note_text, source_links, related_note_text,
|
| 196 |
+
content_text, content_char_len, content_word_count, source_url, scraped_at
|
| 197 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 198 |
+
"""
|
| 199 |
+
insert_fts_sql = """
|
| 200 |
+
INSERT INTO articles_fts(
|
| 201 |
+
rowid, article_title, subject_title, topic_title, chapter_title, source_note_text, content_text
|
| 202 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
for idx, row in enumerate(tqdm(ds, total=total, desc="Indexing phapdien"), start=1):
|
| 206 |
+
content = compact_text(row.get("content_text", ""))
|
| 207 |
+
if len(content) > MAX_INDEX_CONTENT_CHARS:
|
| 208 |
+
content = content[:MAX_INDEX_CONTENT_CHARS] + " …"
|
| 209 |
+
|
| 210 |
+
values = (
|
| 211 |
+
idx,
|
| 212 |
+
clean_text(row.get("subject_id")),
|
| 213 |
+
clean_text(row.get("topic_id")),
|
| 214 |
+
row.get("topic_number") if row.get("topic_number") is not None else None,
|
| 215 |
+
compact_text(row.get("topic_title")),
|
| 216 |
+
row.get("subject_number") if row.get("subject_number") is not None else None,
|
| 217 |
+
compact_text(row.get("subject_title")),
|
| 218 |
+
clean_text(row.get("article_anchor")),
|
| 219 |
+
compact_text(row.get("article_title")),
|
| 220 |
+
compact_text(row.get("chapter_title")),
|
| 221 |
+
compact_text(row.get("source_note_text")),
|
| 222 |
+
clean_text(row.get("source_links")),
|
| 223 |
+
compact_text(row.get("related_note_text")),
|
| 224 |
+
content,
|
| 225 |
+
row.get("content_char_len") if row.get("content_char_len") is not None else len(content),
|
| 226 |
+
row.get("content_word_count") if row.get("content_word_count") is not None else len(content.split()),
|
| 227 |
+
clean_text(row.get("source_url")),
|
| 228 |
+
clean_text(row.get("scraped_at")),
|
| 229 |
+
)
|
| 230 |
+
conn.execute(insert_article_sql, values)
|
| 231 |
+
conn.execute(
|
| 232 |
+
insert_fts_sql,
|
| 233 |
+
(
|
| 234 |
+
idx,
|
| 235 |
+
values[8],
|
| 236 |
+
values[6],
|
| 237 |
+
values[4],
|
| 238 |
+
values[9],
|
| 239 |
+
values[10],
|
| 240 |
+
values[13],
|
| 241 |
+
),
|
| 242 |
+
)
|
| 243 |
+
if idx % 500 == 0:
|
| 244 |
+
conn.commit()
|
| 245 |
+
|
| 246 |
+
conn.commit()
|
| 247 |
+
conn.execute("INSERT INTO articles_fts(articles_fts) VALUES('optimize')")
|
| 248 |
+
conn.commit()
|
| 249 |
+
count = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0]
|
| 250 |
+
conn.close()
|
| 251 |
+
return f"Đã xây chỉ mục {count:,} điều/khoản tại: {INDEX_PATH}"
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def like_fallback(conn: sqlite3.Connection, query: str, top_k: int) -> List[sqlite3.Row]:
|
| 255 |
+
tokens = tokenize_query(query, max_terms=5)
|
| 256 |
+
if not tokens:
|
| 257 |
+
return []
|
| 258 |
+
where = " OR ".join(["(content_text LIKE ? OR article_title LIKE ? OR subject_title LIKE ?)" for _ in tokens])
|
| 259 |
+
params: List[str] = []
|
| 260 |
+
for token in tokens:
|
| 261 |
+
pattern = f"%{token}%"
|
| 262 |
+
params.extend([pattern, pattern, pattern])
|
| 263 |
+
sql = f"""
|
| 264 |
+
SELECT id, subject_title, topic_title, article_title, chapter_title,
|
| 265 |
+
source_note_text, content_text, source_url, 999.0 AS score
|
| 266 |
+
FROM articles
|
| 267 |
+
WHERE {where}
|
| 268 |
+
LIMIT ?
|
| 269 |
+
"""
|
| 270 |
+
params.append(top_k)
|
| 271 |
+
return list(conn.execute(sql, params).fetchall())
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def search_articles(query: str, top_k: int = DEFAULT_TOP_K) -> List[SearchResult]:
|
| 275 |
+
build_index(force=False)
|
| 276 |
+
conn = connect_db()
|
| 277 |
+
fts_query = build_fts_query(query)
|
| 278 |
+
rows: Sequence[sqlite3.Row]
|
| 279 |
+
try:
|
| 280 |
+
rows = conn.execute(
|
| 281 |
+
"""
|
| 282 |
+
SELECT a.id, a.subject_title, a.topic_title, a.article_title, a.chapter_title,
|
| 283 |
+
a.source_note_text, a.content_text, a.source_url,
|
| 284 |
+
bm25(articles_fts, 2.2, 1.3, 1.0, 1.0, 1.4, 3.0) AS score
|
| 285 |
+
FROM articles_fts
|
| 286 |
+
JOIN articles a ON a.id = articles_fts.rowid
|
| 287 |
+
WHERE articles_fts MATCH ?
|
| 288 |
+
ORDER BY score
|
| 289 |
+
LIMIT ?
|
| 290 |
+
""",
|
| 291 |
+
(fts_query, top_k),
|
| 292 |
+
).fetchall()
|
| 293 |
+
except sqlite3.OperationalError:
|
| 294 |
+
rows = []
|
| 295 |
+
|
| 296 |
+
if not rows:
|
| 297 |
+
rows = like_fallback(conn, query, top_k)
|
| 298 |
+
conn.close()
|
| 299 |
+
|
| 300 |
+
results: List[SearchResult] = []
|
| 301 |
+
for row in rows:
|
| 302 |
+
results.append(
|
| 303 |
+
SearchResult(
|
| 304 |
+
id=int(row["id"]),
|
| 305 |
+
subject_title=clean_text(row["subject_title"]),
|
| 306 |
+
topic_title=clean_text(row["topic_title"]),
|
| 307 |
+
article_title=clean_text(row["article_title"]),
|
| 308 |
+
chapter_title=clean_text(row["chapter_title"]),
|
| 309 |
+
source_note_text=clean_text(row["source_note_text"]),
|
| 310 |
+
content_text=clean_text(row["content_text"]),
|
| 311 |
+
source_url=clean_text(row["source_url"]),
|
| 312 |
+
score=float(row["score"]),
|
| 313 |
+
)
|
| 314 |
+
)
|
| 315 |
+
return results
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def make_excerpt(text: str, query: str, max_chars: int = MAX_ANSWER_CONTEXT_CHARS) -> str:
|
| 319 |
+
text = compact_text(text)
|
| 320 |
+
if len(text) <= max_chars:
|
| 321 |
+
return text
|
| 322 |
+
tokens = tokenize_query(query, max_terms=8)
|
| 323 |
+
lower = text.lower()
|
| 324 |
+
positions = [lower.find(token.lower()) for token in tokens if lower.find(token.lower()) >= 0]
|
| 325 |
+
if positions:
|
| 326 |
+
center = min(positions)
|
| 327 |
+
start = max(0, center - max_chars // 3)
|
| 328 |
+
else:
|
| 329 |
+
start = 0
|
| 330 |
+
end = min(len(text), start + max_chars)
|
| 331 |
+
excerpt = text[start:end]
|
| 332 |
+
if start > 0:
|
| 333 |
+
excerpt = "… " + excerpt
|
| 334 |
+
if end < len(text):
|
| 335 |
+
excerpt += " …"
|
| 336 |
+
return excerpt
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def render_sources(results: Sequence[SearchResult], query: str) -> str:
|
| 340 |
+
cards = []
|
| 341 |
+
for idx, result in enumerate(results, start=1):
|
| 342 |
+
title = html.escape(result.article_title or "Không có tiêu đề điều")
|
| 343 |
+
subject = html.escape(result.subject_title or "Không rõ đề mục")
|
| 344 |
+
topic = html.escape(result.topic_title or "Không rõ chủ đề")
|
| 345 |
+
chapter = html.escape(result.chapter_title or "")
|
| 346 |
+
source_note = html.escape(result.source_note_text or "")
|
| 347 |
+
excerpt = html.escape(make_excerpt(result.content_text, query))
|
| 348 |
+
url = html.escape(result.source_url or "")
|
| 349 |
+
link = f'<a href="{url}" target="_blank" rel="noopener noreferrer">Mở nguồn pháp điển</a>' if url else "Không có URL"
|
| 350 |
+
cards.append(
|
| 351 |
+
f"""
|
| 352 |
+
<div class="source-card">
|
| 353 |
+
<div class="source-title">{idx}. {title}</div>
|
| 354 |
+
<div class="source-meta"><b>Chủ đề:</b> {topic} · <b>Đề mục:</b> {subject}</div>
|
| 355 |
+
{f'<div class="source-meta"><b>Chương:</b> {chapter}</div>' if chapter else ''}
|
| 356 |
+
{f'<div class="source-meta"><b>Nguồn:</b> {source_note}</div>' if source_note else ''}
|
| 357 |
+
<div class="source-text">{excerpt}</div>
|
| 358 |
+
<div class="source-meta">{link}</div>
|
| 359 |
+
</div>
|
| 360 |
+
"""
|
| 361 |
+
)
|
| 362 |
+
return "\n".join(cards)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def synthesize_answer(question: str, results: Sequence[SearchResult]) -> str:
|
| 366 |
+
if not results:
|
| 367 |
+
return (
|
| 368 |
+
"Tôi chưa tìm thấy điều/khoản đủ gần với câu hỏi trong chỉ mục Bộ pháp điển. "
|
| 369 |
+
"Bạn nên thử hỏi bằng từ khóa cụ thể hơn, ví dụ: tên luật, lĩnh vực, hành vi, điều kiện, mức phạt, thủ tục hoặc đối tượng áp dụng."
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
top = results[0]
|
| 373 |
+
bullets = []
|
| 374 |
+
for result in results[:3]:
|
| 375 |
+
excerpt = make_excerpt(result.content_text, question, max_chars=520)
|
| 376 |
+
bullets.append(
|
| 377 |
+
f"- **{result.article_title}** — {result.subject_title}. {excerpt}"
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
answer = f"""
|
| 381 |
+
<div class="notice">
|
| 382 |
+
<b>Lưu ý:</b> Đây là công cụ tra cứu và tổng hợp thông tin từ Bộ pháp điển, không thay thế ý kiến tư vấn của luật sư/cơ quan có thẩm quyền. Khi áp dụng vào vụ việc thật, cần kiểm tra văn bản gốc, hiệu lực và tình tiết cụ thể.
|
| 383 |
+
</div>
|
| 384 |
+
|
| 385 |
+
### Trả lời dựa trên kết quả gần nhất
|
| 386 |
+
|
| 387 |
+
Căn cứ gần nhất tôi tìm thấy là **{top.article_title}** trong đề mục **{top.subject_title}**, thuộc chủ đề **{top.topic_title}**.
|
| 388 |
+
|
| 389 |
+
Các đoạn liên quan:
|
| 390 |
+
{chr(10).join(bullets)}
|
| 391 |
+
|
| 392 |
+
### Nguồn tra cứu
|
| 393 |
+
|
| 394 |
+
{render_sources(results, question)}
|
| 395 |
+
"""
|
| 396 |
+
return textwrap.dedent(answer).strip()
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def chat_fn(message: str, history: List[Dict[str, str]], top_k: int) -> str:
|
| 400 |
+
message = (message or "").strip()
|
| 401 |
+
if not message:
|
| 402 |
+
return "Bạn hãy nhập câu hỏi pháp luật cần tra cứu."
|
| 403 |
+
results = search_articles(message, top_k=int(top_k))
|
| 404 |
+
return synthesize_answer(message, results)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def status_fn() -> str:
|
| 408 |
+
msg = build_index(force=False)
|
| 409 |
+
return f"{msg}\nDataset: {DATASET_ID}/{DATASET_CONFIG}/{DATASET_SPLIT}\nIndex: {INDEX_PATH}"
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def rebuild_fn() -> str:
|
| 413 |
+
return build_index(force=True)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
EXAMPLES = [
|
| 417 |
+
"Người lao động đơn phương chấm dứt hợp đồng cần báo trước bao lâu?",
|
| 418 |
+
"Điều kiện cấp giấy chứng nhận quyền sử dụng đất là gì?",
|
| 419 |
+
"Xử phạt hành vi vi phạm an toàn thực phẩm như thế nào?",
|
| 420 |
+
"Quyền và nghĩa vụ của công dân trong bảo vệ an ninh quốc gia",
|
| 421 |
+
"Thủ tục đăng ký kết hôn với người nước ngoài",
|
| 422 |
+
]
|
| 423 |
+
|
| 424 |
+
with gr.Blocks(title="Chatbot Luật Việt Nam - Bộ pháp điển", css=APP_CSS, theme=gr.themes.Soft()) as demo:
|
| 425 |
+
gr.Markdown(
|
| 426 |
+
"""
|
| 427 |
+
# ⚖️ Chatbot Luật Việt Nam — RAG từ Bộ pháp điển
|
| 428 |
+
|
| 429 |
+
Dữ liệu: `tmquan/phapdien-moj-gov-vn` trên Hugging Face. Ứng dụng dùng SQLite FTS5 để tìm điều/khoản liên quan, sau đó trả lời kèm nguồn.
|
| 430 |
+
**Nguyên tắc:** không bịa quy định, không thay thế tư vấn pháp lý chuyên nghiệp.
|
| 431 |
+
"""
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
with gr.Row():
|
| 435 |
+
with gr.Column(scale=3):
|
| 436 |
+
chatbot = gr.ChatInterface(
|
| 437 |
+
fn=chat_fn,
|
| 438 |
+
type="messages",
|
| 439 |
+
additional_inputs=[gr.Slider(3, 10, value=DEFAULT_TOP_K, step=1, label="Số nguồn trả về")],
|
| 440 |
+
examples=EXAMPLES,
|
| 441 |
+
cache_examples=False,
|
| 442 |
+
chatbot=gr.Chatbot(height=560, type="messages", show_copy_button=True),
|
| 443 |
+
textbox=gr.Textbox(
|
| 444 |
+
placeholder="Nhập câu hỏi, ví dụ: Điều kiện cấp sổ đỏ là gì?",
|
| 445 |
+
label="Câu hỏi pháp luật",
|
| 446 |
+
),
|
| 447 |
+
)
|
| 448 |
+
with gr.Column(scale=1):
|
| 449 |
+
gr.Markdown("### Trạng thái dữ liệu")
|
| 450 |
+
status = gr.Textbox(label="Index", lines=7, interactive=False)
|
| 451 |
+
refresh_btn = gr.Button("Kiểm tra / tạo chỉ mục")
|
| 452 |
+
rebuild_btn = gr.Button("Tạo lại chỉ mục", variant="secondary")
|
| 453 |
+
refresh_btn.click(status_fn, outputs=status)
|
| 454 |
+
rebuild_btn.click(rebuild_fn, outputs=status)
|
| 455 |
+
gr.Markdown(
|
| 456 |
+
"""
|
| 457 |
+
### Gợi ý hỏi tốt
|
| 458 |
+
- Nêu lĩnh vực: đất đai, lao động, hôn nhân, doanh nghiệp...
|
| 459 |
+
- Nêu hành vi/thủ tục/mức phạt/đối tượng áp dụng.
|
| 460 |
+
- Có thể hỏi theo tên luật hoặc điều khoản cụ thể.
|
| 461 |
+
"""
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
demo.load(status_fn, outputs=status)
|
| 465 |
+
|
| 466 |
+
if __name__ == "__main__":
|
| 467 |
+
demo.queue(default_concurrency_limit=8).launch()
|
deploy_to_hf.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Upload Space lên Hugging Face.
|
| 4 |
+
|
| 5 |
+
Cách dùng:
|
| 6 |
+
1) pip install -U huggingface_hub
|
| 7 |
+
2) Đặt token có quyền write:
|
| 8 |
+
Windows CMD: set HF_TOKEN=hf_xxx
|
| 9 |
+
PowerShell: $env:HF_TOKEN="hf_xxx"
|
| 10 |
+
macOS/Linux: export HF_TOKEN=hf_xxx
|
| 11 |
+
3) Đặt repo Space:
|
| 12 |
+
Windows CMD: set SPACE_ID=your-username/phapdien-law-chatbot
|
| 13 |
+
macOS/Linux: export SPACE_ID=your-username/phapdien-law-chatbot
|
| 14 |
+
4) python deploy_to_hf.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
from huggingface_hub import HfApi, create_repo
|
| 23 |
+
|
| 24 |
+
SPACE_ID = os.environ.get("SPACE_ID", "").strip()
|
| 25 |
+
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
|
| 26 |
+
ROOT = Path(__file__).resolve().parent
|
| 27 |
+
|
| 28 |
+
if not SPACE_ID or "/" not in SPACE_ID:
|
| 29 |
+
raise SystemExit("Thiếu SPACE_ID. Ví dụ: SPACE_ID=tenban/phapdien-law-chatbot")
|
| 30 |
+
if not HF_TOKEN:
|
| 31 |
+
raise SystemExit("Thiếu HF_TOKEN có quyền write trên Hugging Face.")
|
| 32 |
+
|
| 33 |
+
create_repo(
|
| 34 |
+
repo_id=SPACE_ID,
|
| 35 |
+
repo_type="space",
|
| 36 |
+
space_sdk="gradio",
|
| 37 |
+
private=False,
|
| 38 |
+
exist_ok=True,
|
| 39 |
+
token=HF_TOKEN,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
api = HfApi(token=HF_TOKEN)
|
| 43 |
+
api.upload_folder(
|
| 44 |
+
folder_path=str(ROOT),
|
| 45 |
+
repo_id=SPACE_ID,
|
| 46 |
+
repo_type="space",
|
| 47 |
+
ignore_patterns=[
|
| 48 |
+
".git/*",
|
| 49 |
+
"__pycache__/*",
|
| 50 |
+
"*.pyc",
|
| 51 |
+
"*.sqlite",
|
| 52 |
+
"*.sqlite-*",
|
| 53 |
+
"*.zip",
|
| 54 |
+
".venv/*",
|
| 55 |
+
],
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
print(f"Đã upload Space: https://huggingface.co/spaces/{SPACE_ID}")
|
deploy_windows.cmd
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
REM Deploy Chatbot Luat Viet Nam len Hugging Face Spaces
|
| 3 |
+
REM Buoc 1: pip install -U huggingface_hub
|
| 4 |
+
REM Buoc 2: set HF_TOKEN=hf_xxx
|
| 5 |
+
REM Buoc 3: set SPACE_ID=tenban/phapdien-law-chatbot
|
| 6 |
+
python deploy_to_hf.py
|
| 7 |
+
pause
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44.1
|
| 2 |
+
datasets>=2.20.0
|
| 3 |
+
huggingface_hub>=0.24.0
|
| 4 |
+
tqdm>=4.66.4
|
| 5 |
+
pyarrow>=15.0.0
|