CVNSS commited on
Commit
f5645c2
·
verified ·
1 Parent(s): 4433b08

Upload 7 files

Browse files
Files changed (7) hide show
  1. .gitignore +6 -0
  2. LICENSE +21 -0
  3. README.md +54 -7
  4. app.py +467 -0
  5. deploy_to_hf.py +58 -0
  6. deploy_windows.cmd +7 -0
  7. 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: Lawbot
3
- emoji: 🏆
4
  colorFrom: blue
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.15.2
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
- license: apache-2.0
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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