File size: 9,289 Bytes
5a60197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Text normalisation and tokenisation.

Tokens keep their character offsets in the normalised text, so every value the copilot
fills can point back at the words that produced it (the UI highlights them).
"""
from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass

MAX_CHARS = 2000

_QUOTES = {'‘': "'", '’': "'", '‚': "'", '‛': "'", '′': "'",
           '“': '"', '”': '"', '„': '"', '″': '"', '«': '"', '»': '"',
           '–': '-', '—': '-', '−': '-', ' ': ' '}

CONTRACTIONS = {
    "don't": 'do not', 'dont': 'do not', "doesn't": 'does not', 'doesnt': 'does not', "didn't": 'did not',
    "can't": 'can not', 'cant': 'can not', 'cannot': 'can not', "won't": 'will not', 'wont': 'will not',
    "shouldn't": 'should not', 'shouldnt': 'should not', "mustn't": 'must not', 'mustnt': 'must not',
    "isn't": 'is not', "aren't": 'are not', "wasn't": 'was not', "weren't": 'were not',
    "wouldn't": 'would not', "couldn't": 'could not', "haven't": 'have not', "hasn't": 'has not',
    "shan't": 'shall not', "needn't": 'need not', "mayn't": 'may not', "ain't": 'is not',
    "let's": 'let us', "it's": 'it is', "i'm": 'i am', "they're": 'they are', "we're": 'we are',
    "you're": 'you are', "that's": 'that is', "what's": 'what is', "there's": 'there is',
    "i'd": 'i would', "we'd": 'we would', "i'll": 'i will', "we'll": 'we will', "i've": 'i have', "we've": 'we have',
}

NUMBER_WORDS = {
    'zero': 0, 'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5, 'six': 6, 'seven': 7, 'eight': 8,
    'nine': 9, 'ten': 10, 'eleven': 11, 'twelve': 12, 'thirteen': 13, 'fourteen': 14, 'fifteen': 15,
    'sixteen': 16, 'seventeen': 17, 'eighteen': 18, 'nineteen': 19, 'twenty': 20, 'thirty': 30,
    'forty': 40, 'fifty': 50, 'sixty': 60, 'seventy': 70, 'eighty': 80, 'ninety': 90,
    'hundred': 100, 'thousand': 1000, 'dozen': 12,
}

# Order matters: the first alternative that matches at a position wins.
_TOKEN_RE = re.compile(r"""
    (?P<quoted>"[^"\n]{1,80}"|(?<![\w'])'[^'\n]{1,60}'(?![\w']))
  | (?P<email>[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+)
  | (?P<url>https?://[^\s,;"'<>()]+)
  | (?P<path>(?:\*\*/|\*|~)?[./\\]?[a-z0-9_*-]*\.(?:env|pem|key|pfx|p12|kdbx|ssh|aws|npmrc|pypirc|netrc|git-credentials)\b|\*\*?/[^\s,;]+|\*\.[a-z0-9]{1,8}\b)
  | (?P<time>\d{1,2}(?::\d{2})?\s?(?:am|pm)\b|\d{1,2}:\d{2}\b)
  | (?P<number>\d[\d,]*(?:\.\d+)?(?=(?:kb|mb|gb|tb)\b|[^a-z0-9.,]|[.,](?![0-9])|$))   # whole numbers only: "1234abcd" is a serial, "50mb" is 50 + mb
  | (?P<word>[a-z0-9][a-z0-9_]*(?:['&.+-][a-z0-9_]+)*(?:'s)?)
  | (?P<punct>[,.;:!?/()&|\n-])
""", re.VERBOSE)

_TLD = re.compile(r'\.[a-z]{2,24}$')
# "CLAUDE.md", "config.json", "readme.txt": file names, not websites (even where the suffix is a real TLD)
FILE_EXTS = {'md', 'json', 'txt', 'env', 'yaml', 'yml', 'py', 'js', 'ts', 'pem', 'key', 'csv', 'docx', 'xlsx', 'pdf',
             'sh', 'toml', 'ini', 'cfg', 'log', 'xml', 'doc', 'xls', 'ppt', 'pptx', 'zip', 'exe', 'dll', 'jar', 'sql',
             'bak', 'conf', 'pfx', 'p12', 'kdbx', 'rules', 'lock'}
_DOMAIN = re.compile(r'^(?:[a-z0-9-]+\.)+[a-z]{2,24}$')
_SERIAL = re.compile(r'^(?=.*\d)(?=.*[a-z])[a-z0-9]+(?:-[a-z0-9]+)+$|^(?=.*\d)[a-z]{2,}\d{4,}[a-z0-9]*$'
                     r'|^(?=.*\d)(?=.*[a-z])[a-z0-9]{8,}$')   # 1234ABCD, 4C530001231120115142


@dataclass
class Token:
    text: str          # normalised form used for matching
    start: int         # offsets into the normalised text
    end: int
    kind: str          # word | number | quoted | email | domain | url | path | time | punct
    value: object = None   # number value, quoted content, cleaned domain ...

    @property
    def is_word(self) -> bool:
        return self.kind in ('word', 'domain')


_TAGS = re.compile(r'<\s*(script|style)\b.*?<\s*/\s*\1\s*>|<[^<>]{1,200}>', re.I | re.S)
_PLUS = re.compile(r'(?<=[a-z0-9])\s*\+\s*(?=[a-z])', re.I)


def clean(raw: object) -> tuple[str, list[str]]:
    """NFKC, straight quotes, no control characters, length cap. Returns (text, notes)."""
    notes: list[str] = []
    if not isinstance(raw, str):
        raw = ''   # only text is read: str() of a list or dict would turn its repr into a request
    text = unicodedata.normalize('NFKC', raw)
    text = _TAGS.sub(' ', text)          # pasted markup is never part of a request
    text = _PLUS.sub(' and ', text)      # "usb+print+bt" is a list
    text = ''.join(_QUOTES.get(c, c) for c in text)
    text = ''.join(c if (c in '\n\t' or unicodedata.category(c)[0] != 'C') else ' ' for c in text)
    text = re.sub(r'[ \t]+', ' ', text).strip()
    if len(text) > MAX_CHARS:
        text = text[:MAX_CHARS]
        notes.append('INPUT_TRUNCATED')
    return text, notes


def tokenize(text: str) -> list[Token]:
    """Tokens of the (already cleaned) text. Offsets refer to `text`."""
    low = text.lower()
    out: list[Token] = []
    for m in _TOKEN_RE.finditer(low):
        kind = m.lastgroup
        s, e = m.span()
        t = m.group()
        if kind == 'quoted':
            inner = t[1:-1].strip()
            if inner:
                out.append(Token(t, s, e, 'quoted', inner))
            continue
        if kind == 'email':
            out.append(Token(t, s, e, 'email', t))
            continue
        if kind == 'url':
            host = re.sub(r'^https?://', '', t).split('/')[0].split(':')[0]
            out.append(Token(host, s, e, 'domain', host))
            continue
        if kind == 'path':
            out.append(Token(t, s, e, 'path', t))
            continue
        if kind == 'time':
            out.append(Token(t.replace(' ', ''), s, e, 'time', _parse_time(t)))
            continue
        if kind == 'number':
            try:
                v = float(t.replace(',', ''))
                v = int(v) if v.is_integer() else v
            except ValueError:
                v = None
            out.append(Token(t, s, e, 'number', v))
            continue
        if kind == 'punct':
            out.append(Token(t, s, e, 'punct'))
            continue
        # words
        exp = CONTRACTIONS.get(t)
        if exp:
            for part in exp.split():
                out.append(Token(part, s, e, 'word'))
            continue
        if t.endswith("'s") and len(t) > 3:
            t_base = t[:-2]
        else:
            t_base = t
        if t_base in NUMBER_WORDS:
            out.append(Token(t_base, s, e, 'number', NUMBER_WORDS[t_base]))
            continue
        if '.' in t_base and _DOMAIN.match(t_base) and _TLD.search(t_base):
            if t_base.rsplit('.', 1)[1] in FILE_EXTS:
                out.append(Token(t_base, s, e, 'path', t_base))
            else:
                out.append(Token(t_base, s, e, 'domain', t_base))
            continue
        out.append(Token(t_base if t_base != t and len(t_base) > 1 else t, s, e, 'word'))
    return _merge_number_words(out)


def _merge_number_words(tokens: list[Token]) -> list[Token]:
    """'twenty five' -> 25, 'two hundred' -> 200, 'a hundred' -> 100."""
    out: list[Token] = []
    for t in tokens:
        prev = out[-1] if out else None
        if t.kind == 'number' and t.text in NUMBER_WORDS and prev is not None:
            if prev.kind == 'number' and isinstance(prev.value, int):
                if t.value in (100, 1000):
                    prev.value = prev.value * t.value
                    prev.end, prev.text = t.end, f'{prev.text} {t.text}'
                    continue
                if prev.value >= 20 and prev.value % 10 == 0 and t.value < 10:
                    prev.value += t.value
                    prev.end, prev.text = t.end, f'{prev.text} {t.text}'
                    continue
            if prev.kind == 'word' and prev.text == 'a' and t.value in (100, 1000, 12):
                out[-1] = Token(f'a {t.text}', prev.start, t.end, 'number', t.value)
                continue
        out.append(t)
    return out


def _parse_time(t: str) -> int | None:
    """'6pm' -> 18, '9:30 am' -> 9, '18:00' -> 18 (hours only: the platform stores whole hours)."""
    m = re.match(r'(\d{1,2})(?::(\d{2}))?\s?(am|pm)?', t)
    if not m:
        return None
    h = int(m.group(1))
    ap = m.group(3)
    if ap == 'pm' and h < 12:
        h += 12
    if ap == 'am' and h == 12:
        h = 0
    return h if 0 <= h <= 23 else None


def is_domain(s: str) -> bool:
    return bool(_DOMAIN.match(s))


def is_serial(s: str) -> bool:
    return bool(_SERIAL.match(s))


def stem(word: str) -> str:
    """Plural to singular, conservatively. Only used when the exact word is not in the vocabulary."""
    if len(word) < 3 or not word.isalpha():
        return word
    if len(word) == 3:
        # "ids" -> "id", "dms" -> "dm"; leave words that only look plural ("bus", "gas", "his")
        return word[:-1] if word.endswith('s') and word not in ('bus', 'gas', 'his', 'has', 'was', 'yes', 'its',
                                                                'ios', 'aws', 'sms', 'ssn', 'pls') else word
    if word.endswith('ies') and len(word) > 4:
        return word[:-3] + 'y'
    if word.endswith(('ses', 'xes', 'ches', 'shes')):
        return word[:-2]
    if word.endswith('s') and not word.endswith(('ss', 'us', 'is')):
        return word[:-1]
    return word