-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathchunker.py
More file actions
225 lines (194 loc) · 7.88 KB
/
Copy pathchunker.py
File metadata and controls
225 lines (194 loc) · 7.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""
chunker.py
Splits documents from unified_corpus.jsonl into retrieval-ready chunks.
Novel strategy: Rhetorical role-aware chunking for case documents.
- Detect role boundaries using regex patterns
- Each chunk is tagged with its rhetorical role
- Task-specific role weights applied during retrieval scoring:
case retrieval → RATIO + ANALYSIS chunks boosted
statute retrieval → STATUTE_REF + ANALYSIS chunks boosted
Run:
python preprocessing/chunker.py
"""
import re
import json
from pathlib import Path
from tqdm import tqdm
BASE_DIR = Path(__file__).resolve().parent.parent
PROC_DIR = BASE_DIR / "data" / "processed"
IN_FILE = PROC_DIR / "unified_corpus.jsonl"
OUT_FILE = PROC_DIR / "chunked_corpus.jsonl"
# ── Rhetorical role patterns ───────────────────────────────────────────────────
ROLE_PATTERNS = [
("FACTS", re.compile(
r'\b(facts?|background|brief facts?|factual background|case background|'
r'brief background|facts? of the case)\b', re.IGNORECASE)),
("ARGUMENTS", re.compile(
r'\b(argued?|submitted?|contended?|counsel.*?submits?|learned counsel|'
r'argument[s]? on behalf|it is argued|petitioner.*?submits?|respondent.*?submits?)\b',
re.IGNORECASE)),
("ANALYSIS", re.compile(
r'\b(we have considered|having considered|on consideration|'
r'in our (view|opinion)|the court (holds?|finds?|observes?)|'
r'we (hold|find|observe|are of the view))\b', re.IGNORECASE)),
("RATIO", re.compile(
r'\b(ratio decidendi|the law (is|has been)|it is (settled|well.settled|established)|'
r'it is a settled (law|proposition|principle)|legal proposition|'
r'principle of law|this court has (held|laid down))\b', re.IGNORECASE)),
("STATUTE_REF", re.compile(
r'\b(section \d+|article \d+|IPC|CrPC|CPC|Constitution of India|'
r'under (the )?act|under section|by virtue of)\b', re.IGNORECASE)),
("RULING", re.compile(
r'\b(accordingly|in the result|for (the )?foregoing reasons|'
r'the appeal (is|stands?)|the petition (is|stands?)|we (allow|dismiss|'
r'partly allow)|order accordingly|disposed? of|set aside)\b', re.IGNORECASE)),
]
# ── Base role weights (used by BM25 retriever) ────────────────────────────────
ROLE_WEIGHTS = {
"RATIO": 1.5,
"ANALYSIS": 1.3,
"RULING": 1.2,
"STATUTE_REF": 1.2,
"ARGUMENTS": 1.0,
"FACTS": 0.9,
"GENERAL": 0.8,
}
# ── Task-specific weights (used by dense + hybrid retriever) ──────────────────
# Informed by Kalamkar et al. (LREC 2022): ratio/analysis most useful for
# case retrieval; statute references most useful for statute retrieval.
TASK_WEIGHTS = {
"case": {
"RATIO": 1.6,
"ANALYSIS": 1.4,
"RULING": 1.2,
"STATUTE_REF": 1.0,
"ARGUMENTS": 1.0,
"FACTS": 0.8,
"GENERAL": 0.7,
},
"statute": {
"STATUTE_REF": 1.6,
"ANALYSIS": 1.3,
"RATIO": 1.2,
"RULING": 1.1,
"ARGUMENTS": 0.9,
"FACTS": 0.7,
"GENERAL": 0.7,
},
}
MAX_CHUNK_CHARS = 1500 # ~300-400 tokens for BERT
OVERLAP_CHARS = 200 # overlap between consecutive chunks
def detect_role(text: str) -> str:
"""Return the dominant rhetorical role for a text segment."""
scores = {role: 0 for role, _ in ROLE_PATTERNS}
for role, pattern in ROLE_PATTERNS:
scores[role] = len(pattern.findall(text))
best_role = max(scores, key=scores.get)
return best_role if scores[best_role] > 0 else "GENERAL"
def split_into_sentences(text: str) -> list[str]:
"""Sentence-level split for legal text."""
# Split on ". " followed by capital letter, or newlines
sentences = re.split(r'(?<=[.!?])\s+(?=[A-Z])', text)
return [s.strip() for s in sentences if s.strip()]
def chunk_case(doc: dict) -> list[dict]:
"""
Role-aware chunking for case documents.
Groups sentences into chunks of ~MAX_CHUNK_CHARS with overlap,
detecting and tagging the dominant rhetorical role per chunk.
"""
text = doc["text"]
sentences = split_into_sentences(text)
chunks = []
current = []
current_len = 0
for sent in sentences:
if current_len + len(sent) > MAX_CHUNK_CHARS and current:
chunk_text = " ".join(current)
role = detect_role(chunk_text)
chunks.append({
"doc_id": doc["doc_id"],
"doc_type": "case",
"chunk_id": f"{doc['doc_id']}_c{len(chunks)}",
"role": role,
"weight": ROLE_WEIGHTS[role],
"title": doc.get("title", ""),
"court": doc.get("court", ""),
"date": doc.get("date", ""),
"text": chunk_text,
})
# Overlap: keep last N chars worth of sentences
overlap_text = ""
for s in reversed(current):
if len(overlap_text) + len(s) < OVERLAP_CHARS:
overlap_text = s + " " + overlap_text
else:
break
current = overlap_text.split(". ")
current_len = len(overlap_text)
current.append(sent)
current_len += len(sent) + 1
# Last chunk
if current:
chunk_text = " ".join(current)
role = detect_role(chunk_text)
chunks.append({
"doc_id": doc["doc_id"],
"doc_type": "case",
"chunk_id": f"{doc['doc_id']}_c{len(chunks)}",
"role": role,
"weight": ROLE_WEIGHTS[role],
"title": doc.get("title", ""),
"court": doc.get("court", ""),
"date": doc.get("date", ""),
"text": chunk_text,
})
return chunks
def chunk_statute(doc: dict) -> list[dict]:
"""
Statutes are short (~200-300 words) — keep as single chunk.
Title is prepended to improve semantic matching.
"""
text = f"{doc.get('title', '')}. {doc.get('text', '')}".strip()
return [{
"doc_id": doc["doc_id"],
"doc_type": "statute",
"chunk_id": f"{doc['doc_id']}_c0",
"role": "STATUTE_REF",
"weight": ROLE_WEIGHTS["STATUTE_REF"],
"title": doc.get("title", ""),
"description": doc.get("description", ""),
"text": text,
}]
def build_chunked_corpus():
all_chunks = []
case_count = 0
stat_count = 0
chunk_count = 0
with open(IN_FILE, "r", encoding="utf-8") as f:
docs = [json.loads(line) for line in f]
print(f"Chunking {len(docs)} documents...")
for doc in tqdm(docs):
if doc["doc_type"] == "case":
chunks = chunk_case(doc)
case_count += 1
else:
chunks = chunk_statute(doc)
stat_count += 1
all_chunks.extend(chunks)
chunk_count += len(chunks)
with open(OUT_FILE, "w", encoding="utf-8") as f:
for chunk in all_chunks:
f.write(json.dumps(chunk, ensure_ascii=False) + "\n")
print(f"\nChunked corpus saved → {OUT_FILE}")
print(f" Case docs: {case_count}")
print(f" Statute docs: {stat_count}")
print(f" Total chunks: {chunk_count}")
print(f" Avg chunks/case: {chunk_count / max(case_count, 1):.1f}")
# Role distribution
from collections import Counter
role_counts = Counter(c["role"] for c in all_chunks if c["doc_type"] == "case")
print("\n Role distribution (cases):")
for role, cnt in role_counts.most_common():
print(f" {role:<15} {cnt}")
if __name__ == "__main__":
build_chunked_corpus()