File size: 13,044 Bytes
83892b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
"""

cleaner.py

──────────

Cleans raw Romanian legal text after scraping.



The problems we solve:

  1. Diacritic inconsistency

     Romanian has TWO Unicode encodings for ș and ț that look identical

     on screen but are DIFFERENT bytes. This breaks NLP models.

       - Correct modern form:  ș (U+015F),  ț (U+0163)

       - Legacy cedilla form:  ş (U+015E),  ţ (U+0162)  ← used on old RO sites

     We always normalize to the correct modern form.



  2. Encoding mojibake

     The portal sometimes serves pages with the wrong encoding header,

     which turns "ș" into garbage like "ÅŸ". The ftfy library fixes this.



  3. Site boilerplate

     Every page from the portal contains the same navigation text, browser

     upgrade warnings, copyright notices, etc. We strip all of it.



  4. Amendment noise

     Romanian laws are heavily amended. Many articles contain only a list

     of "modified by Law X, completed by OUG Y..." references with no

     actual legal text. These are useless for RAG and we filter them out.



  5. Abrogated articles

     Repealed articles only say "Abrogat" — we skip those.



  6. Footnote separators

     The portal adds "─────" lines followed by footnote text.

     We remove everything from the separator onwards.



Install: pip install ftfy

"""

import re
import unicodedata

# ftfy = "fixes text for you" — a library that repairs encoding problems
try:
    import ftfy
    HAS_FTFY = True
except ImportError:
    HAS_FTFY = False
    print("Tip: install ftfy for better encoding fixes: pip install ftfy")


# ── Diacritic normalization ───────────────────────────────────────────────────
# The two forms look the same in your editor but are different Unicode code points.
# Embeddings treat them as completely different characters — this MUST be fixed.
DIACRITIC_MAP = {
    "ş": "ș",       # U+015E → U+015F  (s with cedilla → s with comma below)
    "ţ": "ț",       # U+0162 → U+0163  (t with cedilla → t with comma below)
    "Ş": "Ș",       # uppercase versions
    "Ţ": "Ț",
    "\u015e": "Ș",  # explicit code point versions (same chars, just to be safe)
    "\u015f": "ș",
    "\u0162": "Ț",
    "\u0163": "ț",
}


# ── Boilerplate patterns ──────────────────────────────────────────────────────
# These strings appear on EVERY page from the portal — they are site UI,
# not law text. We strip them with regex.
_BOILERPLATE_PATTERNS = [
    # Browser upgrade warning banner
    r"A fost lansata versiunea Beta.*?corect\.",
    r"Datorita faptului ca folositi.*?corect!",
    r"De ce sa actualizez browserul\?.*?securitate\.",
    r"Browserele invechite.*?securitate\.",
    # Navigation links
    r"Reveniti in topul paginii",
    r"Forma printabilă",
    # Law metadata header (we already have this from the title)
    r"EMITENT\s*\n.*?\n",
    r"Publicat în\s*\n.*?\n",
    r"MONITORUL OFICIAL.*?\n",
    # Site identity strings
    r"Portal Legislativ",
    r"legislatie\.just\.ro",
    # Copyright footer
    r"©\s*\d{4}.*?rezervate\.",
    r"Conținutul acestui material.*?României\.",
]

# Compile all patterns into one big regex (compiled once = faster)
_BOILERPLATE_RE = re.compile(
    "|".join(_BOILERPLATE_PATTERNS),
    re.IGNORECASE | re.DOTALL,
)

# Lines to remove from article text (exact phrase matches)
_BLACKLIST_LINES = {
    "Reveniti in topul paginii",
    "Forma printabilă",
    "EMITENT",
    "PARLAMENTUL ROMÂNIEI",
    "PARLAMENTUL",
    "GUVERNUL",
    "GUVERNUL ROMÂNIEI",
    "PREȘEDINTELE ROMÂNIEI",
    "MONITORUL OFICIAL",
    "Portal Legislativ",
    "Pagina de start",
}

