Spaces:
Running on Zero
Running on Zero
Download app/services/classifier.py from cipher-attacks/Dbu-central-bot: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/spaces/cipher-attacks/Dbu-central-bot/resolve/main/app/services/classifier.py
- Command line
-
hf download hf://spaces/cipher-attacks/Dbu-central-bot/app/services/classifier.py
-
curl -L -o classifier.py https://huggingface.co/spaces/cipher-attacks/Dbu-central-bot/resolve/main/app/services/classifier.py
13.6 kB
| """Smart file understanding: which course, which kind, a clean title, duplicate fingerprints. | |
| Pure functions (no I/O) so everything here is unit-tested.""" | |
| import hashlib | |
| import re | |
| import urllib.parse | |
| from typing import Optional, Sequence | |
| from app.services.fuzzy import words | |
| # ---------------------------------------------------------------- helpers | |
| _STOP = {"for", "of", "the", "and", "to", "in", "on", "a", "an"} | |
| _GENERIC = {"general", "introduction", "skills", "language", "sciences", "science", "education", "technologies", "communicative"} | |
| def collapse(text: str) -> str: | |
| return re.sub(r"[^a-z0-9\u1200-\u137f]+", "", (text or "").lower()) | |
| def norm_title_key(title: str) -> str: | |
| return collapse(title) | |
| # Short, hand-written synonyms (collapsed course name -> aliases). Cheap to extend. | |
| ALIASES: dict[str, list[str]] = { | |
| "mathematicsfornaturalsciences": ["ሒሳብ", "ሂሳብ", "calculus", "natural math", "math natural"], | |
| "appliedmathematicsi": ["applied math", "applied maths", "applied mathematics"], | |
| "mathematicsforsocialsciences": ["social math", "math social", "maths social"], | |
| "generalphysics": ["physics", "phys", "mechanics", "ፊዚክስ"], | |
| "generalchemistry": ["chemistry", "chem", "ኬሚስትሪ"], | |
| "generalbiology": ["biology", "bio", "ባዮሎጂ"], | |
| "communicativeenglishlanguageskillsi": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"], | |
| "communicativeenglishlanguageskillsii": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"], | |
| "generalpsychology": ["psychology", "psych", "psys", "ሳይኮሎጂ"], | |
| "criticalthinking": ["አመክንዮ", "logic", "loct", "logic and critical thinking"], | |
| "geographyofethiopiaandthehorn": ["geography", "geo", "gees", "ጂኦግራፊ"], | |
| "historyofethiopiaandthehorn": ["history", "hist", "ታሪክ"], | |
| "socialanthropology": ["አንትሮፖሎጂ", "anthropology", "anth"], | |
| "entrepreneurship": ["ስራ ፈጠራ", "ሥራ ፈጠራ", "entrepreneur", "entre", "mgmt"], | |
| "introductiontoemergingtechnologies": ["emerging", "emte", "emerging technology"], | |
| "computerprogramming": ["programming", "coding", "eceg", "python", "c++"], | |
| "moralandciviceducation": ["ሲቪክስ", "ሥነ ምግባር", "civic", "civics", "moral", "mcie", "ethics"], | |
| "physicalfitness": ["ስፖርት", "fitness", "sports", "spsc"], | |
| "introductiontoeconomics": ["ኢኮኖሚክስ", "ምጣኔ ሀብት", "economics", "econ"], | |
| "globalaffairs": ["global", "global trends"], | |
| "introductiontolawandtheethiopianlegalsystem": ["ህግ", "ሕግ", "law", "legal"], | |
| "freshmanguideandgeneralresources": [ | |
| "cut point", "cutt point", "cutpoint", "cutoff", "cut off", "placement", "department selection", | |
| "quantitative reasoning", "verbal reasoning", "logical reasoning", "aptitude", "reasoning", "verbal", | |
| "quantitative", "guide", "handbook", "calendar", "schedule", "scholarship", "orientation", "exit exam", | |
| ], | |
| } | |
| def _hit(word: str, target: str) -> bool: | |
| """word (from a file name) refers to target (a course-name word).""" | |
| if word == target: | |
| return True | |
| if len(word) >= 4 and (target.startswith(word) or word.startswith(target)): | |
| return True | |
| return len(word) >= 5 and len(target) >= 5 and _ratio(word, target) >= 0.85 | |
| def _ratio(a: str, b: str) -> float: | |
| import difflib | |
| return difflib.SequenceMatcher(None, a, b).ratio() | |
| def _alias_hit(alias: str, ws: Sequence[str], fuzzy_ok: bool = True) -> int: | |
| """0 = no match, 1 = prefix match, 2 = exact match, 3 = close typo of a whole word.""" | |
| parts = words(alias) | |
| if not parts: | |
| return 0 | |
| if len(parts) > 1: | |
| return 2 if all(any(w == p for w in ws) for p in parts) else 0 | |
| p = parts[0] | |
| best = 0 | |
| for w in ws: | |
| base = re.sub(r"\d+$", "", w) | |
| if w == p or base == p: | |
| return 2 | |
| if len(p) >= 4 and w.startswith(p): | |
| best = max(best, 1) | |
| elif fuzzy_ok and len(p) >= 6 and len(w) >= 6 and w[0] == p[0] and _ratio(w, p) >= 0.7: # "psyscho" -> psychology | |
| best = max(best, 3) | |
| return best | |
| def _level(ws: Sequence[str]) -> Optional[int]: | |
| if any(w in ("ii", "2") for w in ws) or any(re.fullmatch(r"[a-z]*(1002|ii)", w) for w in ws): | |
| return 2 | |
| if any(w in ("i", "1") for w in ws): | |
| return 1 | |
| return None | |
| # ---------------------------------------------------------------- course matching | |
| def rank_courses(courses: Sequence, sources: Sequence[tuple[str, str]]) -> list[tuple[float, object]]: | |
| """sources: [(text, 'name'|'text')]. 'name' = file name/caption (strong), 'text' = content (weak). | |
| Returns [(score, course)] best first, only courses with score > 0.""" | |
| prepared = [] | |
| for text, kind in sources: | |
| if text and text.strip(): | |
| prepared.append((collapse(text), words(text), 1.0 if kind == "name" else 0.5)) | |
| if not prepared: | |
| return [] | |
| out = [] | |
| for c in courses: | |
| cname_key = collapse(c.name) | |
| code_key = collapse(c.code) if c.code else "" | |
| sig_words = [w for w in words(c.name) if len(w) >= 4 and w not in _STOP and w not in _GENERIC] | |
| score = 0.0 | |
| for compact, ws, weight in prepared: | |
| if code_key and code_key in compact: | |
| score += 10 * weight | |
| if cname_key and cname_key in compact: | |
| score += 8 * weight | |
| best_alias = 0 | |
| for alias in ALIASES.get(cname_key, ()): | |
| # the catch-all bucket only matches exact words ('reading' must never look like 'reasoning') | |
| strength = _alias_hit(alias, ws, fuzzy_ok=cname_key != "freshmanguideandgeneralresources") | |
| if strength: | |
| multi = len(alias.split()) > 1 | |
| best_alias = max(best_alias, 8 if multi else (5 if strength == 1 else 6)) | |
| score += best_alias * weight | |
| hits = sum(1 for nw in sig_words if any(_hit(w, nw) for w in ws)) | |
| score += min(hits * 2.0 * weight, 4.0 if weight < 1 else 8.0) | |
| # "English I" vs "English II": use the level written in the file name | |
| name_words = words(c.name) | |
| if name_words and name_words[-1] in ("i", "ii") and score > 0: | |
| lvl = _level([w for _, ws, wt in prepared if wt == 1.0 for w in ws]) | |
| if lvl: | |
| is_two = name_words[-1] == "ii" | |
| score += 3 if (lvl == 2) == is_two else -3 | |
| if score > 0: | |
| out.append((score, c)) | |
| out.sort(key=lambda item: (-item[0], item[1].id)) | |
| return out | |
| def decide(ranked: Sequence[tuple[float, object]]) -> tuple[Optional[object], bool, list]: | |
| """-> (best course or None, confident?, top-3 suggestions).""" | |
| if not ranked: | |
| return None, False, [] | |
| top = ranked[0][0] | |
| second = ranked[1][0] if len(ranked) > 1 else 0.0 | |
| confident = top >= 6 and (top - second >= 3 or second <= 0) | |
| return ranked[0][1], confident, [c for _, c in ranked[:3]] | |
| # ---------------------------------------------------------------- kind | |
| _EXAM_STRONG = {"exam", "exams", "examination", "midterm", "midexam", "quiz", "quizzes", "ፈተና", "ፈተናዎች", "ፈተናው"} | |
| _EXAM_PHRASES = ("past paper", "model exam", "question paper", "mid exam", "final exam") | |
| _EXAM_WEAK = {"final", "test", "tests", "question", "questions", "previous", "mid", "ጥያቄ", "ጥያቄዎች"} # "Module Final" is NOT an exam | |
| _SLIDE = {"slide", "slides", "ppt", "pptx", "presentation", "lecture", "lectures", "ስላይድ", "ስላይዶች", "ፕሬዘንቴሽን"} | |
| _NOTE = {"note", "notes", "handout", "handouts", "summary", "summaries", "worksheet", "assignment", "tutorial", "module", | |
| "modules", "lab", "ማስታወሻ", "ሞጁል", "ሞዱል"} | |
| _BOOK = {"textbook", "book", "books", "edition", "isbn", "volume", "መጽሐፍ", "መፅሐፍ", "መጽሀፍ", "መፅሀፍ"} | |
| _EXAM_TEXT = ("time allowed", "answer all questions", "choose the best answer", "instruction:", "id no", "name:") | |
| def _kind_hit(tokens: Sequence[str], vocab: set) -> bool: | |
| """A whole word of the file name is a keyword, or a close typo of a longer one ('exm', 'quize', 'slid').""" | |
| for t in tokens: | |
| if t in vocab: | |
| return True | |
| if len(t) >= 3 and t.isascii(): | |
| for k in vocab: | |
| if k.isascii() and len(k) >= 4 and t[0] == k[0] and abs(len(t) - len(k)) <= 2 and _ratio(t, k) >= 0.8: | |
| return True | |
| return False | |
| def detect_kind(name: str, caption: str, text: str, pages: Optional[int], size: Optional[int], ext: str) -> tuple[str, str]: | |
| label = f"{name or ''} {caption or ''}".lower() | |
| tokens = words(label) | |
| spaced = " " + " ".join(tokens) + " " | |
| if ext in (".ppt", ".pptx"): | |
| return "slide", "presentation file" | |
| if _kind_hit(tokens, _EXAM_STRONG) or any(f" {p} " in spaced for p in _EXAM_PHRASES): | |
| return "exam", "name says exam" | |
| if _kind_hit(tokens, _BOOK): | |
| return "book", "name says book" | |
| if _kind_hit(tokens, _NOTE): | |
| return "note", "name says module/notes" # beats the ambiguous words "final" and "chapter" | |
| if _kind_hit(tokens, _SLIDE): | |
| return "slide", "name says slides/lecture" | |
| if _kind_hit(tokens, _EXAM_WEAK): | |
| return "exam", "name hints at an exam" | |
| if sum(1 for k in _EXAM_TEXT if k in (text or "").lower()) >= 2: | |
| return "exam", "content looks like an exam" | |
| if pages and pages >= 350: # national modules are 150-300 pages: those stay "note" | |
| return "book", f"{pages} pages" | |
| if size and size > 40 * 1024 * 1024: | |
| return "book", "very large file" | |
| return "note", "default" | |
| # ---------------------------------------------------------------- title | |
| _JUNK = re.compile(r"(https?://\S+|t\.me/\S+|@\w+|www\.\S+|\(\d+\)|\bcopy\b|\bscanned\b)", re.I) | |
| def detect_year(*texts: str) -> Optional[str]: | |
| for t in texts: | |
| m = re.search(r"\b(20[0-3]\d)\b", t or "") | |
| if m: | |
| return m.group(1) | |
| return None | |
| def exam_stage(*texts: str) -> Optional[str]: | |
| blob = " ".join(texts).lower() | |
| if "mid" in blob: | |
| return "Mid" | |
| if "final" in blob: | |
| return "Final" | |
| if "quiz" in blob: | |
| return "Quiz" | |
| return None | |
| def clean_title(file_name: Optional[str], caption: Optional[str], course_name: str, kind: str, year: Optional[str], stage: Optional[str]) -> str: | |
| base = re.sub(r"\.[A-Za-z0-9]{2,5}$", "", urllib.parse.unquote(file_name or "")) | |
| base = _JUNK.sub(" ", base) # junk first: "@some_channel" must go before underscores become spaces | |
| base = base.replace("_", " ").replace("-", " ").replace(".", " ") | |
| parts = [] | |
| for tok in base.split(): | |
| if re.fullmatch(r"\d{8,}", tok) or (len(tok) >= 12 and re.search(r"\d", tok) and re.search(r"[a-z]", tok, re.I)): | |
| continue # phone-number / hash-like junk | |
| parts.append(tok) | |
| deduped: list[str] = [] | |
| for tok in parts: | |
| if deduped and deduped[-1].lower() == tok.lower(): | |
| continue # "final FINAL" -> "final" | |
| deduped.append(tok.capitalize() if tok.islower() and len(tok) > 3 else tok) | |
| title = " ".join(deduped).strip() | |
| if not title and caption: | |
| title = _JUNK.sub(" ", caption.strip().split("\n")[0]).strip() | |
| if not title: | |
| title = f"{course_name} {kind}" | |
| if title.isupper() or title.islower(): | |
| title = title.title() | |
| if kind == "exam": | |
| if stage and stage.lower() not in title.lower(): | |
| title += f" {stage}" | |
| if year and year not in title: | |
| title += f" {year}" | |
| return re.sub(r"\s+", " ", title)[:80] | |
| def display_title(title: str) -> str: | |
| """What students see. Fixes titles saved by older versions (%28 codes, underscores, 'final FINAL') | |
| without touching anything that was renamed by hand.""" | |
| out: list[str] = [] | |
| for tok in urllib.parse.unquote(title or "").replace("_", " ").split(): | |
| if out and out[-1].lower() == tok.lower(): | |
| continue | |
| out.append(tok) | |
| return " ".join(out) | |
| def disambiguate(title: str, taken: set, *, year: Optional[str], pages: Optional[int], size: Optional[int]) -> str: | |
| """Same title already used for a DIFFERENT file in this course/kind -> add what tells them apart.""" | |
| options = [] | |
| if year and year not in title: | |
| options.append(f"{title} ({year})") | |
| if pages: | |
| options.append(f"{title} ({pages} p)") | |
| if size: | |
| options.append(f"{title} ({size / 1048576:.1f} MB)") | |
| for candidate in options: | |
| if norm_title_key(candidate) not in taken: | |
| return candidate | |
| n = 2 | |
| while norm_title_key(f"{title} ({n})") in taken: | |
| n += 1 | |
| return f"{title} ({n})" | |
| # ---------------------------------------------------------------- near-duplicate fingerprint | |
| def simhash64(text: str) -> Optional[int]: | |
| """64-bit SimHash of word 3-grams (signed, fits a BIGINT). None if there is too little text.""" | |
| ws = words(text) | |
| if len(ws) < 40: | |
| return None | |
| vec = [0] * 64 | |
| for i in range(len(ws) - 2): | |
| h = int.from_bytes(hashlib.blake2b(" ".join(ws[i : i + 3]).encode(), digest_size=8).digest(), "big") | |
| for b in range(64): | |
| vec[b] += 1 if (h >> b) & 1 else -1 | |
| out = 0 | |
| for b in range(64): | |
| if vec[b] > 0: | |
| out |= 1 << b | |
| return out - (1 << 64) if out >= (1 << 63) else out | |
| def hamming(a: int, b: int) -> int: | |
| return ((a ^ b) & ((1 << 64) - 1)).bit_count() | |
| NEAR_DUPLICATE_BITS = 3 | |