File size: 16,419 Bytes
b025863
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
"""
Advanced Script Analysis Engine
Deep Unicode-based analysis: script distribution, mixing, directionality,
transliteration detection, noise analysis, and linguistic features.
"""
from __future__ import annotations

import re
import unicodedata
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple

import numpy as np
from loguru import logger

from src.utils.languages import (
    SCRIPT_UNICODE_BLOCKS,
    SCRIPT_TO_DIRECTION,
    LANGUAGE_REGISTRY,
)


# ─────────────────────────────────────────────
# Unicode Block Database
# ─────────────────────────────────────────────

UNICODE_BLOCK_NAMES: List[Tuple[int, int, str]] = [
    (0x0000, 0x007F, "Basic Latin"),
    (0x0080, 0x00FF, "Latin-1 Supplement"),
    (0x0100, 0x017F, "Latin Extended-A"),
    (0x0180, 0x024F, "Latin Extended-B"),
    (0x0250, 0x02AF, "IPA Extensions"),
    (0x0300, 0x036F, "Combining Diacritical Marks"),
    (0x0370, 0x03FF, "Greek"),
    (0x0400, 0x04FF, "Cyrillic"),
    (0x0500, 0x052F, "Cyrillic Supplement"),
    (0x0600, 0x06FF, "Arabic"),
    (0x0700, 0x074F, "Syriac"),
    (0x0750, 0x077F, "Arabic Supplement"),
    (0x0900, 0x097F, "Devanagari"),
    (0x0980, 0x09FF, "Bengali"),
    (0x0A00, 0x0A7F, "Gurmukhi"),
    (0x0A80, 0x0AFF, "Gujarati"),
    (0x0B00, 0x0B7F, "Oriya"),
    (0x0B80, 0x0BFF, "Tamil"),
    (0x0C00, 0x0C7F, "Telugu"),
    (0x0C80, 0x0CFF, "Kannada"),
    (0x0D00, 0x0D7F, "Malayalam"),
    (0x0D80, 0x0DFF, "Sinhala"),
    (0x0E00, 0x0E7F, "Thai"),
    (0x0F00, 0x0FFF, "Tibetan"),
    (0x1000, 0x109F, "Myanmar"),
    (0x10A0, 0x10FF, "Georgian"),
    (0x1100, 0x11FF, "Hangul Jamo"),
    (0x1E00, 0x1EFF, "Latin Extended Additional"),
    (0x2000, 0x206F, "General Punctuation"),
    (0x20A0, 0x20CF, "Currency Symbols"),
    (0x2100, 0x214F, "Letterlike Symbols"),
    (0x2200, 0x22FF, "Mathematical Operators"),
    (0x3000, 0x303F, "CJK Symbols and Punctuation"),
    (0x3040, 0x309F, "Hiragana"),
    (0x30A0, 0x30FF, "Katakana"),
    (0x4E00, 0x9FFF, "CJK Unified Ideographs"),
    (0xFB00, 0xFB4F, "Alphabetic Presentation Forms"),
    (0xFB50, 0xFDFF, "Arabic Presentation Forms-A"),
    (0xFE70, 0xFEFF, "Arabic Presentation Forms-B"),
    (0xFF00, 0xFFEF, "Halfwidth and Fullwidth Forms"),
]


def get_unicode_block(cp: int) -> str:
    for lo, hi, name in UNICODE_BLOCK_NAMES:
        if lo <= cp <= hi:
            return name
    return f"U+{cp:04X} (unknown block)"


# ─────────────────────────────────────────────
# Data Structures
# ─────────────────────────────────────────────

@dataclass
class CharacterAnalysis:
    char: str
    codepoint: int
    unicode_name: str
    unicode_category: str
    unicode_block: str
    script: Optional[str]
    is_alpha: bool
    is_digit: bool
    is_punct: bool
    is_combining: bool


@dataclass
class ScriptSegment:
    script: str
    start: int
    end: int
    text: str
    char_count: int


