hcmue-handbook-rag-api / src /preprocessing /structure_parser.py
HCMUE RAG Deploy
Deploy FastAPI RAG backend
75dea23
Raw
History Blame Contribute Delete
17.5 kB
import json
import re
from pathlib import Path
from typing import Any, Optional
import yaml
CONFIG_PATH = Path("configs/structure_parser.yaml")
NO_CHAPTER = "__NO_CHAPTER__"
MAX_NON_ARTICLE_CHARS = 1200
PART_PATTERN = re.compile(r"^PHẦN\s+[IVXLCDM]+", re.IGNORECASE)
CHAPTER_PATTERN = re.compile(r"^Chương\s+[IVXLCDM]+", re.IGNORECASE)
ARTICLE_PATTERN = re.compile(r"^Điều\s+(\d+)\.\s*(.*)", re.IGNORECASE)
CLAUSE_PATTERN = re.compile(r"^\d+\.\s+")
POINT_PATTERN = re.compile(r"^[a-zđ]\)\s+", re.IGNORECASE)
DOCUMENT_TITLE_PATTERNS = [
re.compile(r"^QUYẾT ĐỊNH\b", re.IGNORECASE),
re.compile(r"^QUY CHẾ\b", re.IGNORECASE),
re.compile(r"^QUY ĐỊNH\b", re.IGNORECASE),
re.compile(r"^PHỤ LỤC\b", re.IGNORECASE),
re.compile(r"^HƯỚNG DẪN\b", re.IGNORECASE),
]
def load_json(path: Path) -> Any:
if not path.exists():
raise FileNotFoundError(f"Missing file: {path}")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(data: Any, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"Missing config file: {path}")
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def normalize_line(line: str) -> str:
line = line.strip()
line = re.sub(r"\s+", " ", line)
return line
def is_heading_like(line: str) -> bool:
if len(line) > 140:
return False
letters = [ch for ch in line if ch.isalpha()]
if not letters:
return False
uppercase_letters = [ch for ch in letters if ch.isupper()]
uppercase_ratio = len(uppercase_letters) / max(len(letters), 1)
return uppercase_ratio >= 0.55
def classify_line(line: str) -> str:
line = normalize_line(line)
if not line:
return "empty"
if PART_PATTERN.match(line):
return "part"
if CHAPTER_PATTERN.match(line):
return "chapter"
if ARTICLE_PATTERN.match(line):
return "article"
if CLAUSE_PATTERN.match(line):
return "clause"
if POINT_PATTERN.match(line):
return "point"
if is_heading_like(line):
for pattern in DOCUMENT_TITLE_PATTERNS:
if pattern.search(line):
return "document_title"
return "normal_text"
def pages_to_line_records(
pages: list[dict[str, Any]],
target_content_types: list[str],
) -> list[dict[str, Any]]:
line_records = []
for page in pages:
content_type = page.get("content_type")
if content_type not in target_content_types:
continue
page_number = page["page_number"]
text = page.get("text", "")
for line_index, line in enumerate(text.splitlines(), start=1):
clean_line = normalize_line(line)
if not clean_line:
continue
line_records.append(
{
"page_number": page_number,
"line_index": line_index,
"content_type": content_type,
"line": clean_line,
"line_type": classify_line(clean_line),
}
)
return line_records
def extract_article_info(line: str) -> tuple[str, str, int]:
match = ARTICLE_PATTERN.match(line)
if not match:
raise ValueError(f"Invalid article line: {line}")
article_number = int(match.group(1))
article = f"Điều {article_number}."
title = line
return article, title, article_number
def slugify_text(text: str) -> str:
text = text.lower()
text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)
text = re.sub(r"\s+", "_", text)
return text.strip("_")
def make_section_id(
section_level: str,
page_start: int,
index: int,
article_number: Optional[int] = None,
content_type: Optional[str] = None,
) -> str:
if article_number is not None:
return f"article_{article_number}_p{page_start}_{index}"
prefix = content_type or section_level
prefix = slugify_text(prefix)
return f"{prefix}_p{page_start}_{index}"
def detect_has_table(content: str) -> bool:
lower = content.lower()
table_patterns = [
r"loại\s+thang điểm\s+10\s+thang điểm chữ",
r"thang điểm chữ\s+thang điểm\s+4",
r"tt\s+khung điểm\s+xếp loại",
r"nội dung đánh giá\s+khung điểm\s+điểm đánh giá",
r"chương trình đào tạo\s+thời gian\s+học tập chuẩn\s+thời gian\s+học tập tối đa",
r"tổng cộng:\s*đạt loại rèn luyện",
]
return any(re.search(pattern, lower) for pattern in table_patterns)
def detect_has_formula(content: str) -> bool:
lower = content.lower()
formula_patterns = [
r"điểm học bổng\s*=",
r"\ba\s+là\s+điểm trung bình",
r"\b[a-zA-Z]\s*=\s*",
r"\(.+\s*[+\-*/x]\s*.+\)",
r"\d+\s*[x*/]\s*\d+",
r"/\s*\d+",
]
return any(re.search(pattern, lower) for pattern in formula_patterns)
def detect_has_scoring_rule(content: str) -> bool:
lower = content.lower()
scoring_keywords = [
"thang điểm",
"điểm thành phần",
"điểm học phần",
"điểm học bổng",
"điểm rèn luyện",
"điểm trung bình",
"xếp loại",
"khung điểm",
"học bổng loại",
]
return any(keyword in lower for keyword in scoring_keywords)
def detect_has_thresholds(content: str) -> bool:
lower = content.lower()
threshold_patterns = [
r"từ\s+\d+([,.]\d+)?\s+đến",
r"dưới\s+\d+([,.]\d+)?",
r"trở lên",
r">=\s*\d+([,.]\d+)?",
r"\d+([,.]\d+)?\s*[-–]\s*\d+([,.]\d+)?",
r"không vượt quá\s+\d+",
r"ít nhất\s+\d+",
r"tối đa\s+\d+",
r"tối thiểu\s+\d+",
]
return any(re.search(pattern, lower) for pattern in threshold_patterns)
def detect_needs_structured_extraction(content: str, content_type: str) -> bool:
return (
content_type == "scoring_form_table"
or detect_has_table(content)
or detect_has_formula(content)
or detect_has_thresholds(content)
)
def resolve_chapter(chapter: Optional[str]) -> str:
return chapter if chapter else NO_CHAPTER
def create_section(
section_level: str,
page_number: int,
content_type: str,
index: int,
document_title: Optional[str],
part: Optional[str],
chapter: Optional[str],
article: Optional[str],
title: str,
article_number: Optional[int] = None,
) -> dict[str, Any]:
return {
"section_id": make_section_id(
section_level=section_level,
page_start=page_number,
index=index,
article_number=article_number,
content_type=content_type,
),
"section_level": section_level,
"document_title": document_title,
"part": part,
"chapter": resolve_chapter(chapter),
"article": article,
"title": title,
"content_type": content_type,
"page_start": page_number,
"page_end": page_number,
"content_lines": [],
"pages": [],
}
def close_section(
current_section: Optional[dict[str, Any]],
sections: list[dict[str, Any]],
) -> None:
if current_section is None:
return
content_lines = current_section.get("content_lines", [])
content = "\n".join(content_lines).strip()
if not content:
return
pages = current_section.get("pages", [])
current_section["content"] = content
current_section["page_end"] = max(pages) if pages else current_section["page_start"]
current_section["has_table"] = detect_has_table(content)
current_section["has_formula"] = detect_has_formula(content)
current_section["has_scoring_rule"] = detect_has_scoring_rule(content)
current_section["has_thresholds"] = detect_has_thresholds(content)
current_section["needs_structured_extraction"] = detect_needs_structured_extraction(
content=content,
content_type=current_section["content_type"],
)
current_section.pop("content_lines", None)
current_section.pop("pages", None)
sections.append(current_section)
def should_close_on_content_type_change(
current_section: Optional[dict[str, Any]],
new_content_type: str,
) -> bool:
if current_section is None:
return False
return current_section.get("content_type") != new_content_type
def should_split_long_non_article_section(
current_section: Optional[dict[str, Any]],
) -> bool:
if current_section is None:
return False
if current_section.get("section_level") != "non_article":
return False
current_content = "\n".join(current_section.get("content_lines", []))
return len(current_content) >= MAX_NON_ARTICLE_CHARS
def build_structured_sections(
line_records: list[dict[str, Any]],
) -> list[dict[str, Any]]:
sections: list[dict[str, Any]] = []
current_document_title: Optional[str] = None
current_part: Optional[str] = None
current_chapter: Optional[str] = None
current_section: Optional[dict[str, Any]] = None
section_index = 1
for record in line_records:
line = record["line"]
line_type = record["line_type"]
page_number = record["page_number"]
content_type = record["content_type"]
if should_close_on_content_type_change(current_section, content_type):
close_section(current_section, sections)
current_section = None
if line_type == "document_title":
close_section(current_section, sections)
current_section = None
current_document_title = line
current_part = None
current_chapter = None
continue
if line_type == "part":
close_section(current_section, sections)
current_section = None
current_part = line
current_chapter = None
continue
if line_type == "chapter":
close_section(current_section, sections)
current_section = None
current_chapter = line
continue
if line_type == "article":
close_section(current_section, sections)
article, title, article_number = extract_article_info(line)
current_section = create_section(
section_level="article",
page_number=page_number,
content_type=content_type,
index=section_index,
document_title=current_document_title,
part=current_part,
chapter=current_chapter,
article=article,
title=title,
article_number=article_number,
)
current_section["content_lines"].append(line)
current_section["pages"].append(page_number)
section_index += 1
continue
if current_section is None:
title = (
current_chapter
or current_part
or current_document_title
or f"Section page {page_number}"
)
current_section = create_section(
section_level="non_article",
page_number=page_number,
content_type=content_type,
index=section_index,
document_title=current_document_title,
part=current_part,
chapter=current_chapter,
article=None,
title=title,
)
section_index += 1
current_section["content_lines"].append(line)
current_section["pages"].append(page_number)
if should_split_long_non_article_section(current_section):
close_section(current_section, sections)
current_section = None
close_section(current_section, sections)
return sections
def validate_sections(sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues = []
seen_ids = set()
for section in sections:
section_id = section["section_id"]
if section_id in seen_ids:
issues.append(
{
"section_id": section_id,
"issue": "duplicate_section_id",
"severity": "high",
}
)
seen_ids.add(section_id)
if section["page_end"] < section["page_start"]:
issues.append(
{
"section_id": section_id,
"issue": "page_end_before_page_start",
"severity": "high",
}
)
if (
section["content_type"] == "regulation_text"
and section["section_level"] == "non_article"
and len(section["content"]) > MAX_NON_ARTICLE_CHARS + 300
):
issues.append(
{
"section_id": section_id,
"issue": "long_non_article_regulation_section",
"severity": "medium",
"page_start": section["page_start"],
"page_end": section["page_end"],
"content_length": len(section["content"]),
}
)
return issues
def build_structure_report(sections: list[dict[str, Any]]) -> dict[str, Any]:
content_type_count: dict[str, int] = {}
section_level_count: dict[str, int] = {}
for section in sections:
content_type = section["content_type"]
section_level = section["section_level"]
content_type_count[content_type] = content_type_count.get(content_type, 0) + 1
section_level_count[section_level] = (
section_level_count.get(section_level, 0) + 1
)
validation_issues = validate_sections(sections)
return {
"total_sections": len(sections),
"total_article_sections": sum(
1 for s in sections if s["section_level"] == "article"
),
"total_non_article_sections": sum(
1 for s in sections if s["section_level"] == "non_article"
),
"content_type_count": content_type_count,
"section_level_count": section_level_count,
"sections_with_tables": [
{
"section_id": s["section_id"],
"title": s["title"],
"page_start": s["page_start"],
"page_end": s["page_end"],
}
for s in sections
if s["has_table"]
],
"sections_with_formulas": [
{
"section_id": s["section_id"],
"title": s["title"],
"page_start": s["page_start"],
"page_end": s["page_end"],
}
for s in sections
if s["has_formula"]
],
"sections_with_scoring_rules": [
{
"section_id": s["section_id"],
"title": s["title"],
"page_start": s["page_start"],
"page_end": s["page_end"],
}
for s in sections
if s["has_scoring_rule"]
],
"sections_with_thresholds": [
{
"section_id": s["section_id"],
"title": s["title"],
"page_start": s["page_start"],
"page_end": s["page_end"],
}
for s in sections
if s["has_thresholds"]
],
"sections_need_structured_extraction": [
{
"section_id": s["section_id"],
"title": s["title"],
"content_type": s["content_type"],
"page_start": s["page_start"],
"page_end": s["page_end"],
}
for s in sections
if s["needs_structured_extraction"]
],
"validation_issues": validation_issues,
}
def main() -> None:
config = load_yaml(CONFIG_PATH)
pages_path = Path(config["input"]["pages"])
pages = load_json(pages_path)
target_content_types = config["target_content_types"]
line_records = pages_to_line_records(
pages=pages,
target_content_types=target_content_types,
)
structured_sections = build_structured_sections(line_records)
structure_report = build_structure_report(structured_sections)
save_json(line_records, Path(config["output"]["line_records"]))
save_json(structured_sections, Path(config["output"]["structured_sections"]))
save_json(structure_report, Path(config["output"]["structure_report"]))
print("Structure parsing completed.")
print(f"Line records: {len(line_records)}")
print(f"Structured sections: {structure_report['total_sections']}")
print(f"Article sections: {structure_report['total_article_sections']}")
print(f"Non-article sections: {structure_report['total_non_article_sections']}")
print(f"Validation issues: {len(structure_report['validation_issues'])}")
if __name__ == "__main__":
main()