Dbu-central-bot / app /services /classifier.py
cipher-attacks's picture
analyzer + other-stream courses
b473ad4
Raw History Blame Contribute Delete
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