@dataclass
class ScriptAnalysisResult:
    text: str
    total_chars: int
    alpha_chars: int
    digit_chars: int
    punct_chars: int
    space_chars: int
    other_chars: int

    # Script distribution
    script_distribution: Dict[str, float]
    dominant_script: str
    dominant_script_ratio: float

    # Mixing analysis
    is_mixed_script: bool
    mixing_score: float           # 0=pure, 1=maximally mixed
    script_segments: List[ScriptSegment]
    script_transitions: int

    # Directionality
    primary_direction: str
    has_bidi: bool
    rtl_ratio: float

    # Noise indicators
    noise_score: float
    has_zero_width: bool
    has_control_chars: bool
    has_excessive_punct: bool
    url_count: int
    emoji_count: int
    latin_ratio: float            # in otherwise Indic text β†’ potential transliteration

    # Unicode statistics
    unicode_block_distribution: Dict[str, float]
    unique_chars: int
    char_entropy: float

    # Language hints
    script_to_lang_hints: Dict[str, List[str]]

    def to_dict(self) -> Dict:
        return {
            "total_chars": self.total_chars,
            "alpha_chars": self.alpha_chars,
            "alpha_ratio": self.alpha_chars / max(self.total_chars, 1),
            "dominant_script": self.dominant_script,
            "dominant_script_ratio": self.dominant_script_ratio,
            "is_mixed_script": self.is_mixed_script,
            "mixing_score": self.mixing_score,
            "script_transitions": self.script_transitions,
            "primary_direction": self.primary_direction,
            "has_bidi": self.has_bidi,
            "noise_score": self.noise_score,
            "char_entropy": self.char_entropy,
            "script_distribution": self.script_distribution,
            "script_to_lang_hints": self.script_to_lang_hints,
        }


# ─────────────────────────────────────────────
# Script Analyser
# ─────────────────────────────────────────────