# Keywords that signal an article is ONLY an amendment reference list,
# not actual legal text. If more than half the lines contain these,
# the article is dropped.
_AMENDMENT_KEYWORDS = [
    "abrogată",
    "abrogat",
    "respinsă",
    "modificat prin",
    "completat prin",
    "înlocuit prin",
    "republicată",
]


# ── Individual cleaning functions ─────────────────────────────────────────────

def fix_encoding(text: str) -> str:
    """

    Fix garbled characters caused by wrong encoding detection.



    When a server says a page is UTF-8 but it's actually ISO-8859-2,

    characters get scrambled. For example:

        "ș" might appear as "Å£" or "ÅŸ"



    ftfy detects and repairs these patterns automatically.

    Without ftfy, we at least normalize to NFC (Unicode composed form).

    """
    if HAS_FTFY:
        return ftfy.fix_text(text)
    return unicodedata.normalize("NFC", text)


def fix_diacritics(text: str) -> str:
    """

    Replace legacy cedilla forms with the correct comma-below forms.



    This is the single most important cleaning step for Romanian NLP.

    Without it, the same word might be stored two different ways in

    your vector index, causing missed matches during retrieval.

    """
    for wrong, correct in DIACRITIC_MAP.items():
        text = text.replace(wrong, correct)
    return text


def remove_boilerplate(text: str) -> str:
    """Remove portal navigation/UI text from the full document."""
    return _BOILERPLATE_RE.sub("", text)


