File size: 12,524 Bytes
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7353b3c
 
 
 
 
 
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674e77f
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674e77f
 
 
 
 
 
 
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674e77f
 
 
 
 
 
eff511c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import re
from typing import Any


ARTICLE_RE = re.compile(
    r"(?m)^\s*(?P<kind>Ek Madde|Geçici Madde|Madde)\s+"
    r"(?P<number>\d+(?:/[A-ZÇĞİÖŞÜ])?|[0-9]+[A-ZÇĞİÖŞÜ]?)\s*[–-]?"
)

SCHEDULE_RE = re.compile(
    r"(?m)^\s*(?P<title>EK\s+GÖSTERGE\s+CETVELİ|MAKAM\s+TAZMİNATI\s+CETVELİ[^\n]*)\s*:?\s*$",
    flags=re.IGNORECASE,
)

HIERARCHY_HEADING_RE = re.compile(
    r"(?m)^\s*(?P<label>[A-ZÇĞİÖŞÜ0-9]+(?:\s+[A-ZÇĞİÖŞÜ0-9]+){0,4}\s+"
    r"(?P<kind>KISIM|BÖLÜM|ALT BÖLÜM))\s*$"
)

CLAUSE_MARKER_RES = [
    # A true lettered clause is written ``a) ...``.  The letter in a
    # parenthesized cross-reference such as ``40 ıncı maddenin (b) fıkrası``
    # is not a new clause boundary; accepting ``(`` here used to cut a legal
    # sentence into two invalid normative units.
    re.compile(r"(?<![\w/(])([a-zçğıöşü])\)\s", flags=re.IGNORECASE),
    re.compile(r"(?<![\w/])([a-zçğıöşü])\.\s+(?=[A-ZÇĞİÖŞÜ])", flags=re.IGNORECASE),
    re.compile(r"\((\d+)\)\s"),
]


def parse_structural_units(text: str, document_id: str = "TR-KANUN-2547") -> dict[str, Any]:
    articles = []
    clauses = []
    schedules = []
    matches = list(ARTICLE_RE.finditer(text or ""))
    schedule_matches = list(SCHEDULE_RE.finditer(text or ""))

    for idx, match in enumerate(matches):
        start = match.start()
        end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text)
        next_schedule = _first_schedule_start_after(schedule_matches, start, end)
        if next_schedule is not None:
            end = next_schedule
        article_text = text[start:end].strip()
        article_id = f"{match.group('kind')} {match.group('number')}"
        title = _article_title(article_text, text, start)
        heading_path = _heading_path(text, start)

        article = {
            "document_id": document_id,
            "article_id": article_id,
            "article_type": _article_type(match.group("kind")),
            "title": title,
            "heading_path": heading_path,
            "article_heading": title,
            "source_span": {"char_start": start, "char_end": end},
            "source_text": article_text,
        }
        articles.append(article)

        for ordinal, clause in enumerate(split_article_clauses(article_text), start=1):
            label, c_start, c_end = clause
            clauses.append(
                {
                    "document_id": document_id,
                    "article_id": article_id,
                    "clause_id": f"{_slug(article_id)}__{ordinal:03d}_{_slug(label)}",
                    "label": label,
                    "source_span": {
                        "article_char_start": c_start,
                        "article_char_end": c_end,
                        "document_char_start": start + c_start,
                        "document_char_end": start + c_end,
                    },
                    "source_text": article_text[c_start:c_end].strip(),
                }
        )

    for idx, match in enumerate(schedule_matches):
        start = match.start()
        next_article_start = _first_article_start_after(matches, start)
        next_schedule_start = schedule_matches[idx + 1].start() if idx + 1 < len(schedule_matches) else None
        candidates = [value for value in [next_article_start, next_schedule_start, len(text)] if value is not None]
        end = min(candidates) if candidates else len(text)
        title = re.sub(r"\s+", " ", match.group("title")).strip().rstrip(":")
        schedule_text = text[start:end].strip()
        schedule_id = f"{document_id}__{_slug(title).upper()}"
        schedules.append(
            {
                "class": "ScheduleUnit",
                "document_id": document_id,
                "schedule_id": schedule_id,
                "article_id": title,
                "title": title,
                "source_span": {"char_start": start, "char_end": end},
                "source_text": schedule_text,
            }
        )

    if not articles:
        articles, clauses = _parse_generic_sections(text or "", document_id)

    return {
        "document_id": document_id,
        "article_count": len(articles),
        "clause_count": len(clauses),
        "schedule_count": len(schedules),
        "articles": articles,
        "clauses": clauses,
        "schedules": schedules,
    }


