juribot-backend / utils /process_data.py
entidi2608's picture
update: retriever
25e6e74
import re
import os
from typing import List, Dict, Optional, Tuple, Union,Any
import logging
from tqdm import tqdm
import uuid
import json
from langchain_core.documents import Document
from config import LEGAL_DOC_TYPES, MAX_CHUNK_SIZE, CHUNK_OVERLAP
logger = logging.getLogger(__name__)
def filter_and_serialize_complex_metadata(documents: List[Document]) -> List[Document]:
"""Hàm cuối cùng để chuẩn hóa metadata ngay trước khi ingest vào vector store."""
updated_documents = []
allowed_types = (str, bool, int, float, type(None))
serialize_keys = ["penalties", "cross_references"]
for doc in documents:
filtered_metadata = {}
for key, value in doc.metadata.items():
if key in serialize_keys and value: # Chỉ serialize nếu value không rỗng
try:
filtered_metadata[key] = json.dumps(value, ensure_ascii=False, default=str)
except TypeError:
logger.warning(f"Không thể serialize key '{key}' cho doc ID {doc.id}. Chuyển thành string.")
filtered_metadata[key] = str(value)
elif isinstance(value, allowed_types):
filtered_metadata[key] = value
elif isinstance(value, list):
filtered_metadata[key] = json.dumps(value, ensure_ascii=False)
else:
filtered_metadata[key] = str(value)
doc.metadata = filtered_metadata
updated_documents.append(doc)
return updated_documents
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Đây là cấu hình tốt nhất, cân bằng, đã được kiểm chứng và không cần regex phức tạp
base_text_splitter = RecursiveCharacterTextSplitter(
chunk_size=MAX_CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
length_function=len,
add_start_index=True,
# Các separator này tôn trọng cấu trúc tự nhiên của văn bản (đoạn, dòng, câu)
# Đây là cách tiếp cận được khuyến nghị và hiệu quả nhất.
separators=["\n\n", "\n", ". ", ", ", " ", ""],
is_separator_regex=False # Không cần bật regex cho các separator đơn giản này
)
def generate_structured_id(doc_so_hieu: Optional[str], structure_path: List[str], filename: str) -> str:
"""
CẢI TIẾN: Tạo ra một chuỗi UUID v5 nhất quán từ ID có cấu trúc.
Thêm filename để đảm bảo tính duy nhất.
"""
# Ưu tiên so_hieu, nhưng fallback về filename để tránh "unknown-document"
base_id = doc_so_hieu if doc_so_hieu else filename
safe_base_id = re.sub(r'[/\s\.]', '-', base_id) # Thay thế các ký tự không an toàn
path_str = '_'.join(filter(None, structure_path))
# Đảm bảo unique_string_id khác nhau cho mỗi file
unique_string_id = f"{safe_base_id}_{path_str}"
generated_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, unique_string_id)
return str(generated_uuid)
def general_ocr_corrections(text: str) -> str:
"""Sửa các lỗi OCR phổ biến."""
corrections = {
"LuatWielnam": "LuatVietnam", "LualVietnam": "LuatVietnam",
"aflu atvistnarn.vni": "@luatvietnam.vn", "Tien ch van bán luat": "Tiện ích văn bản luật",
"teeeeokanlbaglueloen": "",
"Tee===": "", "Tc=e===": "", "nem": "",
"SN Hntlin sa:": "", "HT:": "", "Hntlin sa:": "",
r"([a-z])([A-Z])": r"\1 \2",
"Nghịđịnh": "Nghị định", " điểu ": " điều ", "Chưong": "Chương",
" điềm ": " điểm ", "khoån": "khoản", "Chínhphủ": "Chính phủ",
" điềukhỏan": " điều khoản", "LuậtTổ": "Luật Tổ", "LuậtXử": "Luật Xử",
"LuậtTrật": "Luật Trật", " điềuchỉnh": " điều chỉnh", " cá_nhân": " cá nhân",
" tổ_chức": " tổ chức", " hành_chính": " hành chính", " giấy_phép": " giấy phép",
" lái_xe": " lái xe", " Giao_thông": " Giao thông",
# 'ó': '6', 'Ò': '0', 'ọ': '0', 'l': '1', 'I': '1', 'i': '1',
# 'Z': '2', 'z': '2', 'B': '8',
}
for wrong, right in corrections.items():
if wrong.startswith(r"([a-z])([A-Z])"):
text = re.sub(wrong, right, text)
else:
text = text.replace(wrong, right)
text = re.sub(r"\s+", " ", text)
return text.strip()
### HÀM 1: CLEAN_DOCUMENT_TEXT (Cải tiến) ###
def clean_document_text(raw_text: str) -> str:
"""
Làm sạch văn bản luật một cách an toàn, giữ lại cấu trúc cột ở phần đầu
và loại bỏ nhiễu một cách có mục tiêu.
"""
if not raw_text: return ""
lines = raw_text.splitlines()
noise_patterns_to_remove = re.compile(
r"|".join([
r"LuatVietnam(?:\.vn)?", r"Tiện ích văn bản luật", r"www\.vanbanluat\.vn",
r"Hotline:", r"Email:", r"Cơ sở dữ liệu văn bản pháp luật",
r"Trang \d+\s*/\s*\d+", r"^\s*[=\-_*#]+\s*$", r"^\s*\[\s*Hình\s*ảnh\s*]\s*$",
]), re.IGNORECASE
)
footer_keywords = ["Nơi nhận:", "TM. CHÍNH PHỦ", "TM. BAN BÍ THƯ", "KT. BỘ TRƯỞNG", "TL. BỘ TRƯỞNG", "CHỦ TỊCH QUỐC HỘI"]
footer_start_index = len(lines)
for i, line in enumerate(lines):
line_upper = line.strip().upper()
if any(keyword.upper() in line_upper for keyword in footer_keywords):
if "CHỦ TỊCH" in line_upper and i < len(lines) / 2 and "NƯỚC" in line_upper: continue
footer_start_index = i
break
lines_before_footer = lines[:footer_start_index]
cleaned_lines = []
for line in lines_before_footer:
if noise_patterns_to_remove.search(line): continue
stripped_line = line.strip()
if not stripped_line: continue
cleaned_lines.append(stripped_line)
text = "\n".join(cleaned_lines)
text = general_ocr_corrections(text)
text = re.sub(r"\n{3,}", "\n\n", text).strip()
return text
def extract_document_metadata(raw_text: str, filename: str) -> Dict[str, Any]:
"""Trích xuất metadata, xử lý tốt định dạng 2 cột."""
metadata: Dict[str, Any] = { "so_hieu": None, "loai_van_ban": None, "ten_van_ban": None, "ngay_ban_hanh_str": None, "nam_ban_hanh": None, "co_quan_ban_hanh": None, "ngay_hieu_luc_str": None }
lines = raw_text.splitlines()[:50]
found_doc_type = False
is_capturing_title = False
title_lines = []
for line in lines:
stripped_line = line.strip()
if not stripped_line:
is_capturing_title = False
continue
parts = re.split(r'\s{3,}', stripped_line)
if len(parts) >= 2:
left, right = parts[0], parts[-1]
if any(kw in left.upper() for kw in ["CHÍNH PHỦ", "QUỐC HỘI", "BỘ"]): metadata["co_quan_ban_hanh"] = left.strip().upper()
if "số:" in left.lower():
if m := re.search(r"([\w\d/.-]+(?:-[\w\d/.-]+)?)", left): metadata["so_hieu"] = m.group(1).strip()
if m := re.search(r"ngày\s*(\d{1,2})\s*tháng\s*(\d{1,2})\s*năm\s*(\d{4})", right, re.I):
day, month, year = m.groups()
metadata["ngay_ban_hanh_str"] = f"ngày {day} tháng {month} năm {year}"
metadata["nam_ban_hanh"] = int(year)
doc_type_pattern = r"^(" + "|".join(LEGAL_DOC_TYPES) + ")$"
if not found_doc_type and (m := re.fullmatch(doc_type_pattern, stripped_line, re.IGNORECASE)):
metadata["loai_van_ban"] = m.group(1).strip().upper()
found_doc_type = True
is_capturing_title = True
continue
if is_capturing_title:
if stripped_line.startswith(("Căn cứ", "Chương ", "Điều ", "Phần ")) or re.match(r"^-+$", stripped_line):
is_capturing_title = False
else:
title_lines.append(stripped_line)
if title_lines: metadata["ten_van_ban"] = re.sub(r'\s+', ' ', " ".join(title_lines)).strip()
# 2. Fallback tìm năm ban hành từ số hiệu hoặc tên file
if not metadata["nam_ban_hanh"]:
if metadata["so_hieu"] and (m := re.search(r"/(\d{4})/", metadata["so_hieu"])):
metadata["nam_ban_hanh"] = int(m.group(1))
elif m := re.search(r"[-_](\d{4})[-_.]", filename):
metadata["nam_ban_hanh"] = int(m.group(1))
# 3. Fallback tìm loại văn bản nếu cách trên thất bại
if not metadata["loai_van_ban"]:
for doc_type in LEGAL_DOC_TYPES:
if metadata["ten_van_ban"] and metadata["ten_van_ban"].upper().startswith(doc_type.upper()):
metadata["loai_van_ban"] = doc_type.upper()
# Loại bỏ phần loại văn bản khỏi tên
metadata["ten_van_ban"] = metadata["ten_van_ban"][len(doc_type):].strip()
break
# 4. Tìm ngày hiệu lực ở cuối văn bản
# (Giữ nguyên logic này vì nó thường hoạt động tốt)
eff_text = "\n".join(raw_text.splitlines()[-20:]) # Chỉ tìm trong 20 dòng cuối
if m := re.search(r"có\s+hiệu\s+lực\s+(?:thi\s+hành\s+)?kể\s+từ\s+ngày\s*(\d{1,2})\s*tháng\s*(\d{1,2})\s*năm\s*(\d{4})", eff_text, re.I):
day, month, year = m.groups()
metadata["ngay_hieu_luc_str"] = f"ngày {day} tháng {month} năm {year}"
return metadata
def extract_cross_references(text_chunk_content: str, current_doc_full_metadata: Dict) -> List[Dict]:
references = []
internal_ref_patterns = [
re.compile(r"(?:quy định tại|xem|theo|như|tại)\s+(?:(điểm\s+[a-zđ])\s+)?(?:(khoản\s+\d+)\s+)?(Điều\s+\d+[a-z]?)(?:\s+của\s+(?:Nghị định|Luật|Bộ luật|Thông tư|Pháp lệnh|Quyết định|Nghị quyết)\s+này)?", re.IGNORECASE),
re.compile(r"(?:quy định tại|xem|theo|như|tại)\s+(?:(khoản\s+\d+)\s+)?(Điều\s+\d+[a-z]?)(?:\s+của\s+(?:Nghị định|Luật|Bộ luật|Thông tư|Pháp lệnh|Quyết định|Nghị quyết)\s+này)?", re.IGNORECASE),
re.compile(r"(?:quy định tại|xem|theo|như|tại)\s+(Điều\s+\d+[a-z]?)(?:\s+của\s+(?:Nghị định|Luật|Bộ luật|Thông tư|Pháp lệnh|Quyết định|Nghị quyết)\s+này|\s+nêu trên|\s+dưới đây)?", re.IGNORECASE),
]
# ... (phần internal_ref_patterns giữ nguyên) ...
for pattern in internal_ref_patterns:
for match in pattern.finditer(text_chunk_content):
original_text_internal = match.group(0) # Lấy toàn bộ chuỗi khớp
groups = match.groups()
ref_diem, ref_khoan, ref_dieu = None, None, None
# Logic xác định điểm, khoản, điều dựa trên số lượng group và nội dung
# Pattern 1: (điểm)? (khoản)? (Điều)
if pattern.pattern.count('(') - pattern.pattern.count('(?:') == 3: # Đếm số capturing groups
ref_diem_text = groups[0] if groups[0] and "điểm" in groups[0].lower() else None
ref_khoan_text = groups[1] if groups[1] and "khoản" in groups[1].lower() else None
ref_dieu_text = groups[2] if groups[2] and "điều" in groups[2].lower() else None
# Nếu group 1 là khoản, group 2 là điều (do điểm optional)
if not ref_diem_text and ref_khoan_text is None and (groups[0] and "khoản" in groups[0].lower()):
ref_khoan_text = groups[0]
ref_dieu_text = groups[1]
# Nếu group 1 là điều (do điểm và khoản optional)
elif not ref_diem_text and not ref_khoan_text and (groups[0] and "điều" in groups[0].lower()):
ref_dieu_text = groups[0]
# Pattern 2: (khoản)? (Điều)
elif pattern.pattern.count('(') - pattern.pattern.count('(?:') == 2:
ref_khoan_text = groups[0] if groups[0] and "khoản" in groups[0].lower() else None
ref_dieu_text = groups[1] if groups[1] and "điều" in groups[1].lower() else None
# Nếu group 0 là điều
if not ref_khoan_text and (groups[0] and "điều" in groups[0].lower()):
ref_dieu_text = groups[0]
# Pattern 3: (Điều)
elif pattern.pattern.count('(') - pattern.pattern.count('(?:') == 1:
ref_dieu_text = groups[0] if groups[0] and "điều" in groups[0].lower() else None
ref_dieu = ref_dieu_text.replace("Điều ", "").strip() if ref_dieu_text else None
ref_khoan = ref_khoan_text.replace("khoản ", "").strip() if ref_khoan_text else None
ref_diem = ref_diem_text.replace("điểm ", "").strip() if ref_diem_text else None
references.append({
"type": "internal", "original_text": original_text_internal,
"target_dieu": ref_dieu,
"target_khoan": ref_khoan,
"target_diem": ref_diem,
"target_document_id": current_doc_full_metadata.get("so_hieu"),
"target_document_title": current_doc_full_metadata.get("ten_van_ban")
})
external_ref_pattern = re.compile(
r"(?:quy định tại|theo|tại|của|trong)\s+"
# Group 1: Cụm điểm/khoản/điều (optional), ví dụ "điểm a khoản 1 Điều 5 " hoặc "Điều 5 "
r"((?:điểm\s+[a-zđ]\s*)?(?:khoản\s+\d+\s*)?(?:Điều\s+\d+[a-z]?\s*)?)?"
r"(?:của\s+)?" # Non-capturing "của "
# Group 2: Loại VB + Tên VB, ví dụ "Luật Giao thông đường bộ" hoặc "Nghị định 100/2019/NĐ-CP"
r"((?:Nghị định|Luật|Bộ luật|Thông tư|Pháp lệnh|Quyết định|Nghị quyết|Hiến pháp)"
r"(?:\s+[\w\sÀ-Ỹà-ỹ\d()/'.,-]+?)?)"
# Group 3: Số hiệu (optional), ví dụ "100/2019/NĐ-CP"
r"(?:\s+(?:số|số hiệu)?\s*([\w\d/.-]+(?:-\d{4}-[\w\d.-]+)?))?"
# Group 4: Năm từ ngày ban hành (optional), ví dụ "2019"
r"(?:\s*ngày\s*\d{1,2}\s*(?:tháng|-|/)\s*\d{1,2}\s*(?:năm|-|/)\s*(\d{4}))?"
r"(?:\s*của\s*(?:Chính phủ|Quốc hội|[\w\sÀ-Ỹà-ỹ]+))?", # Non-capturing cơ quan ban hành
re.IGNORECASE
)
for match in external_ref_pattern.finditer(text_chunk_content):
# match.groups() sẽ trả về 4 phần tử tương ứng với 4 capturing groups ở trên
matched_groups = match.groups()
original_text_external = match.group(0) # Toàn bộ chuỗi khớp
provision_elements_str = matched_groups[0] if matched_groups[0] else ""
target_doc_full_name_raw = matched_groups[1].strip() if matched_groups[1] else ""
target_doc_number_explicit = matched_groups[2].strip() if matched_groups[2] else None
target_doc_year_in_ref_str = matched_groups[3].strip() if matched_groups[3] else None
# Phân tích provision_elements_str để lấy điểm, khoản, điều
target_diem = None
if diem_match_obj := re.search(r"điểm\s+([a-zđ])", provision_elements_str, re.IGNORECASE):
target_diem = diem_match_obj.group(1)
target_khoan = None
if khoan_match_obj := re.search(r"khoản\s+(\d+)", provision_elements_str, re.IGNORECASE):
target_khoan = khoan_match_obj.group(1)
target_dieu = None
if dieu_match_obj := re.search(r"Điều\s+(\d+[a-z]?)", provision_elements_str, re.IGNORECASE):
target_dieu = dieu_match_obj.group(1)
# Phân tích loại và tên văn bản từ target_doc_full_name_raw
target_doc_type = None
target_doc_title = target_doc_full_name_raw # Gán giá trị mặc định
for doc_type_keyword in LEGAL_DOC_TYPES:
# Sử dụng \b để khớp từ chính xác hơn và re.escape để xử lý ký tự đặc biệt nếu có
# Khớp ở đầu chuỗi và không phân biệt hoa thường
if re.match(rf"^{re.escape(doc_type_keyword)}\b", target_doc_full_name_raw, re.IGNORECASE):
target_doc_type = doc_type_keyword.upper() # Chuẩn hóa về chữ hoa
# Loại bỏ phần loại văn bản khỏi tên, và các khoảng trắng thừa
temp_title = re.sub(rf"^{re.escape(doc_type_keyword)}\b\s*", "", target_doc_full_name_raw, count=1, flags=re.IGNORECASE)
target_doc_title = temp_title.strip()
break
final_doc_number = target_doc_number_explicit
final_doc_year = None
if target_doc_year_in_ref_str:
final_doc_year = int(target_doc_year_in_ref_str)
# Nếu không có số hiệu rõ ràng, thử trích từ tên (ví dụ: "Nghị định 100/2019/NĐ-CP")
if not final_doc_number and target_doc_title:
# Cố gắng bắt số hiệu dạng X/YYYY/ABC-XYZ hoặc X-YYYY-ABC
number_in_title_match = re.search(r"(\d+(?:/\d{4})?/[\w.-]+(?:-[\w\d.-]+)?|\d+-\d{4}-[\w.-]+)", target_doc_title)
if number_in_title_match:
final_doc_number = number_in_title_match.group(1)
# Cập nhật lại title nếu đã lấy số hiệu ra
target_doc_title = target_doc_title.replace(final_doc_number, "").strip()
target_doc_title = re.sub(r"^\s*(?:số|số hiệu)\s*$", "", target_doc_title, flags=re.IGNORECASE).strip() # Bỏ "số" thừa
target_doc_title = target_doc_title.replace("của", "").strip() # Bỏ "của" thừa nếu có
# Nếu không có năm rõ ràng, thử trích từ số hiệu hoặc tên
if not final_doc_year and final_doc_number:
year_in_number_match = re.search(r"/(\d{4})/", final_doc_number) or \
re.search(r"-(\d{4})-", final_doc_number)
if year_in_number_match:
final_doc_year = int(year_in_number_match.group(1))
if not final_doc_year and target_doc_title:
year_in_title_match = re.search(r"(?:năm|khóa)\s+(\d{4})", target_doc_title, re.IGNORECASE) # Thêm "khóa"
if year_in_title_match:
final_doc_year = int(year_in_title_match.group(1))
# Bỏ qua nếu không có loại văn bản HOẶC (cả tên văn bản VÀ số hiệu đều không có)
if not target_doc_type or (not target_doc_title and not final_doc_number):
# logger.debug(f"Skipping external ref: {original_text_external} -> Type: {target_doc_type}, Title: {target_doc_title}, Number: {final_doc_number}")
continue
references.append({
"type": "external",
"original_text": original_text_external,
"target_document_type": target_doc_type,
"target_document_title": target_doc_title if target_doc_title else None,
"target_document_number": final_doc_number,
"target_document_year": final_doc_year, # Đã là int hoặc None
"target_dieu": target_dieu,
"target_khoan": target_khoan,
"target_diem": target_diem,
"target_document_year_hint": final_doc_year # Dùng final_doc_year đã được chuẩn hóa
})
return references
def hierarchical_split_law_document(doc_obj: Document) -> List[Document]:
"""
PHIÊN BẢN CUỐI CÙNG: Chia văn bản luật theo cấu trúc, xử lý Preamble,
giữ trọn vẹn "Điều" và thêm "structured context".
"""
text = doc_obj.page_content
source_metadata = doc_obj.metadata.copy()
filename = source_metadata.get("source", "unknown_file")
doc_so_hieu = source_metadata.get("so_hieu")
final_chunks: List[Document] = []
# === BƯỚC 1: TÁCH VĂN BẢN THÀNH CÁC KHỐI CÓ CẤU TRÚC LỚN ===
# Regex này sẽ chia văn bản tại MỌI dòng bắt đầu bằng "Phần", "Chương", hoặc "Điều".
# `(?=...)` là positive lookahead, nó tìm điểm chia mà không "ăn" mất chuỗi đó.
# Thêm `\s*($|\n)` vào cuối để xử lý các dòng tiêu đề không có nội dung theo sau.
split_pattern = r"(?=\n^\s*(?:PHẦN\s+(?:THỨ\s+[\w\sÀ-Ỹà-ỹ]+|[IVXLCDM]+|CHUNG)|Chương\s+[IVXLCDM\d]+|Điều\s+\d+[a-z]?)\s*[:.]?.*($|\n))"
blocks = re.split(split_pattern, text, flags=re.MULTILINE | re.IGNORECASE)
# Khối đầu tiên trước lần chia đầu tiên luôn là Preamble (hoặc toàn bộ văn bản nếu không có cấu trúc)
preamble_block = blocks.pop(0).strip()
if preamble_block:
logger.debug(f"Processing Preamble for {filename}...")
# Tạo metadata cho Preamble
preamble_meta = source_metadata.copy()
preamble_meta["title"] = f"{source_metadata.get('ten_van_ban', filename)} - Phần Mở đầu"
# Làm giàu metadata cho Preamble
preamble_meta["entity_type"] = infer_entity_type(preamble_block, preamble_meta.get("field"))
preamble_meta["penalties"] = extract_penalties_from_text(preamble_block) # Thường là rỗng
# Tạo structured content
context_header = f"Trích từ: {preamble_meta['title']}\nThuộc văn bản: {preamble_meta.get('ten_van_ban', filename)}"
final_page_content = f"{context_header}\n\nNội dung:\n{preamble_block}"
# Tạo chunk cho Preamble
chunk_id = generate_structured_id(doc_so_hieu, ["preamble"], filename)
final_chunks.append(Document(page_content=final_page_content, metadata=preamble_meta, id=chunk_id))
# === BƯỚC 2: XỬ LÝ TỪNG KHỐI CẤU TRÚC (PHẦN, CHƯƠNG, ĐIỀU) ===
hierarchy_context: Dict[str, Any] = {}
for block_content in blocks:
block_content = block_content.strip()
if not block_content:
continue
first_line = block_content.splitlines()[0].strip()
item_type, item_code, item_title = parse_law_item_line(first_line)
# Cập nhật ngữ cảnh phân cấp
if item_type == "phan":
hierarchy_context = {"phan_code": item_code, "phan_title": item_title}
elif item_type == "chuong":
# Khi gặp Chương mới, giữ lại Phần, reset các cấp nhỏ hơn
hierarchy_context = {k: v for k, v in hierarchy_context.items() if k.startswith("phan")}
hierarchy_context.update({"chuong_code": item_code, "chuong_title": item_title})
# Chỉ xử lý sâu hơn nếu khối này là một "Điều"
if item_type == "dieu":
# Cập nhật context cho Điều này
hierarchy_context.update({"dieu_code": item_code, "dieu_title": item_title})
# Tạo metadata cuối cùng cho khối "Điều"
block_meta = {**source_metadata, **hierarchy_context}
title_parts = [str(block_meta.get(k)) for k in ["phan_code", "chuong_code", "dieu_code"] if block_meta.get(k)]
block_meta["title"] = " - ".join(title_parts)
# Làm giàu metadata cho toàn bộ "Điều"
block_meta["entity_type"] = infer_entity_type(block_content, block_meta.get("field"))
block_meta["penalties"] = extract_penalties_from_text(block_content)
block_meta["cross_references"] = extract_cross_references(block_content, source_metadata)
context_header = f"Trích từ: {block_meta['title']}\nThuộc văn bản: {block_meta.get('ten_van_ban', filename)}"
# Chia nhỏ "Điều" nếu cần
if len(block_content) > MAX_CHUNK_SIZE:
logger.debug(f"Splitting long article: {block_meta['title']}")
sub_texts = base_text_splitter.split_text(block_content)
for i, sub_text in enumerate(sub_texts):
sub_chunk_meta = block_meta.copy()
sub_chunk_meta["sub_chunk_index"] = i
final_page_content = f"{context_header}\n\nNội dung:\n{sub_text}"
chunk_id = generate_structured_id(doc_so_hieu, title_parts + [f"part-{i}"], filename)
final_chunks.append(Document(page_content=final_page_content, metadata=sub_chunk_meta, id=chunk_id))
else:
final_page_content = f"Toàn văn: {block_meta['title']}\n\nNội dung:\n{block_content}"
chunk_id = generate_structured_id(doc_so_hieu, title_parts, filename)
final_chunks.append(Document(page_content=final_page_content, metadata=block_meta, id=chunk_id))
return final_chunks
def infer_field(text_content: str, doc_title: Optional[str]) -> str:
"""
CẢI TIẾN V2: Bổ sung từ khóa ngắn gọn, thông tục để xử lý câu hỏi người dùng.
"""
safe_text_content = str(text_content) if text_content else ""
safe_doc_title = str(doc_title) if doc_title else ""
if not safe_doc_title and not safe_text_content:
return "khac"
# Chỉ cần một đoạn ngắn để tìm kiếm, giúp tăng hiệu suất
search_text = (safe_doc_title.lower() + " " + safe_text_content[:2500].lower()).strip()
title_lower = safe_doc_title.lower()
field_keywords = {
# 1. Giao thông
"giao_thong": [
("trật tự, an toàn giao thông đường bộ", 12),
("xử phạt vi phạm hành chính trong lĩnh vực giao thông", 11),
("giao thông đường bộ", 10),
("giao thông đường sắt", 10),
("giấy phép lái xe", 9), ("gplx", 9),
("đèn tín hiệu giao thông", 8),
("vượt đèn đỏ", 9),
("nồng độ cồn", 9),
("quá tốc độ", 8),
("đăng kiểm", 7),
("phạt nguội", 7),
("xe ô tô", 5), ("ô-tô", 5),
("xe máy", 5), ("xe mô tô", 5),
("lái xe", 4),
("biển báo", 4),
],
# 2. Hình sự
"hinh_su": [
("bộ luật hình sự", 12),
("truy cứu trách nhiệm hình sự", 10),
("tội phạm", 9),
("khởi tố", 8), ("tố tụng hình sự", 8),
("điều tra hình sự", 8),
("tòa án nhân dân", 7),
("viện kiểm sát", 7),
("giết người", 9),
("cướp giật tài sản", 9),
("lừa đảo chiếm đoạt tài sản", 9),
("ma túy", 8),
("cố ý gây thương tích", 8),
("tử hình", 6), ("tù chung thân", 6),
],
# 3. Dân sự
"dan_su": [
("bộ luật dân sự", 12),
("bồi thường thiệt hại ngoài hợp đồng", 10),
("giao dịch dân sự", 9),
("quyền sở hữu", 8),
("thừa kế", 9),
("di chúc", 9),
("hợp đồng dân sự", 7),
("tranh chấp dân sự", 6),
("nghĩa vụ dân sự", 6),
("đại diện, ủy quyền", 5),
],
# 4. Hôn nhân và Gia đình
"hon_nhan_gia_dinh": [
("luật hôn nhân và gia đình", 12),
("kết hôn", 9),
("ly hôn", 10),
("quan hệ giữa vợ và chồng", 8),
("tài sản chung của vợ chồng", 8), ("tài sản riêng", 8),
("quyền nuôi con", 9),
("cấp dưỡng", 8),
("mang thai hộ", 7),
("giám hộ", 6),
],
# 5. Lao động
"lao_dong": [
("bộ luật lao động", 12),
("hợp đồng lao động", 10),
("người lao động", 9), ("nlđ", 9),
("người sử dụng lao động", 9), ("nsdlđ", 9),
("bảo hiểm xã hội", 8), ("bhxh", 8),
("tiền lương", 7), ("lương tối thiểu vùng", 7),
("thời giờ làm việc", 6), ("thời giờ nghỉ ngơi", 6),
("kỷ luật lao động", 6),
("sa thải", 7), ("chấm dứt hợp đồng lao động", 7),
("an toàn, vệ sinh lao động", 6),
],
# 6. Đất đai
"dat_dai": [
("luật đất đai", 12),
("quyền sử dụng đất", 10),
("giấy chứng nhận quyền sử dụng đất", 9),
("thu hồi đất", 8),
("bồi thường, hỗ trợ, tái định cư", 8),
("quy hoạch, kế hoạch sử dụng đất", 7),
("sổ đỏ", 7), ("sổ hồng", 7),
("tranh chấp đất đai", 7),
("giá đất", 6),
],
# 7. Doanh nghiệp & Đầu tư
"doanh_nghiep": [
("luật doanh nghiệp", 12),
("luật đầu tư", 12),
("thành lập doanh nghiệp", 9),
("giấy chứng nhận đăng ký doanh nghiệp", 9),
("công ty cổ phần", 8),
("công ty trách nhiệm hữu hạn", 8),
("doanh nghiệp tư nhân", 8),
("vốn điều lệ", 7),
("cổ đông", 6), ("thành viên góp vốn", 6),
("phá sản", 7),
],
# 8. Xây dựng & Nhà ở
"xay_dung": [
("luật xây dựng", 12),
("luật nhà ở", 12),
("giấy phép xây dựng", 9),
("quy hoạch xây dựng", 8),
("chủ đầu tư", 7),
("dự án đầu tư xây dựng", 7),
("hợp đồng xây dựng", 6),
("chung cư", 6),
],
# 9. Hành chính
"hanh_chinh": [
("luật xử lý vi phạm hành chính", 10),
("xử phạt vi phạm hành chính", 9),
("khiếu nại", 8),
("tố cáo", 8),
("thủ tục hành chính", 7),
("công chức", 7), ("viên chức", 7),
("cán bộ", 6),
],
# 10. Thuế & Tài chính & Ngân hàng
"tai_chinh_thue": [
("luật quản lý thuế", 12),
("luật các tổ chức tín dụng", 12),
("thuế giá trị gia tăng", 9), ("thuế gtgt", 9),
("thuế thu nhập doanh nghiệp", 9), ("thuế tndn", 9),
("thuế thu nhập cá nhân", 9), ("thuế tncn", 9),
("ngân sách nhà nước", 8),
("hóa đơn điện tử", 7),
("kế toán, kiểm toán", 7),
("ngân hàng", 6),
("trái phiếu", 6),
],
# 11. Môi trường
"moi_truong": [
("luật bảo vệ môi trường", 12),
("đánh giá tác động môi trường", 9), ("đtm", 9),
("ô nhiễm môi trường", 8),
("chất thải", 7), ("chất thải nguy hại", 7),
("tài nguyên nước", 6),
("khí thải", 6),
],
# 12. Sở hữu trí tuệ
"so_huu_tri_tue": [
("luật sở hữu trí tuệ", 12),
("quyền tác giả", 9),
("bản quyền", 8),
("quyền liên quan", 8),
("sáng chế", 8),
("nhãn hiệu", 8),
("chỉ dẫn địa lý", 7),
],
# 13. Giáo dục
"giao_duc": [
("luật giáo dục", 12),
("học sinh, sinh viên", 7),
("cơ sở giáo dục", 7),
("học phí", 7),
("tuyển sinh", 6),
("giáo viên", 6),
("bằng cấp, chứng chỉ", 5),
],
# 14. Y tế
"y_te": [
("luật khám bệnh, chữa bệnh", 12),
("bảo hiểm y tế", 10), ("bhyt", 10),
("dược", 8), ("thuốc", 7),
("trang thiết bị y tế", 7),
("bệnh viện", 6),
("an toàn thực phẩm", 6),
],
}
# ... (phần logic tính điểm và trả về giữ nguyên) ...
field_scores = {field: 0 for field in field_keywords.keys()}
for field, weighted_keywords in field_keywords.items():
score = 0
for keyword, weight in weighted_keywords:
if doc_title and keyword in title_lower:
score += weight * 3
occurrences_in_text = search_text.count(keyword)
if occurrences_in_text > 0:
score += weight * occurrences_in_text
field_scores[field] = score
positive_scores = {f: s for f, s in field_scores.items() if s > 0}
if not positive_scores:
return "khac"
# In ra điểm số để debug
logger.debug(f"Field scores for query '{text_content[:50]}...': {positive_scores}")
best_field = max(positive_scores, key=positive_scores.get)
return best_field
def infer_entity_type(query_or_text: str, field: Optional[str]) -> Optional[List[str]]:
"""
CẢI TIẾN V2: Mở rộng từ khóa, xử lý khi không có field và luôn trả về list.
"""
text_lower = query_or_text.lower()
entity_definitions = {
"giao_thong": {
"xe_oto": {"keywords": ["ô tô", "xe hơi", "xe con", "xe ô-tô"], "priority": 10},
"xe_may": {"keywords": ["xe máy", "mô tô", "xe gắn máy", "xe 2 bánh"], "priority": 10},
# Thêm các từ khóa ngắn gọn hơn
"nguoi_dieu_khien": {"keywords": ["người điều khiển", "lái xe", "tài xế"], "priority": 9},
"phuong_tien": {"keywords": ["phương tiện", "xe cộ", "xe"], "priority": 5}, # Thêm "xe cộ"
},
"hinh_su": {
"nguoi_pham_toi": {"keywords": ["tội phạm", "bị can", "bị cáo", "người phạm tội", "kẻ gian"], "priority": 10},
"nan_nhan": {"keywords": ["nạn nhân", "người bị hại"], "priority": 9},
},
"lao_dong": {
"nguoi_lao_dong": {"keywords": ["người lao động", "nhân viên", "công nhân", "nlđ"], "priority": 10},
"nguoi_su_dung_lao_dong": {"keywords": ["người sử dụng lao động", "công ty", "doanh nghiệp", "nsdlđ"], "priority": 10},
"hop_dong_lao_dong": {"keywords": ["hợp đồng lao động", "hđlđ"], "priority": 8},
},
"khac": {
"ca_nhan": {"keywords": ["cá nhân", "người", "công dân", "một người"], "priority": 7},
"to_chuc": {"keywords": ["tổ chức", "cơ quan", "đơn vị", "công ty"], "priority": 7},
}
}
found_entities = []
# CẢI TIẾN: Nếu có field, chỉ tìm trong field đó. Nếu không, tìm trong tất cả.
fields_to_check = [field] if field and field in entity_definitions else list(entity_definitions.keys())
for f in fields_to_check:
current_field_entities = entity_definitions.get(f, {})
sorted_entities = sorted(current_field_entities.items(), key=lambda item: item[1]["priority"], reverse=True)
for entity_type, definition in sorted_entities:
sorted_keywords = sorted(definition["keywords"], key=len, reverse=True)
if any(re.search(r"\b" + re.escape(keyword) + r"\b", text_lower) for keyword in sorted_keywords):
if entity_type not in found_entities:
found_entities.append(entity_type)
if not found_entities:
return None
# Luôn trả về một danh sách các entity tìm được
return found_entities
def parse_law_item_line(line: str) -> Tuple[Optional[str], str, str]:
"""Phân tích cấu trúc dòng một cách mạnh mẽ và có thứ tự."""
stripped_line = line.strip()
patterns = [
("phan", r"^\s*(PHẦN\s+(?:THỨ\s+[\w\sÀ-Ỹà-ỹ]+|[IVXLCDM]+|CHUNG))\s*?$"),
("chuong", r"^\s*(Chương\s+[IVXLCDM\d]+)\s*?$"),
("dieu", r"^\s*(Điều\s+\d+[a-z]?)\.?\s*(.*)"),
]
for item_type, pattern_str in patterns:
match = re.match(pattern_str, stripped_line, re.IGNORECASE)
if match:
if item_type in ["phan", "chuong"]:
return item_type, match.group(1).strip(), ""
elif item_type == "dieu":
return item_type, match.group(1).strip(), match.group(2).strip()
if stripped_line.isupper() and len(stripped_line.split()) > 1 and len(stripped_line) < 150:
return "title", "", stripped_line
if m := re.match(r"^\s*(\d+)\.\s+(.*)", stripped_line):
return "khoan", m.group(1), m.group(2).strip()
if m := re.match(r"^\s*([a-zđ])\)\s+(.*)", stripped_line):
return "diem", m.group(1), m.group(2).strip()
return None, "", stripped_line
def _normalize_money(value_str: str) -> Optional[float]:
if not value_str: return None
try:
return float(value_str.replace(".", "").replace(",", "."))
except ValueError: return None
def _normalize_duration(value_str: str, unit_str: str) -> Optional[Dict[str, Union[int, str]]]:
if not value_str or not unit_str: return None
try:
value = int(value_str)
unit = unit_str.lower()
if unit == "tháng": return {"value": value, "unit": "months"}
if unit == "năm": return {"value": value, "unit": "years"}
if unit == "ngày": return {"value": value, "unit": "days"}
return {"value": value, "unit": unit_str}
except ValueError: return None
def extract_penalties_from_text(text_content: str) -> List[Dict]:
"""
Trích xuất các loại hình phạt khác nhau từ một đoạn văn bản.
Cải tiến: Sử dụng set để tránh thêm các hình phạt trùng lặp.
"""
if not text_content:
return []
penalties = []
found_original_texts = set() # Set để theo dõi các chuỗi đã tìm thấy
# --- 1. PHẠT TIỀN ---
# Ưu tiên bắt khoảng (từ... đến...) trước
fine_range_pattern = r"phạt tiền từ\s*([\d\.,]+)\s*đồng\s*đến\s*([\d\.,]+)\s*đồng"
for m in re.finditer(fine_range_pattern, text_content, re.IGNORECASE):
original_text = m.group(0)
if original_text not in found_original_texts:
penalties.append({
"type": "fine",
"min_amount": _normalize_money(m.group(1)),
"max_amount": _normalize_money(m.group(2)),
"currency": "đồng",
"original_text": original_text
})
found_original_texts.add(original_text)
# Bắt các mức phạt cố định sau
fine_fixed_pattern = r"\bphạt tiền\s*([\d\.,]+)\s*đồng\b"
for m in re.finditer(fine_fixed_pattern, text_content, re.IGNORECASE):
original_text = m.group(0)
# Kiểm tra xem nó có phải là một phần của một khoảng đã tìm thấy không
is_part_of_range = any(original_text in found_range for found_range in found_original_texts)
if not is_part_of_range and original_text not in found_original_texts:
penalties.append({
"type": "fine",
"amount": _normalize_money(m.group(1)),
"currency": "đồng",
"original_text": original_text
})
found_original_texts.add(original_text)
# --- 2. HÌNH PHẠT TÙ ---
prison_range_pattern = r"phạt tù từ\s*(\d+)\s*(tháng|năm|ngày)\s*đến\s*(\d+)\s*(tháng|năm|ngày)"
for m in re.finditer(prison_range_pattern, text_content, re.IGNORECASE):
original_text = m.group(0)
if original_text not in found_original_texts:
penalties.append({
"type": "prison",
"min_duration": _normalize_duration(m.group(1), m.group(2)),
"max_duration": _normalize_duration(m.group(3), m.group(4)),
"original_text": original_text
})
found_original_texts.add(original_text)
prison_fixed_pattern = r"\bphạt tù\s*(\d+)\s*(tháng|năm|ngày)\b"
for m in re.finditer(prison_fixed_pattern, text_content, re.IGNORECASE):
original_text = m.group(0)
is_part_of_range = any(original_text in found_range for found_range in found_original_texts)
if not is_part_of_range and original_text not in found_original_texts:
penalties.append({
"type": "prison",
"duration": _normalize_duration(m.group(1), m.group(2)),
"original_text": original_text
})
found_original_texts.add(original_text)
# Tù chung thân và tử hình
special_prison_patterns = {
"life_imprisonment": r"phạt tù chung thân",
"death_penalty": r"phạt tử hình"
}
for p_type, pattern in special_prison_patterns.items():
if match := re.search(pattern, text_content, re.IGNORECASE):
original_text = match.group(0)
if original_text not in found_original_texts:
penalties.append({"type": "prison", "duration_type": p_type, "original_text": original_text})
found_original_texts.add(original_text)
# --- 3. TƯỚC QUYỀN SỬ DỤNG GIẤY PHÉP ---
license_revocation_pattern = r"tước quyền sử dụng giấy phép lái xe\s*(?:từ|từ\s*thời\s*hạn)?\s*(\d+)\s*(tháng)\s*đến\s*(\d+)\s*(tháng)"
for m in re.finditer(license_revocation_pattern, text_content, re.IGNORECASE):
original_text = m.group(0)
if original_text not in found_original_texts:
penalties.append({
"type": "license_revocation",
"min_duration": _normalize_duration(m.group(1), m.group(2)),
"max_duration": _normalize_duration(m.group(3), m.group(4)),
"original_text": original_text
})
found_original_texts.add(original_text)
# --- 4. CÁC HÌNH PHẠT KHÁC (Cảnh cáo, tịch thu, v.v.) ---
other_patterns = {
"warning": r"\bphạt cảnh cáo\b",
"confiscation_object_vehicle": r"tịch thu tang vật,? phương tiện vi phạm hành chính",
"deportation": r"\btrục xuất\b"
}
for p_type, pattern in other_patterns.items():
if match := re.search(pattern, text_content, re.IGNORECASE):
original_text = match.group(0)
if original_text not in found_original_texts:
penalties.append({"type": p_type, "original_text": original_text})
found_original_texts.add(original_text)
return penalties
def load_process_and_split_documents(folder_path: str) -> List[Document]:
all_final_chunks = []
if not os.path.isdir(folder_path):
logger.error(f"Folder '{folder_path}' does not exist.")
return all_final_chunks
txt_files = [f for f in os.listdir(folder_path) if f.lower().endswith('.txt')]
if not txt_files:
logger.warning(f"No .txt files found in '{folder_path}'.")
return all_final_chunks
logger.info(f"Found {len(txt_files)} .txt files. Processing...")
for filename in tqdm(txt_files, desc="Processing files"):
file_path = os.path.join(folder_path, filename)
try:
with open(file_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
if not raw_content.strip():
logger.warning(f"File '{filename}' is empty or contains only whitespace.")
continue
# Trích xuất metadata gốc từ raw_content trước khi làm sạch quá nhiều
doc_metadata_original = extract_document_metadata(raw_content, filename)
doc_metadata_original["source"] = filename
# Làm sạch nội dung để xử lý (có thể giữ lại phần đầu nếu clean_document_text được điều chỉnh)
cleaned_content_for_processing = clean_document_text(raw_content)
if not cleaned_content_for_processing.strip():
logger.warning(f"File '{filename}' is empty after cleaning for processing.")
continue
# Suy luận lĩnh vực và các thông tin khác
doc_metadata_original["field"] = infer_field(cleaned_content_for_processing, doc_metadata_original.get("ten_van_ban"))
doc_metadata_original["entity_type"] = infer_entity_type(cleaned_content_for_processing, doc_metadata_original.get("field"))
# Penalty sẽ được trích xuất cho từng chunk
# Tạo đối tượng Document lớn ban đầu để truyền vào hàm chia chunk
# Nội dung là cleaned_content, metadata là doc_metadata_original
# Tham số id của Document này không quá quan trọng vì nó sẽ được chia nhỏ
doc_to_split = Document(page_content=cleaned_content_for_processing, metadata=doc_metadata_original)
chunks_from_file = hierarchical_split_law_document(doc_to_split)
all_final_chunks.extend(chunks_from_file)
except Exception as e:
logger.error(f"Error processing file '{filename}': {e}", exc_info=True)
logger.info(f"Processed {len(txt_files)} files, generated {len(all_final_chunks)} final chunks.")
# Log kiểm tra cuối cùng trước khi trả về
for i, chk in enumerate(all_final_chunks[:3]): # Log 3 chunk đầu tiên
logger.debug(f"Final Chunk {i} ID: {chk.id if hasattr(chk, 'id') else 'NO ID ATTR'}, Metadata: {chk.metadata}")
if not hasattr(chk, 'id') or not chk.id:
logger.error(f"!!! FINAL CHECK: Chunk {i} from {chk.metadata.get('source')} is missing valid ID attribute before returning from load_process_and_split_documents.")
return all_final_chunks
def process_single_file(file_path: str) -> List[Document]:
"""
PHIÊN BẢN CUỐI CÙNG: Pipeline xử lý hoàn chỉnh cho một file duy nhất,
với thứ tự xử lý được tối ưu hóa.
"""
filename = os.path.basename(file_path)
logger.info(f"--- Starting Full Processing Pipeline for: {filename} ---")
try:
# --- BƯỚC 1: ĐỌC FILE ---
with open(file_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
if not raw_content.strip():
logger.warning(f"File '{filename}' is empty. Skipping.")
return []
# --- BƯỚC 2: LÀM SẠCH VĂN BẢN ---
# Việc làm sạch trước giúp các bước sau hoạt động chính xác hơn
cleaned_content = clean_document_text(raw_content)
if not cleaned_content.strip():
logger.warning(f"File '{filename}' is empty after cleaning. Skipping.")
return []
logger.debug(f"File '{filename}' cleaned successfully.")
# --- BƯỚC 3: TRÍCH XUẤT METADATA CẤP VĂN BẢN ---
# Trích xuất từ nội dung đã được làm sạch
doc_metadata = extract_document_metadata(cleaned_content, filename)
doc_metadata["source"] = filename
logger.debug(f"Extracted document metadata for '{filename}': so_hieu={doc_metadata.get('so_hieu')}, loai_van_ban={doc_metadata.get('loai_van_ban')}")
# --- BƯỚC 4: SUY LUẬN LĨNH VỰC ---
# Dựa trên nội dung sạch và tiêu đề đã trích xuất
doc_metadata["field"] = infer_field(cleaned_content, doc_metadata.get("ten_van_ban"))
logger.debug(f"Inferred field for '{filename}': {doc_metadata['field']}")
# --- BƯỚC 5: TẠO ĐỐI TƯỢNG DOCUMENT VÀ CHIA CHUNK ---
doc_to_split = Document(page_content=cleaned_content, metadata=doc_metadata)
# Gọi hàm chia chunk phiên bản cuối cùng
chunks_from_file = hierarchical_split_law_document(doc_to_split)
if not chunks_from_file:
logger.warning(f"File '{filename}' did not yield any chunks after processing.")
else:
logger.info(f"✅ Successfully processed '{filename}', generated {len(chunks_from_file)} chunks.")
return chunks_from_file
except Exception as e:
logger.error(f"❌ A critical error occurred while processing file '{filename}': {e}", exc_info=True)
return []