Legora / db /parsers /bns /clause_parser.py
sai-Rohan's picture
combined results of all parsers and made a single pipeline
dbabef2
Raw
History Blame Contribute Delete
11.8 kB
from __future__ import annotations
import re
from typing import List
from db.parsers.bns.cleaner import BNSTextCleaner
from db.parsers.bns.legal_models import (
Clause,
SubClause,
RomanClause
)
class ClauseParser:
"""
BNS Clause Parser
Supports:
(1)
(2)
(2A)
Sub Clauses:
(a)
(b)
Roman Clauses:
(i)
(ii)
(iii)
"""
# =====================================================
# REGEX
# =====================================================
CLAUSE_RE = re.compile(
r"(?m)^(?:\s*)\((\d+[A-Za-z]?)\)"
)
SUBCLAUSE_RE = re.compile(
r"(?m)^\s*\(([a-z])\)"
)
ROMAN_RE = re.compile(
r"(?m)^\s*\(([ivxlcdm]+)\)"
)
# =====================================================
# SPLIT SECTIONS
# =====================================================
def split_sections(
self,
text: str,
pattern: re.Pattern
):
matches = list(
pattern.finditer(text)
)
if not matches:
return []
sections = []
for i, match in enumerate(matches):
start = match.start()
end = (
matches[i + 1].start()
if i + 1 < len(matches)
else len(text)
)
sections.append(
(
match.group(1),
text[start:end].strip()
)
)
return sections
# =====================================================
# ROMAN CLAUSES
# =====================================================
def parse_roman_clauses(
self,
text: str
) -> List[RomanClause]:
romans = []
roman_sections = (
self.split_sections(
text,
self.ROMAN_RE
)
)
for roman_no, roman_text in roman_sections:
romans.append(
RomanClause(
document="bns",
roman_no=roman_no,
text=roman_text
)
)
return romans
# =====================================================
# SUB CLAUSES
# =====================================================
ROMAN_VALUES = {
"i", "ii", "iii", "iv", "v",
"vi", "vii", "viii", "ix", "x",
"xi", "xii", "xiii", "xiv", "xv",
"xvi", "xvii", "xviii", "xix", "xx"
}
def parse_subclauses(
self,
text: str
) -> List[SubClause]:
subclauses = []
sub_sections = (
self.split_sections(
text,
self.SUBCLAUSE_RE
)
)
for sub_no, sub_text in sub_sections:
# Skip roman numerals
if (
sub_no.lower()
in self.ROMAN_VALUES
):
continue
sub = SubClause(
document="bns",
sub_clause_no=sub_no,
text=sub_text
)
sub.roman_clauses.extend(
self.parse_roman_clauses(
sub_text
)
)
subclauses.append(
sub
)
return subclauses
# =====================================================
# CLAUSES
# =====================================================
def parse_clauses(
self,
section_text: str
) -> List[Clause]:
# Normalize inline markers
section_text = re.sub(
r"([;:—])\(([a-z])\)",
r"\1\n(\2)",
section_text
)
section_text = re.sub(
r"([.—])\((\d+[A-Za-z]?)\)",
r"\1\n(\2)",
section_text
)
clauses = []
clause_sections = self.split_sections(
section_text,
self.CLAUSE_RE
)
def remove_non_clause_parts(
text: str
) -> str:
stop_words = [
"Explanation.",
"Explanation.—",
"Explanation.––",
"Illustration.",
"Illustrations.",
"Exception.",
"Exception.—"
]
cutoff = len(text)
for word in stop_words:
pos = text.find(word)
if pos != -1:
cutoff = min(
cutoff,
pos
)
return text[:cutoff]
# ----------------------------------
# No numbered clauses
# ----------------------------------
if not clause_sections:
clean_text = remove_non_clause_parts(
section_text
)
subclauses = self.parse_subclauses(
clean_text
)
if subclauses:
synthetic_clause = Clause(
document="bns",
clause_no="0",
text=section_text
)
synthetic_clause.sub_clauses.extend(
subclauses
)
clauses.append(
synthetic_clause
)
return clauses
# ----------------------------------
# Numbered clauses
# ----------------------------------
for clause_no, original_clause_text in clause_sections:
clause = Clause(
document="bns",
clause_no=clause_no,
text=original_clause_text
)
clean_clause_text = (
remove_non_clause_parts(
original_clause_text
)
)
clause.sub_clauses.extend(
self.parse_subclauses(
clean_clause_text
)
)
# Handle direct roman clauses
if not clause.sub_clauses:
clause.roman_clauses.extend(
self.parse_roman_clauses(
clean_clause_text
)
)
clauses.append(
clause
)
return clauses
def parse_section_structure(
self,
section_text: str
) -> List[Clause]:
has_numeric = bool(
self.NUMERIC_RE.search(
section_text
)
)
has_alpha = bool(
self.ALPHA_RE.search(
section_text
)
)
has_capital = bool(
self.CAPITAL_RE.search(
section_text
)
)
has_roman = bool(
self.ROMAN_RE.search(
section_text
)
)
# =====================================
# PLAIN SECTION
# =====================================
if (
not has_numeric
and not has_alpha
and not has_capital
and not has_roman
):
return []
# =====================================
# ALPHA ONLY
# (a)(b)(c)
# =====================================
if (
not has_numeric
and has_alpha
and not has_capital
and not has_roman
):
return self.parse_alpha_clauses(
section_text
)
# =====================================
# NUMERIC ONLY
# (1)(2)(3)
# =====================================
if (
has_numeric
and not has_alpha
and not has_capital
and not has_roman
):
return self.parse_numeric_clauses(
section_text
)
# =====================================
# NUMERIC -> ALPHA
# =====================================
if (
has_numeric
and has_alpha
and not has_capital
and not has_roman
):
return self.parse_numeric_alpha(
section_text
)
# =====================================
# NUMERIC -> ALPHA -> ROMAN
# =====================================
if (
has_numeric
and has_alpha
and not has_capital
and has_roman
):
return self.parse_numeric_alpha_roman(
section_text
)
# =====================================
# ALPHA -> ROMAN
# (a)
# (i)
# =====================================
if (
not has_numeric
and has_alpha
and not has_capital
and has_roman
):
return self.parse_alpha_roman(
section_text
)
# =====================================
# CAPITAL -> ROMAN
# (A)
# (i)
# =====================================
if (
not has_numeric
and has_alpha
and has_capital
and has_roman
):
return self.parse_capital_roman(
section_text
)
return []
# =====================================================
# VALIDATION
# =====================================================
def validate_clauses(
self,
clauses: List[Clause]
) -> List[str]:
errors = []
seen = set()
for clause in clauses:
# if clause.clause_no in seen:
# print(
# "\nDUPLICATE CLAUSE FOUND"
# )
# print(
# "Clause:",
# clause.clause_no
# )
# print(
# clause.text[:500]
# )
seen.add(
clause.clause_no
)
sub_seen = set()
for sub in clause.sub_clauses:
if sub.sub_clause_no in sub_seen:
errors.append(
f"Duplicate SubClause "
f"{sub.sub_clause_no}"
)
sub_seen.add(
sub.sub_clause_no
)
roman_seen = set()
for roman in sub.roman_clauses:
if roman.roman_no in roman_seen:
errors.append(
f"Duplicate Roman "
f"{roman.roman_no}"
)
roman_seen.add(
roman.roman_no
)
return errors
# =========================================================
# TEST
# =========================================================
if __name__ == "__main__":
with open(
"../../pdfs/bns.txt",
"r",
encoding="utf8"
) as f:
text = f.read()
cleaner=BNSTextCleaner()
text=cleaner.clean(text)
parser = ClauseParser()
print(len(parser.CLAUSE_RE.findall(text)))
print(len(parser.SUBCLAUSE_RE.findall(text)))
print(len(parser.ROMAN_RE.findall(text)))
clauses = parser.parse_clauses(
text
)
# print(
# "Clauses:",
# len(clauses)
# )
# for clause in clauses:
# print(
# clause.clause_no,
# len(clause.sub_clauses)
# )
print(
parser.validate_clauses(
clauses
)
)