GENERIC_HEADING_RE = re.compile(
    r"(?m)^\s*(?:(?P<number>\d+(?:\.\d+){0,3})[.)-]\s+)?"
    r"(?P<title>[A-ZÇĞİÖŞÜ][^\n]{2,119})\s*$"
)


def _parse_generic_sections(text: str, document_id: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    """Parse non-legislative institutional TXT files without changing law parsing.

    Numbered headings and short uppercase headings become stable section units. If
    the document has no visible hierarchy, non-empty paragraphs become units so
    every source span remains traceable and reviewable.
    """
    headings = []
    for match in GENERIC_HEADING_RE.finditer(text):
        title = re.sub(r"\s+", " ", match.group("title")).strip().rstrip(":")
        if not _looks_like_generic_heading(title, bool(match.group("number"))):
            continue
        headings.append((match.start(), match.end(), match.group("number") or "", title))

    spans: list[tuple[str, str, int, int]] = []
    if headings:
        for index, (start, _heading_end, number, title) in enumerate(headings, start=1):
            end = headings[index][0] if index < len(headings) else len(text)
            section_id = f"Bölüm {number or index}"
            spans.append((section_id, title, start, end))
    else:
        for index, match in enumerate(re.finditer(r"(?ms)(?:^|\n\s*\n)(\S.*?)(?=\n\s*\n|\Z)", text), start=1):
            body = match.group(1).strip()
            if len(body) < 20:
                continue
            title = re.sub(r"\s+", " ", body).strip()[:120]
            spans.append((f"Birim {index}", title, match.start(1), match.end(1)))

    articles: list[dict[str, Any]] = []
    clauses: list[dict[str, Any]] = []
    for index, (article_id, title, start, end) in enumerate(spans, start=1):
        source_text = text[start:end].strip()
        article = {
            "document_id": document_id,
            "article_id": article_id,
            "article_type": "section",
            "title": title,
            "heading_path": [],
            "article_heading": title,
            "source_span": {"char_start": start, "char_end": end},
            "source_text": source_text,
        }
        articles.append(article)
        clauses.append(
            {
                "document_id": document_id,
                "article_id": article_id,
                "clause_id": f"{_slug(article_id)}__001_section",
                "label": "section",
                "source_span": {
                    "article_char_start": 0,
                    "article_char_end": len(source_text),
                    "document_char_start": start,
                    "document_char_end": end,
                },
                "source_text": source_text,
            }
        )
    return articles, clauses


def _looks_like_generic_heading(title: str, numbered: bool) -> bool:
    if numbered:
        return not title.endswith((".", ";", ","))
    words = title.split()
    if not 1 <= len(words) <= 10:
        return False
    letters = [char for char in title if char.isalpha()]
    return bool(letters) and sum(char.isupper() for char in letters) / len(letters) >= 0.75


def _heading_path(text: str, article_start: int) -> list[dict[str, str]]:
    """Return the active part/section headings governing an article.



    Turkish legislation normally places a hierarchy marker on one line and its

    subject on the next. Keeping that path prevents article titles from being

    interpreted without their document context.

    """
    active: dict[str, tuple[int, dict[str, str]]] = {}
    for match in HIERARCHY_HEADING_RE.finditer(text, 0, article_start):
        title = _next_heading_title(text, match.end(), article_start)
        kind = match.group("kind").lower().replace(" ", "_")
        active[kind] = (
            match.start(),
            {
                "level": kind,
                "label": re.sub(r"\s+", " ", match.group("label")).strip(),
                "title": title,
            },
        )
    return [item for _position, item in sorted(active.values(), key=lambda value: value[0])]


def _next_heading_title(text: str, start: int, limit: int) -> str:
    for line in text[start:limit].splitlines()[:4]:
        candidate = re.sub(r"\s+", " ", line).strip().rstrip(":")
        candidate = re.sub(r"(?<=[A-Za-zÇĞİÖŞÜçğıöşü])\d+$", "", candidate).strip()
        if candidate and not ARTICLE_RE.match(candidate):
            return candidate
    return ""


def _first_schedule_start_after(matches: list[re.Match], start: int, end: int) -> int | None:
    starts = [match.start() for match in matches if start < match.start() < end]
    return min(starts) if starts else None


def _first_article_start_after(matches: list[re.Match], start: int) -> int | None:
    starts = [match.start() for match in matches if match.start() > start]
    return min(starts) if starts else None


def split_article_clauses(article_text: str) -> list[tuple[str, int, int]]:
    """Return authoritative legal-clause boundaries for every MCKF layer.

    Runtime ontology generation and structural exports must call this same
    function.  Duplicated marker regexes previously allowed the two artifacts
    to disagree about where a provision began and ended.
    """
    markers: list[tuple[int, str]] = []
    for pattern in CLAUSE_MARKER_RES:
        for match in pattern.finditer(article_text):
            if _looks_like_date_context(article_text, match.start()):
                continue
            markers.append((match.start(), match.group(1)))

    markers = sorted(set(markers), key=lambda item: item[0])
    if not markers:
        return [("article", 0, len(article_text))]

    spans: list[tuple[str, int, int]] = []
    if markers[0][0] > 0:
        spans.append(("preamble", 0, markers[0][0]))

    for idx, (start, label) in enumerate(markers):
        end = markers[idx + 1][0] if idx + 1 < len(markers) else len(article_text)
        spans.append((label, start, end))
    return spans


def _split_article_clauses(article_text: str) -> list[tuple[str, int, int]]:
    """Backward-compatible alias for older checks and integrations."""
    return split_article_clauses(article_text)


def _previous_heading(text: str, article_start: int) -> str:
    before = text[:article_start].rstrip().splitlines()
    for line in reversed(before[-5:]):
        cleaned = line.strip()
        if (
            cleaned
            and len(cleaned) <= 120
            and not cleaned.isupper()
            and not cleaned.startswith(("Kanun ", "Yayımlandığı", "Madde ", "Ek Madde ", "Geçici Madde "))
            and not cleaned.endswith((".", ";", ","))
        ):
            return cleaned.rstrip(":")
    return ""


def _article_title(article_text: str, full_text: str, article_start: int) -> str:
    first_line = (article_text or "").splitlines()[0].strip()
    remainder = ARTICLE_RE.sub("", first_line, count=1).strip(" –-")
    if remainder and not remainder.startswith("("):
        colon = remainder.find(":")
        semicolon = remainder.find(";")
        boundary_candidates = [value for value in (colon, semicolon) if 0 < value <= 140]
        if boundary_candidates:
            candidate = remainder[:min(boundary_candidates)].strip()
            if 2 <= len(candidate) <= 140:
                return candidate
    return _previous_heading(full_text, article_start)


def _article_type(kind: str) -> str:
    if kind == "Ek Madde":
        return "additional"
    if kind == "Geçici Madde":
        return "temporary"
    return "main"


def _looks_like_date_context(text: str, position: int) -> bool:
    window = text[max(0, position - 16):position + 16]
    return bool(re.search(r"\d{1,2}/\d{1,2}/\d{4}", window))


def _slug(value: str) -> str:
    cleaned = re.sub(r"\W+", "_", value.lower(), flags=re.UNICODE).strip("_")
    return cleaned or "unit"