Download copilot/text.py from Wardline/cloak-api: direct link, hf CLI and curl.
- Browser
- Download file 9.29 kB
-
https://huggingface.co/spaces/Wardline/cloak-api/resolve/main/copilot/text.py
- Command line
-
hf download hf://spaces/Wardline/cloak-api/copilot/text.py
-
curl -L -o text.py https://huggingface.co/spaces/Wardline/cloak-api/resolve/main/copilot/text.py
9.29 kB
| """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 | |
| 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 ... | |
| 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 | |