class ScriptAnalyzer:
    """
    Deep script and Unicode analysis for multilingual text.
    Designed for Indic languages with mixed-script handling.
    """

    _URL_RE = re.compile(r"https?://\S+|www\.\S+")
    _EMOJI_RE = re.compile(
        "["
        "\U0001F600-\U0001F64F"
        "\U0001F300-\U0001F5FF"
        "\U0001F680-\U0001F6FF"
        "\U0001F1E0-\U0001F1FF"
        "\U00002702-\U000027B0"
        "\U000024C2-\U0001F251"
        "]+",
        re.UNICODE
    )
    _ZW_RE = re.compile(r"[\u200b-\u200f\u202a-\u202e\ufeff\u00ad]")
    _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")

    # Script β†’ language hints
    SCRIPT_LANG_HINTS: Dict[str, List[str]] = {
        "Devanagari": ["hin", "mar"],
        "Bengali": ["ben"],
        "Gurmukhi": ["pan"],
        "Gujarati": ["guj"],
        "Oriya": ["ori"],
        "Tamil": ["tam"],
        "Telugu": ["tel"],
        "Kannada": ["kan"],
        "Malayalam": ["mal"],
        "Arabic": ["urd"],
        "Latin": ["eng"],
    }

    def analyze(self, text: str) -> ScriptAnalysisResult:
        """Comprehensive script analysis of input text."""
        if not text:
            return self._empty_result(text)

        chars = list(text)
        n = len(chars)

        # Character category counts
        alpha = sum(c.isalpha() for c in chars)
        digits = sum(c.isdigit() for c in chars)
        spaces = sum(c.isspace() for c in chars)
        puncts = sum(unicodedata.category(c).startswith("P") for c in chars)
        other = n - alpha - digits - spaces - puncts

        # Script assignment per alpha char
        script_of: List[Optional[str]] = [self._assign_script(c) for c in chars]
        alpha_scripts = [s for c, s in zip(chars, script_of) if c.isalpha() and s]

        # Script distribution
        script_counter = Counter(alpha_scripts)
        total_alpha = max(len(alpha_scripts), 1)
        script_dist = {s: c / total_alpha for s, c in script_counter.most_common()}

        dominant = script_counter.most_common(1)[0][0] if script_counter else "Unknown"
        dom_ratio = script_dist.get(dominant, 0.0)

        # Segment extraction
        segments = self._extract_segments(chars, script_of)
        transitions = max(0, len(segments) - 1)

        # Mixing score: entropy-based
        mixing_score = self._script_entropy(script_dist)

        # Directionality
        rtl_langs = {"Arabic", "Hebrew"}
        rtl_chars = sum(1 for s in alpha_scripts if s in rtl_langs)
        rtl_ratio = rtl_chars / max(total_alpha, 1)
        primary_direction = "RTL" if rtl_ratio > 0.5 else "LTR"
        has_bidi = 0.05 < rtl_ratio < 0.95

        # Unicode block distribution
        block_counter: Counter = Counter()
        for c in chars:
            block = get_unicode_block(ord(c))
            block_counter[block] += 1
        block_dist = {b: c / n for b, c in block_counter.most_common(10)}

        # Noise analysis
        url_count = len(self._URL_RE.findall(text))
        emoji_count = len(self._EMOJI_RE.findall(text))
        has_zero_width = bool(self._ZW_RE.search(text))
        has_control = bool(self._CTRL_RE.search(text))
        has_excessive_punct = (puncts / max(n, 1)) > 0.15
        latin_ratio = script_dist.get("Basic Latin", 0.0) + script_dist.get("Latin", 0.0)

        noise_components = [
            0.3 * min(1.0, url_count * 0.5),
            0.2 * int(has_zero_width),
            0.2 * int(has_control),
            0.15 * int(has_excessive_punct),
            0.15 * min(1.0, emoji_count * 0.2),
        ]
        noise_score = sum(noise_components)

        # Char entropy
        char_freq = Counter(text)
        total_f = sum(char_freq.values())
        entropy = -sum(
            (c / total_f) * np.log2(c / total_f)
            for c in char_freq.values() if c > 0
        )

        # Lang hints based on detected scripts
        lang_hints: Dict[str, List[str]] = {
            s: self.SCRIPT_LANG_HINTS.get(s, [])
            for s in script_dist
        }

        return ScriptAnalysisResult(
            text=text,
            total_chars=n,
            alpha_chars=alpha,
            digit_chars=digits,
            punct_chars=puncts,
            space_chars=spaces,
            other_chars=other,
            script_distribution=script_dist,
            dominant_script=dominant,
            dominant_script_ratio=dom_ratio,
            is_mixed_script=len(script_dist) > 1 and dom_ratio < 0.95,
            mixing_score=mixing_score,
            script_segments=segments,
            script_transitions=transitions,
            primary_direction=primary_direction,
            has_bidi=has_bidi,
            rtl_ratio=rtl_ratio,
            noise_score=noise_score,
            has_zero_width=has_zero_width,
            has_control_chars=has_control,
            has_excessive_punct=has_excessive_punct,
            url_count=url_count,
            emoji_count=emoji_count,
            latin_ratio=latin_ratio,
            unicode_block_distribution=block_dist,
            unique_chars=len(set(text)),
            char_entropy=float(entropy),
            script_to_lang_hints=lang_hints,
        )

    def analyze_batch(self, texts: List[str]) -> List[ScriptAnalysisResult]:
        return [self.analyze(t) for t in texts]

    def _assign_script(self, char: str) -> Optional[str]:
        if not char.isalpha():
            return None
        cp = ord(char)
        for block_start, block_end, block_name in UNICODE_BLOCK_NAMES:
            if block_start <= cp <= block_end:
                return block_name
        return "Unknown"

    def _extract_segments(
        self, chars: List[str], scripts: List[Optional[str]]
    ) -> List[ScriptSegment]:
        """Extract contiguous script segments from text."""
        segments = []
        if not chars:
            return segments

        current_script = None
        start = 0
        seg_chars = []

        for i, (c, s) in enumerate(zip(chars, scripts)):
            if not c.isalpha():
                seg_chars.append(c)
                continue
            if s != current_script:
                if current_script is not None and seg_chars:
                    segments.append(ScriptSegment(
                        script=current_script,
                        start=start,
                        end=i,
                        text="".join(seg_chars),
                        char_count=sum(1 for ch in seg_chars if ch.isalpha()),
                    ))
                current_script = s
                start = i
                seg_chars = [c]
            else:
                seg_chars.append(c)

        if current_script and seg_chars:
            segments.append(ScriptSegment(
                script=current_script,
                start=start,
                end=len(chars),
                text="".join(seg_chars),
                char_count=sum(1 for ch in seg_chars if ch.isalpha()),
            ))

        return [s for s in segments if s.char_count >= 3]

    def _script_entropy(self, dist: Dict[str, float]) -> float:
        """Shannon entropy over script distribution (normalized to [0,1])."""
        if len(dist) <= 1:
            return 0.0
        probs = list(dist.values())
        entropy = -sum(p * np.log2(p) for p in probs if p > 0)
        max_entropy = np.log2(len(probs))
        return float(entropy / max_entropy) if max_entropy > 0 else 0.0

    def _empty_result(self, text: str) -> ScriptAnalysisResult:
        return ScriptAnalysisResult(
            text=text, total_chars=0, alpha_chars=0, digit_chars=0,
            punct_chars=0, space_chars=0, other_chars=0,
            script_distribution={}, dominant_script="Unknown",
            dominant_script_ratio=0.0, is_mixed_script=False,
            mixing_score=0.0, script_segments=[], script_transitions=0,
            primary_direction="LTR", has_bidi=False, rtl_ratio=0.0,
            noise_score=0.0, has_zero_width=False, has_control_chars=False,
            has_excessive_punct=False, url_count=0, emoji_count=0,
            latin_ratio=0.0, unicode_block_distribution={},
            unique_chars=0, char_entropy=0.0, script_to_lang_hints={},
        )

    def compare_texts(self, text1: str, text2: str) -> Dict:
        """Compare script profiles of two texts."""
        r1 = self.analyze(text1)
        r2 = self.analyze(text2)

        scripts1 = set(r1.script_distribution.keys())
        scripts2 = set(r2.script_distribution.keys())
        shared = scripts1 & scripts2

        jaccard = len(shared) / max(len(scripts1 | scripts2), 1)

        return {
            "script_overlap": list(shared),
            "jaccard_similarity": jaccard,
            "dominant_scripts_match": r1.dominant_script == r2.dominant_script,
            "text1_dominant": r1.dominant_script,
            "text2_dominant": r2.dominant_script,
            "text1_mixing_score": r1.mixing_score,
            "text2_mixing_score": r2.mixing_score,
        }

    def detect_transliteration(self, text: str) -> Dict:
        """
        Detect if text is a transliteration of an Indic language
        written in Latin script (Romanized Indic).
        """
        result = self.analyze(text)

        # If predominantly Latin but contains Indic vocabulary patterns
        latin_dominated = result.dominant_script in ("Basic Latin", "Latin Extended-A")
        has_indic_patterns = self._has_indic_romanization_patterns(text)

        # Common Romanization markers
        has_aspirates = bool(re.search(r"\b\w*(kh|gh|ch|jh|th|dh|ph|bh)\w*\b", text, re.I))
        has_retroflex = bool(re.search(r"\b\w*[ṭḍṇṣ]\w*\b", text))
        has_nasal_markers = bool(re.search(r"\b\w*(ng|ny|Γ±|αΉ…)\w*\b", text, re.I))

        is_likely_transliteration = (
            latin_dominated and (has_indic_patterns or has_aspirates)
        )

        return {
            "is_transliteration": is_likely_transliteration,
            "confidence": 0.8 if (is_likely_transliteration and has_aspirates) else 0.5,
            "evidence": {
                "latin_dominated": latin_dominated,
                "has_indic_patterns": has_indic_patterns,
                "has_aspirates": has_aspirates,
                "has_retroflex": has_retroflex,
                "has_nasal_markers": has_nasal_markers,
            }
        }

    def _has_indic_romanization_patterns(self, text: str) -> bool:
        """Common romanization patterns across Indic languages."""
        patterns = [
            r"\b(hai|hain|nahin|nahi|aur|yeh|woh|kya|kaun|kahan|kyun|mera|tera|apna)\b",  # Hindi
            r"\b(amma|appa|anna|akka|vanakkam|nandri|romba|paaru|paar)\b",   # Tamil
            r"\b(ahe|aahe|nahi|tumhi|amhi|tya|tyanche|mhanje)\b",           # Marathi
        ]
        for p in patterns:
            if re.search(p, text, re.I):
                return True
        return False


# Singleton for reuse
default_analyzer = ScriptAnalyzer()