# -*- coding: utf-8 -*- """ Chatbot tra cứu Bộ pháp điển Việt Nam trên Hugging Face Spaces. Dataset mặc định: tmquan/phapdien-moj-gov-vn / subset articles. Thiết kế an toàn pháp lý: - Không tự bịa quy định. - Luôn trả lời kèm điều/đề mục/nguồn/URL. - Có thể chạy hoàn toàn không cần API key: SQLite FTS5 + Gradio. """ from __future__ import annotations import html import json import os import re import sqlite3 import textwrap from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Iterable, List, Sequence import gradio as gr from datasets import load_dataset from tqdm import tqdm DATASET_ID = os.getenv("DATASET_ID", "tmquan/phapdien-moj-gov-vn") DATASET_CONFIG = os.getenv("DATASET_CONFIG", "articles") DATASET_SPLIT = os.getenv("DATASET_SPLIT", "train") # Trên Hugging Face Spaces, /data có thể được mount persistent nếu bật persistent storage. # Nếu không có, dùng thư mục cache chuẩn của HF. CACHE_ROOT = Path(os.getenv("SPACE_CACHE_DIR", os.getenv("HF_HOME", "/tmp"))) / "phapdien_law_chatbot" CACHE_ROOT.mkdir(parents=True, exist_ok=True) INDEX_PATH = CACHE_ROOT / "phapdien_fts.sqlite" MAX_INDEX_CONTENT_CHARS = int(os.getenv("MAX_INDEX_CONTENT_CHARS", "50000")) MAX_ANSWER_CONTEXT_CHARS = int(os.getenv("MAX_ANSWER_CONTEXT_CHARS", "1200")) DEFAULT_TOP_K = int(os.getenv("DEFAULT_TOP_K", "5")) STOPWORDS = { "và", "hoặc", "là", "của", "có", "cho", "về", "theo", "được", "trong", "khi", "với", "một", "các", "những", "này", "đó", "tôi", "muốn", "hỏi", "quy", "định", "pháp", "luật", "điều", "khoản", "nếu", "thì", "không", "ai", "gì", "như", "nào", } APP_CSS = """ .gradio-container { max-width: 1180px !important; } .source-card { border: 1px solid #e5e7eb; border-radius: 14px; padding: 12px 14px; margin: 10px 0; background: #ffffff; } .source-title { font-weight: 700; font-size: 1.02rem; } .source-meta { color: #4b5563; font-size: 0.92rem; margin-top: 4px; } .source-text { margin-top: 8px; line-height: 1.55; } .notice { padding: 12px 14px; border-radius: 14px; background: #fff7ed; border: 1px solid #fed7aa; } """ @dataclass class SearchResult: id: int subject_title: str topic_title: str article_title: str chapter_title: str source_note_text: str content_text: str source_url: str score: float def clean_text(value: Any) -> str: if value is None: return "" if isinstance(value, (dict, list)): return json.dumps(value, ensure_ascii=False) return str(value).strip() def compact_text(value: str) -> str: return re.sub(r"\s+", " ", clean_text(value)).strip() def tokenize_query(query: str, *, max_terms: int = 14) -> List[str]: text = query.lower().strip() tokens = re.findall(r"[0-9a-zA-ZÀ-ỹĐđ]+", text, flags=re.UNICODE) filtered: List[str] = [] for token in tokens: token = token.strip() if len(token) < 2: continue if token in STOPWORDS: continue if token not in filtered: filtered.append(token) return filtered[:max_terms] def build_fts_query(query: str) -> str: """Tạo query FTS5 đơn giản, an toàn hơn cho tiếng Việt. FTS5 hỗ trợ prefix query dạng token*. Ta lọc ký tự đặc biệt để tránh lỗi MATCH. """ tokens = tokenize_query(query) if not tokens: fallback = re.sub(r'[^0-9a-zA-ZÀ-ỹĐđ\s]', ' ', query, flags=re.UNICODE).strip() tokens = tokenize_query(fallback, max_terms=6) if not tokens: return '""' return " OR ".join(f"{token}*" for token in tokens) def connect_db() -> sqlite3.Connection: conn = sqlite3.connect(str(INDEX_PATH), check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA synchronous=NORMAL;") return conn def index_exists() -> bool: if not INDEX_PATH.exists() or INDEX_PATH.stat().st_size < 4096: return False try: conn = connect_db() count = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0] conn.close() return count > 100 except Exception: return False def init_schema(conn: sqlite3.Connection) -> None: conn.executescript( """ DROP TABLE IF EXISTS articles; DROP TABLE IF EXISTS articles_fts; CREATE TABLE articles ( id INTEGER PRIMARY KEY, subject_id TEXT, topic_id TEXT, topic_number INTEGER, topic_title TEXT, subject_number INTEGER, subject_title TEXT, article_anchor TEXT, article_title TEXT, chapter_title TEXT, source_note_text TEXT, source_links TEXT, related_note_text TEXT, content_text TEXT, content_char_len INTEGER, content_word_count INTEGER, source_url TEXT, scraped_at TEXT ); CREATE VIRTUAL TABLE articles_fts USING fts5( article_title, subject_title, topic_title, chapter_title, source_note_text, content_text, content='articles', content_rowid='id', tokenize='unicode61 remove_diacritics 2' ); """ ) conn.commit() def build_index(force: bool = False) -> str: if index_exists() and not force: return f"Đã có chỉ mục tại: {INDEX_PATH}" conn = connect_db() init_schema(conn) ds = load_dataset(DATASET_ID, DATASET_CONFIG, split=DATASET_SPLIT) total = len(ds) if hasattr(ds, "__len__") else None insert_article_sql = """ INSERT INTO articles ( id, subject_id, topic_id, topic_number, topic_title, subject_number, subject_title, article_anchor, article_title, chapter_title, source_note_text, source_links, related_note_text, content_text, content_char_len, content_word_count, source_url, scraped_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ insert_fts_sql = """ INSERT INTO articles_fts( rowid, article_title, subject_title, topic_title, chapter_title, source_note_text, content_text ) VALUES (?, ?, ?, ?, ?, ?, ?) """ for idx, row in enumerate(tqdm(ds, total=total, desc="Indexing phapdien"), start=1): content = compact_text(row.get("content_text", "")) if len(content) > MAX_INDEX_CONTENT_CHARS: content = content[:MAX_INDEX_CONTENT_CHARS] + " …" values = ( idx, clean_text(row.get("subject_id")), clean_text(row.get("topic_id")), row.get("topic_number") if row.get("topic_number") is not None else None, compact_text(row.get("topic_title")), row.get("subject_number") if row.get("subject_number") is not None else None, compact_text(row.get("subject_title")), clean_text(row.get("article_anchor")), compact_text(row.get("article_title")), compact_text(row.get("chapter_title")), compact_text(row.get("source_note_text")), clean_text(row.get("source_links")), compact_text(row.get("related_note_text")), content, row.get("content_char_len") if row.get("content_char_len") is not None else len(content), row.get("content_word_count") if row.get("content_word_count") is not None else len(content.split()), clean_text(row.get("source_url")), clean_text(row.get("scraped_at")), ) conn.execute(insert_article_sql, values) conn.execute( insert_fts_sql, ( idx, values[8], values[6], values[4], values[9], values[10], values[13], ), ) if idx % 500 == 0: conn.commit() conn.commit() conn.execute("INSERT INTO articles_fts(articles_fts) VALUES('optimize')") conn.commit() count = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0] conn.close() return f"Đã xây chỉ mục {count:,} điều/khoản tại: {INDEX_PATH}" def like_fallback(conn: sqlite3.Connection, query: str, top_k: int) -> List[sqlite3.Row]: tokens = tokenize_query(query, max_terms=5) if not tokens: return [] where = " OR ".join(["(content_text LIKE ? OR article_title LIKE ? OR subject_title LIKE ?)" for _ in tokens]) params: List[str] = [] for token in tokens: pattern = f"%{token}%" params.extend([pattern, pattern, pattern]) sql = f""" SELECT id, subject_title, topic_title, article_title, chapter_title, source_note_text, content_text, source_url, 999.0 AS score FROM articles WHERE {where} LIMIT ? """ params.append(top_k) return list(conn.execute(sql, params).fetchall()) def search_articles(query: str, top_k: int = DEFAULT_TOP_K) -> List[SearchResult]: build_index(force=False) conn = connect_db() fts_query = build_fts_query(query) rows: Sequence[sqlite3.Row] try: rows = conn.execute( """ SELECT a.id, a.subject_title, a.topic_title, a.article_title, a.chapter_title, a.source_note_text, a.content_text, a.source_url, bm25(articles_fts, 2.2, 1.3, 1.0, 1.0, 1.4, 3.0) AS score FROM articles_fts JOIN articles a ON a.id = articles_fts.rowid WHERE articles_fts MATCH ? ORDER BY score LIMIT ? """, (fts_query, top_k), ).fetchall() except sqlite3.OperationalError: rows = [] if not rows: rows = like_fallback(conn, query, top_k) conn.close() results: List[SearchResult] = [] for row in rows: results.append( SearchResult( id=int(row["id"]), subject_title=clean_text(row["subject_title"]), topic_title=clean_text(row["topic_title"]), article_title=clean_text(row["article_title"]), chapter_title=clean_text(row["chapter_title"]), source_note_text=clean_text(row["source_note_text"]), content_text=clean_text(row["content_text"]), source_url=clean_text(row["source_url"]), score=float(row["score"]), ) ) return results def make_excerpt(text: str, query: str, max_chars: int = MAX_ANSWER_CONTEXT_CHARS) -> str: text = compact_text(text) if len(text) <= max_chars: return text tokens = tokenize_query(query, max_terms=8) lower = text.lower() positions = [lower.find(token.lower()) for token in tokens if lower.find(token.lower()) >= 0] if positions: center = min(positions) start = max(0, center - max_chars // 3) else: start = 0 end = min(len(text), start + max_chars) excerpt = text[start:end] if start > 0: excerpt = "… " + excerpt if end < len(text): excerpt += " …" return excerpt def render_sources(results: Sequence[SearchResult], query: str) -> str: cards = [] for idx, result in enumerate(results, start=1): title = html.escape(result.article_title or "Không có tiêu đề điều") subject = html.escape(result.subject_title or "Không rõ đề mục") topic = html.escape(result.topic_title or "Không rõ chủ đề") chapter = html.escape(result.chapter_title or "") source_note = html.escape(result.source_note_text or "") excerpt = html.escape(make_excerpt(result.content_text, query)) url = html.escape(result.source_url or "") link = f'Mở nguồn pháp điển' if url else "Không có URL" cards.append( f"""