File size: 10,817 Bytes
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39cfcd1
 
 
8f6d79d
 
39cfcd1
 
 
 
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39cfcd1
 
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39cfcd1
 
 
 
 
 
 
 
 
 
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39cfcd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39cfcd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ea9869
 
 
8f6d79d
 
 
 
 
 
 
 
 
 
 
7ea9869
8f6d79d
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Cleaning, punctuation normalization, and block tagging."""

from __future__ import annotations

import re

from app.engine.models import DocumentBlock

_BIBLIO_HEADINGS = re.compile(
    r"^(#{1,6}\s*)?(references|bibliography|works\s+cited|citations)\s*:?\s*$",
    re.I | re.M,
)
_HEADING = re.compile(r"^(#{1,6}\s+\S.*|[A-Z][A-Z0-9 ,.'-]{2,60})$")
_LIST_ITEM = re.compile(r"^(\d+[\.\)]\s+|[-*•]\s+)\S")
_CODE_FENCE = re.compile(r"^```")
_TABLE_LINE = re.compile(r"^\s*\|.+\|\s*$")
_TABLE_SEPARATOR = re.compile(
    r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$"
)
_HTML_TABLE = re.compile(r"</?table\b", re.I)
_FORMULA = re.compile(r"(\$\$.+?\$\$|\\\[[\s\S]+?\\\]|\\begin\{(?:equation|align|math)\})")
_FORMULA_BLOCK = re.compile(
    r"(\$\$|\\\[|\\\]|\\begin\{(?:equation|align|math)\}|"
    r"\\end\{(?:equation|align|math)\})"
)
_URL = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+", re.I)
_EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
_SOFT_WRAP = re.compile(r"(?<=\w)\n(?=\w)")


def normalize_text(text: str) -> str:
    """UTF-8 hygiene, quotes/dashes, soft wraps, spacing."""
    t = (text or "").replace("\r\n", "\n").replace("\r", "\n")
    # Smart quotes / dashes → ASCII equivalents
    t = (
        t.replace("\u201c", '"')
        .replace("\u201d", '"')
        .replace("\u2018", "'")
        .replace("\u2019", "'")
        .replace("\u2013", "-")
        .replace("\u2014", "—")
        .replace("\u00a0", " ")
    )
    # Fix broken line wraps inside paragraphs (keep blank-line paragraph breaks)
    parts = re.split(r"(\n\s*\n)", t)
    fixed: list[str] = []
    for part in parts:
        if re.match(r"\n\s*\n", part):
            fixed.append("\n\n")
            continue
        # Don't join wraps inside code or block-formula fences.
        if "```" in part or _FORMULA_BLOCK.search(part):
            fixed.append(part)
            continue
        lines = part.split("\n")
        rebuilt: list[str] = []
        buf = ""
        for line in lines:
            stripped = line.strip()
            if not stripped:
                if buf:
                    rebuilt.append(buf)
                    buf = ""
                rebuilt.append("")
                continue
            if _LIST_ITEM.match(stripped) or _HEADING.match(stripped) or _TABLE_LINE.match(stripped):
                if buf:
                    rebuilt.append(buf)
                    buf = ""
                rebuilt.append(stripped)
                continue
            if buf and not buf.endswith(("-", "—")):
                # Soft wrap: join with space
                buf = f"{buf} {stripped}"
            elif buf and buf.endswith(("-", "—")):
                buf = buf.rstrip("-—") + stripped
            else:
                buf = stripped
        if buf:
            rebuilt.append(buf)
        fixed.append("\n".join(rebuilt))
    t = "".join(fixed)
    t = re.sub(r"[ \t]+", " ", t)
    t = re.sub(r" *\n *", "\n", t)
    t = re.sub(r"\n{3,}", "\n\n", t)
    # Punctuation spacing
    t = re.sub(r"\s+([,.;:!?])", r"\1", t)
    t = re.sub(r"([(\[{])\s+", r"\1", t)
    t = re.sub(r"\s+([)\]}])", r"\1", t)
    return t.strip()


def _is_bibliography_heading(line: str) -> bool:
    return bool(_BIBLIO_HEADINGS.match(line.strip()))


def _is_markdown_table_block(text: str) -> bool:
    lines = [line.strip() for line in (text or "").splitlines() if line.strip()]
    if len(lines) < 2:
        return False
    table_lines = sum(bool(_TABLE_LINE.match(line)) for line in lines)
    return table_lines == len(lines) and any(
        _TABLE_SEPARATOR.match(line) for line in lines
    )


def detect_blocks(text: str) -> list[DocumentBlock]:
    """Split normalized text into typed blocks with rewrite flags."""
    raw = text or ""
    if not raw.strip():
        return []

    blocks: list[DocumentBlock] = []
    chunks = re.split(r"(\n\s*\n)", raw)
    in_code = False
    in_biblio = False
    buf_lines: list[str] = []
    buf_kind = "paragraph"
    index = 0

    def flush() -> None:
        nonlocal index, buf_lines, buf_kind
        if not buf_lines:
            return
        body = "\n".join(buf_lines).strip("\n")
        if body.strip() == "" and body:
            blocks.append(
                DocumentBlock(text=body, kind="blank", rewriteable=False, index=index)
            )
        elif body.strip():
            rewriteable = buf_kind == "paragraph" and not in_biblio
            blocks.append(
                DocumentBlock(
                    text=body.strip(),
                    kind=buf_kind if not in_biblio or buf_kind != "paragraph" else "bibliography",
                    rewriteable=rewriteable and buf_kind == "paragraph",
                    index=index,
                )
            )
        index += 1
        buf_lines = []
        buf_kind = "paragraph"

    for chunk in chunks:
        if re.match(r"\n\s*\n", chunk or ""):
            flush()
            blocks.append(
                DocumentBlock(text="", kind="blank", rewriteable=False, index=index)
            )
            index += 1
            continue

        stripped_chunk = (chunk or "").strip()
        if (
            _is_markdown_table_block(stripped_chunk)
            or _HTML_TABLE.search(stripped_chunk)
        ):
            flush()
            blocks.append(
                DocumentBlock(
                    text=stripped_chunk,
                    kind="table",
                    rewriteable=False,
                    index=index,
                )
            )
            index += 1
            continue
        if _FORMULA_BLOCK.search(stripped_chunk):
            flush()
            blocks.append(
                DocumentBlock(
                    text=stripped_chunk,
                    kind="formula",
                    rewriteable=False,
                    index=index,
                )
            )
            index += 1
            continue

        for line in (chunk or "").split("\n"):
            stripped = line.strip()

            if _CODE_FENCE.match(stripped):
                if in_code:
                    buf_lines.append(line)
                    buf_kind = "code"
                    flush()
                    in_code = False
                else:
                    flush()
                    in_code = True
                    buf_kind = "code"
                    buf_lines = [line]
                continue

            if in_code:
                buf_lines.append(line)
                buf_kind = "code"
                continue

            if _is_bibliography_heading(stripped):
                flush()
                in_biblio = True
                buf_kind = "bibliography"
                buf_lines = [stripped]
                flush()
                continue

            if (
                in_biblio
                and _HEADING.match(stripped)
                and len(stripped.split()) <= 12
                and not stripped.endswith((".", ",", ";"))
            ):
                flush()
                in_biblio = False
                blocks.append(
                    DocumentBlock(
                        text=stripped,
                        kind="heading",
                        rewriteable=False,
                        index=index,
                    )
                )
                index += 1
                continue

            if _HTML_TABLE.search(stripped) or _TABLE_LINE.match(stripped):
                flush()
                blocks.append(
                    DocumentBlock(
                        text=stripped, kind="table", rewriteable=False, index=index
                    )
                )
                index += 1
                continue

            if _FORMULA.search(stripped) and len(stripped.split()) < 40:
                flush()
                blocks.append(
                    DocumentBlock(
                        text=stripped, kind="formula", rewriteable=False, index=index
                    )
                )
                index += 1
                continue

            if _LIST_ITEM.match(stripped):
                flush()
                blocks.append(
                    DocumentBlock(
                        text=stripped, kind="list", rewriteable=False, index=index
                    )
                )
                index += 1
                continue

            if _HEADING.match(stripped) and len(stripped.split()) <= 12:
                flush()
                blocks.append(
                    DocumentBlock(
                        text=stripped, kind="heading", rewriteable=False, index=index
                    )
                )
                index += 1
                continue

            if in_biblio:
                buf_kind = "bibliography"
            else:
                buf_kind = "paragraph"
            buf_lines.append(stripped if stripped else line)

    flush()

    # Protect paragraphs that are mostly URL/email-only
    for b in blocks:
        if not b.rewriteable:
            continue
        plain = _URL.sub("", b.text)
        plain = _EMAIL.sub("", plain).strip()
        if len(plain.split()) < 3 and (_URL.search(b.text) or _EMAIL.search(b.text)):
            b.rewriteable = False
            b.kind = "special"

    return blocks


def mask_protected_spans(text: str) -> tuple[str, dict[str, str]]:
    """Replace URLs/emails/paths with placeholders for rewrite safety."""
    mapping: dict[str, str] = {}
    counter = {"n": 0}

    def _sub(pattern: re.Pattern[str], label: str, s: str) -> str:
        def repl(m: re.Match[str]) -> str:
            # Alphanumeric placeholders stay as one token in spaCy. Underscore
            # placeholders can be split and lose their trailing delimiter.
            key = f"ZZPROTECTED{label}{counter['n']}ZZ"
            mapping[key] = m.group(0)
            counter["n"] += 1
            return key

        return pattern.sub(repl, s)

    out = text
    out = _sub(_URL, "URL", out)
    out = _sub(_EMAIL, "EMAIL", out)

    def path_repl(m: re.Match[str]) -> str:
        key = f"ZZPROTECTEDPATH{counter['n']}ZZ"
        mapping[key] = m.group(0)
        counter["n"] += 1
        return key

    out = re.sub(r"(?:[A-Za-z]:\\|/)[^\s<>\"']+", path_repl, out)
    return out, mapping


def unmask_protected_spans(text: str, mapping: dict[str, str]) -> str:
    out = text
    for key, val in mapping.items():
        out = out.replace(key, val)
    return out