Spaces:
Sleeping
Sleeping
Download app/normalize.py from stevafernandes/CURAS: direct link, hf CLI and curl.
- Browser
- Download file 5.38 kB
-
https://huggingface.co/spaces/stevafernandes/CURAS/resolve/main/app/normalize.py
- Command line
-
hf download hf://spaces/stevafernandes/CURAS/app/normalize.py
-
curl -L -o normalize.py https://huggingface.co/spaces/stevafernandes/CURAS/resolve/main/app/normalize.py
5.38 kB
| """Normalisation of free-text values into the shared vocabularies. | |
| Used by the Excel importer and by the admin editor (to pre-check boxes from a | |
| stored value). The rule is conservative: a piece of text is converted to | |
| canonical vocabulary items only when every word in it is accounted for by the | |
| vocabulary; otherwise the piece is kept verbatim (whitespace collapsed) so | |
| nothing a faculty member wrote is lost or guessed. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Iterable, Optional | |
| from .fields import LIST_SEPARATOR | |
| _WS = re.compile(r"\s+") | |
| _HARD_SEPARATORS = re.compile(r"[\n\r\t;]|,|\s{2,}") | |
| # Characters that Excel stores from the Windows "Symbol" font as private-use | |
| # code points; mapped to the real characters they stand for. | |
| _SYMBOL_FONT = str.maketrans({ | |
| "\uf0a2": "\u2032", # prime | |
| "\uf0b2": "\u2033", # double prime | |
| "\uf0b7": "\u2022", # bullet | |
| "\uf0b0": "\u00b0", # degree | |
| "\uf0a0": " ", | |
| }) | |
| def clean_text(value, keep_newlines: bool = True) -> Optional[str]: | |
| """Trim, drop non-breaking spaces, collapse runs of blanks. None if empty.""" | |
| if value is None: | |
| return None | |
| s = str(value).translate(_SYMBOL_FONT).replace("\xa0", " ").replace("\r\n", "\n").replace("\r", "\n") | |
| if keep_newlines: | |
| lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in s.split("\n")] | |
| # Collapse 3+ blank lines to a single blank line. | |
| out, blank = [], 0 | |
| for ln in lines: | |
| if ln == "": | |
| blank += 1 | |
| if blank > 1: | |
| continue | |
| else: | |
| blank = 0 | |
| out.append(ln) | |
| s = "\n".join(out).strip() | |
| else: | |
| s = _WS.sub(" ", s).strip() | |
| return s or None | |
| def _build_lookup(vocab: dict[str, list[str]]) -> tuple[dict[tuple[str, ...], str], int]: | |
| lookup: dict[tuple[str, ...], str] = {} | |
| longest = 1 | |
| for canonical, variants in vocab.items(): | |
| for v in [canonical] + list(variants): | |
| words = tuple(_WS.sub(" ", v.lower()).strip().split(" ")) | |
| lookup[words] = canonical | |
| longest = max(longest, len(words)) | |
| return lookup, longest | |
| def _tokenize_piece(piece: str, lookup: dict, longest: int) -> Optional[list[str]]: | |
| """Greedy longest-match of vocabulary phrases. None if any word is unknown.""" | |
| words = piece.lower().strip(" .").split(" ") | |
| words = [w.strip(".,;:") for w in words if w.strip(".,;:")] | |
| if not words: | |
| return [] | |
| out: list[str] = [] | |
| i = 0 | |
| while i < len(words): | |
| matched = False | |
| for n in range(min(longest, len(words) - i), 0, -1): | |
| cand = tuple(words[i:i + n]) | |
| if cand in lookup: | |
| out.append(lookup[cand]) | |
| i += n | |
| matched = True | |
| break | |
| if not matched: | |
| return None | |
| return out | |
| def normalize_choices(value, vocab: dict[str, list[str]]) -> Optional[str]: | |
| """Turn a messy multi-value cell into 'Item, Item, free text' form. | |
| Pieces separated by newlines, tabs, commas, semicolons or runs of spaces are | |
| examined one by one. Pieces made only of vocabulary phrases become canonical | |
| items (duplicates removed, vocabulary order preserved). Any other piece is | |
| kept verbatim after the vocabulary items. | |
| """ | |
| if value is None: | |
| return None | |
| # Split on the raw text so tabs and line breaks still act as separators. | |
| text = str(value).replace("\xa0", " ").strip() | |
| if not text: | |
| return None | |
| lookup, longest = _build_lookup(vocab) | |
| canonical_order = list(vocab.keys()) | |
| found: list[str] = [] | |
| extras: list[str] = [] | |
| for raw in _HARD_SEPARATORS.split(text): | |
| piece = _WS.sub(" ", raw or "").strip() | |
| if not piece: | |
| continue | |
| tokens = _tokenize_piece(piece, lookup, longest) | |
| if tokens is None: | |
| extras.append(piece.rstrip(",")) | |
| else: | |
| for t in tokens: | |
| if t not in found: | |
| found.append(t) | |
| found.sort(key=canonical_order.index) | |
| parts = found + extras | |
| return LIST_SEPARATOR.join(parts) if parts else None | |
| def split_known(value: Optional[str], vocab: dict[str, list[str]]) -> tuple[list[str], str]: | |
| """Split a stored list value into (canonical items present, leftover text). | |
| Used by the admin editor to pre-check boxes and fill the free-text box. | |
| """ | |
| if not value: | |
| return [], "" | |
| lookup, longest = _build_lookup(vocab) | |
| known: list[str] = [] | |
| other: list[str] = [] | |
| for piece in value.split(","): | |
| piece = piece.strip() | |
| if not piece: | |
| continue | |
| tokens = _tokenize_piece(piece, lookup, longest) | |
| if tokens is None: | |
| other.append(piece) | |
| else: | |
| for t in tokens: | |
| if t not in known: | |
| known.append(t) | |
| return known, ", ".join(other) | |
| def join_choices(selected: Iterable[str], other: str, vocab: dict[str, list[str]]) -> Optional[str]: | |
| """Combine checkbox selections and free text into the stored list form.""" | |
| order = list(vocab.keys()) | |
| chosen = [s for s in order if s in set(selected)] | |
| extra = clean_text(other, keep_newlines=False) | |
| parts = chosen + ([extra] if extra else []) | |
| return LIST_SEPARATOR.join(parts) if parts else None | |