def normalize_whitespace(text: str) -> str:
    """

    Tidy up whitespace without destroying paragraph structure.



    What we do:

      - Replace tabs and non-breaking spaces (\\xa0) with regular spaces

      - Collapse 2+ spaces into one space

      - Collapse 3+ newlines into 2 (= one blank line between paragraphs)

      - Strip leading/trailing whitespace

    """
    text = text.replace("\t", " ").replace("\xa0", " ")
    text = re.sub(r" {2,}", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


def is_amendment_only(text: str) -> bool:
    """

    Return True if this article contains nothing but amendment references.



    An amendment-only article looks like:

        "Articolul 5 a fost modificat prin Legea 40/2011.

         Articolul 5 a fost completat prin OUG 53/2017."



    These tell us the law changed, but not what it says now.

    They're useless for a RAG system that needs the actual legal text.



    We check: if more than 50% of the non-empty lines contain amendment

    keywords, we consider the article as amendment-only and drop it.

    """
    lines = [l.strip() for l in text.split("\n") if l.strip()]
    if not lines:
        return True  # empty article = also drop it

    amendment_line_count = sum(
        1 for line in lines
        if any(keyword in line.lower() for keyword in _AMENDMENT_KEYWORDS)
    )

    return (amendment_line_count / len(lines)) > 0.5


def clean_article_text(text: str) -> str:
    """

    Clean the text of a single article.



    Steps performed:

      1. Remove blacklisted boilerplate lines

      2. Remove injected law title headers (the site sometimes repeats

         the law title inside each article's HTML section)

      3. Remove amendment history blocks at the top of an article

         (these start with "***) Note: ..." or similar footnote markers)

      4. Remove everything after the footnote separator line (─────)

      5. Normalize whitespace

    """
    # Step 1: Remove blacklisted whole-line phrases
    lines = text.split("\n")
    lines = [
        line for line in lines
        if not any(phrase in line for phrase in _BLACKLIST_LINES)
    ]
    text = "\n".join(lines)

    # Step 2: Remove law title headers that the portal injects
    # e.g. "LEGE nr. 53 din 28 iunie 2003\n" injected inside an article
    text = re.sub(
        r"LEGE\s+nr\.\s*\d+.*?(?=\n[A-ZĂÎȘȚ\(]|\Z)",
        "",
        text,
        flags=re.DOTALL | re.IGNORECASE,
    )
    text = re.sub(
        r"ORDONAN[ȚT][AĂ]\s+(?:DE\s+URGEN[ȚT][AĂ]\s+)?nr\.\s*\d+.*?(?=\n[A-ZĂÎȘȚ\(]|\Z)",
        "",
        text,
        flags=re.DOTALL | re.IGNORECASE,
    )

    # Step 3: Remove footnote marker blocks at the top
    # These look like: "**) NOTA: ..."
    text = re.sub(r"^\*+\).*?\n\n", "", text, flags=re.DOTALL)

    # Step 4: Remove footnote separator lines and everything after them
    # The portal adds "───────────────" lines before footnotes
    text = re.sub(r"[-─]{5,}.*", "", text, flags=re.DOTALL)

    # Step 5: Clean up whitespace
    return normalize_whitespace(text)


# ── Main cleaning function ────────────────────────────────────────────────────

def clean_law(law: dict) -> dict:
    """

    Apply all cleaning steps to a scraped law dictionary.



    Input (from html_scraper.scrape_law):

    {

        "id": 109567,

        "title": "LEGE 53 28/06/2003",

        "url": "...",

        "article_count": 298,

        "articles": [

            {"number": "Articolul 1", "text": "raw text..."},

            ...

        ],

        "raw_text": "full raw text..."

    }



    Output: same structure, but with cleaned text in every field.

    Articles that are empty, too short, or amendment-only are filtered out.

    """

    # ── Clean the title ───────────────────────────────────────────────────────
    title = re.sub(r"\s+", " ", law.get("title", "")).strip()

    # ── Clean the full raw text ───────────────────────────────────────────────
    raw = law.get("raw_text", "")
    raw = fix_encoding(raw)
    raw = fix_diacritics(raw)
    raw = remove_boilerplate(raw)
    raw = normalize_whitespace(raw)

    # ── Clean each article ────────────────────────────────────────────────────
    cleaned_articles = []

    for article in law.get("articles", []):
        text = article.get("text", "")

        # Apply encoding and diacritic fixes first
        text = fix_encoding(text)
        text = fix_diacritics(text)

        # Apply structural cleaning
        text = clean_article_text(text)

        # Skip articles that became too short after cleaning
        # (80 characters is roughly one short sentence — below that it's noise)
        if len(text) < 80:
            continue

        # Skip articles that only contain amendment history (no real legal text)
        if is_amendment_only(text):
            continue

        cleaned_articles.append({
            "number": article["number"],
            "text":   text,
        })

    print(f"  Cleaned: {len(law.get('articles', []))} articles → "
          f"{len(cleaned_articles)} kept after filtering.")

    return {
        **law,
        "title":         title,
        "raw_text":      raw,
        "articles":      cleaned_articles,
        "article_count": len(cleaned_articles),
    }


# ── Quick test ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    sample = {
        "id": 1,
        "title": "  Test Law  ",
        "url": "http://example.com",
        "raw_text": "Reveniti in topul paginii\nArticolul 1\nSalariaţii au dreptul la concediu.",
        "articles": [
            {
                "number": "Articolul 1",
                "text": (
                    "Salariaţii au dreptul la concediu de odihnă anual plătit.\n"
                    "─────────────────\n"
                    "Modificat prin Legea 40/2011."
                )
            },
            {
                "number": "Articolul 2",
                "text": "modificat prin Legea 1/2020\ncomplet prin OUG 2/2021"
            },
        ],
    }

    result = clean_law(sample)

    print(f"\nTitle: '{result['title']}'")
    print(f"Articles kept: {result['article_count']}")
    for art in result["articles"]:
        print(f"\n  [{art['number']}]")
        print(f"  '{art['text']}'")

    # Expected output:
    # Title: 'Test Law'
    # Articles kept: 1
    # [Articolul 1]
    # 'Salariații au dreptul la concediu de odihnă anual plătit.'
    # (Articolul 2 dropped — amendment only)
    # (footnote separator and everything after it removed from Articolul 1)