cipher-attacks commited on
Commit
b473ad4
·
1 Parent(s): d925e38

analyzer + other-stream courses

Browse files
Files changed (2) hide show
  1. app/services/classifier.py +54 -31
  2. tests/test_logic.py +223 -0
app/services/classifier.py CHANGED
@@ -22,7 +22,7 @@ def norm_title_key(title: str) -> str:
22
 
23
  # Short, hand-written synonyms (collapsed course name -> aliases). Cheap to extend.
24
  ALIASES: dict[str, list[str]] = {
25
- "mathematicsfornaturalsciences": ["calculus", "natural math", "math natural"],
26
  "appliedmathematicsi": ["applied math", "applied maths", "applied mathematics"],
27
  "mathematicsforsocialsciences": ["social math", "math social", "maths social"],
28
  "generalphysics": ["physics", "phys", "mechanics", "ፊዚክስ"],
@@ -31,18 +31,18 @@ ALIASES: dict[str, list[str]] = {
31
  "communicativeenglishlanguageskillsi": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"],
32
  "communicativeenglishlanguageskillsii": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"],
33
  "generalpsychology": ["psychology", "psych", "psys", "ሳይኮሎጂ"],
34
- "criticalthinking": ["logic", "loct", "logic and critical thinking"],
35
  "geographyofethiopiaandthehorn": ["geography", "geo", "gees", "ጂኦግራፊ"],
36
  "historyofethiopiaandthehorn": ["history", "hist", "ታሪክ"],
37
- "socialanthropology": ["anthropology", "anth"],
38
- "entrepreneurship": ["entrepreneur", "entre", "mgmt"],
39
  "introductiontoemergingtechnologies": ["emerging", "emte", "emerging technology"],
40
  "computerprogramming": ["programming", "coding", "eceg", "python", "c++"],
41
- "moralandciviceducation": ["civic", "civics", "moral", "mcie", "ethics"],
42
- "physicalfitness": ["fitness", "sports", "spsc"],
43
- "introductiontoeconomics": ["economics", "econ"],
44
  "globalaffairs": ["global", "global trends"],
45
- "introductiontolawandtheethiopianlegalsystem": ["law", "legal"],
46
  "freshmanguideandgeneralresources": [
47
  "cut point", "cutt point", "cutpoint", "cutoff", "cut off", "placement", "department selection",
48
  "quantitative reasoning", "verbal reasoning", "logical reasoning", "aptitude", "reasoning", "verbal",
@@ -66,20 +66,24 @@ def _ratio(a: str, b: str) -> float:
66
  return difflib.SequenceMatcher(None, a, b).ratio()
67
 
68
 
69
- def _alias_hit(alias: str, ws: Sequence[str]) -> bool:
 
70
  parts = words(alias)
71
  if not parts:
72
- return False
73
  if len(parts) > 1:
74
- return all(any(w == p for w in ws) for p in parts)
75
  p = parts[0]
 
76
  for w in ws:
77
  base = re.sub(r"\d+$", "", w)
78
- if w == p or base == p or (len(p) >= 4 and w.startswith(p)):
79
- return True
80
- if len(p) >= 6 and len(w) >= 6 and _ratio(w, p) >= 0.85: # "pysychology" -> psychology
81
- return True
82
- return False
 
 
83
 
84
 
85
  def _level(ws: Sequence[str]) -> Optional[int]:
@@ -113,8 +117,11 @@ def rank_courses(courses: Sequence, sources: Sequence[tuple[str, str]]) -> list[
113
  score += 8 * weight
114
  best_alias = 0
115
  for alias in ALIASES.get(cname_key, ()):
116
- if _alias_hit(alias, ws):
117
- best_alias = max(best_alias, 8 if len(alias.split()) > 1 else 5)
 
 
 
118
  score += best_alias * weight
119
  hits = sum(1 for nw in sig_words if any(_hit(w, nw) for w in ws))
120
  score += min(hits * 2.0 * weight, 4.0 if weight < 1 else 8.0)
@@ -142,27 +149,43 @@ def decide(ranked: Sequence[tuple[float, object]]) -> tuple[Optional[object], bo
142
 
143
 
144
  # ---------------------------------------------------------------- kind
145
- _EXAM_STRONG = ("exam", "midterm", "mid exam", "quiz", "past paper", "question paper", "ፈተና")
146
- _EXAM_WEAK = ("final", "test", "question", "previous") # "Geography Module Final" is NOT an exam
147
- _SLIDE = ("slide", "lecture", "ppt", "presentation", "chapter")
148
- _NOTE = ("note", "handout", "summary", "worksheet", "assignment", "tutorial", "module", "ማስታወሻ")
149
- _BOOK = ("textbook", "book", "edition", "isbn", "volume")
 
 
150
  _EXAM_TEXT = ("time allowed", "answer all questions", "choose the best answer", "instruction:", "id no", "name:")
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  def detect_kind(name: str, caption: str, text: str, pages: Optional[int], size: Optional[int], ext: str) -> tuple[str, str]:
154
- label = f" {(name or '').lower()} {(caption or '').lower()} ".replace("_", " ").replace("-", " ")
 
 
155
  if ext in (".ppt", ".pptx"):
156
  return "slide", "presentation file"
157
- if any(k in label for k in _EXAM_STRONG):
158
  return "exam", "name says exam"
159
- if any(k in label for k in _SLIDE):
160
- return "slide", "name says slides/chapter"
161
- if any(k in label for k in _BOOK):
162
  return "book", "name says book"
163
- if any(k in label for k in _NOTE):
164
- return "note", "name says module/notes" # module/notes beat the ambiguous word "final"
165
- if any(k in label for k in _EXAM_WEAK) or re.search(r"\bmid\b", label):
 
 
166
  return "exam", "name hints at an exam"
167
  if sum(1 for k in _EXAM_TEXT if k in (text or "").lower()) >= 2:
168
  return "exam", "content looks like an exam"
 
22
 
23
  # Short, hand-written synonyms (collapsed course name -> aliases). Cheap to extend.
24
  ALIASES: dict[str, list[str]] = {
25
+ "mathematicsfornaturalsciences": ["ሒሳብ", "ሂሳብ", "calculus", "natural math", "math natural"],
26
  "appliedmathematicsi": ["applied math", "applied maths", "applied mathematics"],
27
  "mathematicsforsocialsciences": ["social math", "math social", "maths social"],
28
  "generalphysics": ["physics", "phys", "mechanics", "ፊዚክስ"],
 
31
  "communicativeenglishlanguageskillsi": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"],
32
  "communicativeenglishlanguageskillsii": ["english", "eng", "flen", "comm english", "እንግሊዝኛ"],
33
  "generalpsychology": ["psychology", "psych", "psys", "ሳይኮሎጂ"],
34
+ "criticalthinking": ["አመክንዮ", "logic", "loct", "logic and critical thinking"],
35
  "geographyofethiopiaandthehorn": ["geography", "geo", "gees", "ጂኦግራፊ"],
36
  "historyofethiopiaandthehorn": ["history", "hist", "ታሪክ"],
37
+ "socialanthropology": ["አንትሮፖሎጂ", "anthropology", "anth"],
38
+ "entrepreneurship": ["ስራ ፈጠራ", "ሥራ ፈጠራ", "entrepreneur", "entre", "mgmt"],
39
  "introductiontoemergingtechnologies": ["emerging", "emte", "emerging technology"],
40
  "computerprogramming": ["programming", "coding", "eceg", "python", "c++"],
41
+ "moralandciviceducation": ["ሲቪክስ", "ሥነ ምግባር", "civic", "civics", "moral", "mcie", "ethics"],
42
+ "physicalfitness": ["ስፖርት", "fitness", "sports", "spsc"],
43
+ "introductiontoeconomics": ["ኢኮኖሚክስ", "ምጣኔ ሀብት", "economics", "econ"],
44
  "globalaffairs": ["global", "global trends"],
45
+ "introductiontolawandtheethiopianlegalsystem": ["ህግ", "ሕግ", "law", "legal"],
46
  "freshmanguideandgeneralresources": [
47
  "cut point", "cutt point", "cutpoint", "cutoff", "cut off", "placement", "department selection",
48
  "quantitative reasoning", "verbal reasoning", "logical reasoning", "aptitude", "reasoning", "verbal",
 
66
  return difflib.SequenceMatcher(None, a, b).ratio()
67
 
68
 
69
+ def _alias_hit(alias: str, ws: Sequence[str], fuzzy_ok: bool = True) -> int:
70
+ """0 = no match, 1 = prefix match, 2 = exact match, 3 = close typo of a whole word."""
71
  parts = words(alias)
72
  if not parts:
73
+ return 0
74
  if len(parts) > 1:
75
+ return 2 if all(any(w == p for w in ws) for p in parts) else 0
76
  p = parts[0]
77
+ best = 0
78
  for w in ws:
79
  base = re.sub(r"\d+$", "", w)
80
+ if w == p or base == p:
81
+ return 2
82
+ if len(p) >= 4 and w.startswith(p):
83
+ best = max(best, 1)
84
+ elif fuzzy_ok and len(p) >= 6 and len(w) >= 6 and w[0] == p[0] and _ratio(w, p) >= 0.7: # "psyscho" -> psychology
85
+ best = max(best, 3)
86
+ return best
87
 
88
 
89
  def _level(ws: Sequence[str]) -> Optional[int]:
 
117
  score += 8 * weight
118
  best_alias = 0
119
  for alias in ALIASES.get(cname_key, ()):
120
+ # the catch-all bucket only matches exact words ('reading' must never look like 'reasoning')
121
+ strength = _alias_hit(alias, ws, fuzzy_ok=cname_key != "freshmanguideandgeneralresources")
122
+ if strength:
123
+ multi = len(alias.split()) > 1
124
+ best_alias = max(best_alias, 8 if multi else (5 if strength == 1 else 6))
125
  score += best_alias * weight
126
  hits = sum(1 for nw in sig_words if any(_hit(w, nw) for w in ws))
127
  score += min(hits * 2.0 * weight, 4.0 if weight < 1 else 8.0)
 
149
 
150
 
151
  # ---------------------------------------------------------------- kind
152
+ _EXAM_STRONG = {"exam", "exams", "examination", "midterm", "midexam", "quiz", "quizzes", "ፈተና", "ፈተናዎች", "ፈተናው"}
153
+ _EXAM_PHRASES = ("past paper", "model exam", "question paper", "mid exam", "final exam")
154
+ _EXAM_WEAK = {"final", "test", "tests", "question", "questions", "previous", "mid", "ጥያቄ", "ጥያቄዎች"} # "Module Final" is NOT an exam
155
+ _SLIDE = {"slide", "slides", "ppt", "pptx", "presentation", "lecture", "lectures", "ስላይድ", "ስላይዶች", "ፕሬዘንቴሽን"}
156
+ _NOTE = {"note", "notes", "handout", "handouts", "summary", "summaries", "worksheet", "assignment", "tutorial", "module",
157
+ "modules", "lab", "ማስታወሻ", "ሞጁል", "ሞዱል"}
158
+ _BOOK = {"textbook", "book", "books", "edition", "isbn", "volume", "መጽሐፍ", "መፅሐፍ", "መጽሀፍ", "መፅሀፍ"}
159
  _EXAM_TEXT = ("time allowed", "answer all questions", "choose the best answer", "instruction:", "id no", "name:")
160
 
161
 
162
+ def _kind_hit(tokens: Sequence[str], vocab: set) -> bool:
163
+ """A whole word of the file name is a keyword, or a close typo of a longer one ('exm', 'quize', 'slid')."""
164
+ for t in tokens:
165
+ if t in vocab:
166
+ return True
167
+ if len(t) >= 3 and t.isascii():
168
+ for k in vocab:
169
+ 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:
170
+ return True
171
+ return False
172
+
173
+
174
  def detect_kind(name: str, caption: str, text: str, pages: Optional[int], size: Optional[int], ext: str) -> tuple[str, str]:
175
+ label = f"{name or ''} {caption or ''}".lower()
176
+ tokens = words(label)
177
+ spaced = " " + " ".join(tokens) + " "
178
  if ext in (".ppt", ".pptx"):
179
  return "slide", "presentation file"
180
+ if _kind_hit(tokens, _EXAM_STRONG) or any(f" {p} " in spaced for p in _EXAM_PHRASES):
181
  return "exam", "name says exam"
182
+ if _kind_hit(tokens, _BOOK):
 
 
183
  return "book", "name says book"
184
+ if _kind_hit(tokens, _NOTE):
185
+ return "note", "name says module/notes" # beats the ambiguous words "final" and "chapter"
186
+ if _kind_hit(tokens, _SLIDE):
187
+ return "slide", "name says slides/lecture"
188
+ if _kind_hit(tokens, _EXAM_WEAK):
189
  return "exam", "name hints at an exam"
190
  if sum(1 for k in _EXAM_TEXT if k in (text or "").lower()) >= 2:
191
  return "exam", "content looks like an exam"
tests/test_logic.py CHANGED
@@ -634,6 +634,229 @@ def test_analytics_insights_and_screens():
634
  assert render(empty)
635
 
636
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  if __name__ == "__main__":
638
  for name, fn in list(globals().items()):
639
  if name.startswith("test_"):
 
634
  assert render(empty)
635
 
636
 
637
+ REAL_NAMES = [
638
+ [
639
+ "psyscho 2014 mid exam collection.pdf",
640
+ "General Psychology",
641
+ "exam"
642
+ ],
643
+ [
644
+ "Physics mid exam 2014.pdf",
645
+ "General Physics",
646
+ "exam"
647
+ ],
648
+ [
649
+ "General Physics Final Exam.pdf",
650
+ "General Physics",
651
+ "exam"
652
+ ],
653
+ [
654
+ "Math 1003 mid.pdf",
655
+ "Mathematics for Natural Sciences",
656
+ "exam"
657
+ ],
658
+ [
659
+ "Chem Exam 2013 EC.pdf",
660
+ "General Chemistry",
661
+ "exam"
662
+ ],
663
+ [
664
+ "Anthropology Exam Questions.pdf",
665
+ "Social Anthropology",
666
+ "exam"
667
+ ],
668
+ [
669
+ "Logic and critical thinking questions.pdf",
670
+ "Critical Thinking",
671
+ "exam"
672
+ ],
673
+ [
674
+ "ኢኮኖሚክስ ጥያቄ.pdf",
675
+ "Introduction to Economics",
676
+ "exam"
677
+ ],
678
+ [
679
+ "ፊዚክስ ፈተና 2014.pdf",
680
+ "General Physics",
681
+ "exam"
682
+ ],
683
+ [
684
+ "Civics model exam.pdf",
685
+ "Moral and Civic Education",
686
+ "exam"
687
+ ],
688
+ [
689
+ "Past paper English 2013.pdf",
690
+ null,
691
+ "exam"
692
+ ],
693
+ [
694
+ "geography quiz 1.pdf",
695
+ "Geography of Ethiopia and the Horn",
696
+ "exam"
697
+ ],
698
+ [
699
+ "Psychology test 1 answer key.pdf",
700
+ "General Psychology",
701
+ "exam"
702
+ ],
703
+ [
704
+ "Mid-term exam for Entrepreneurship.pdf",
705
+ "Entrepreneurship",
706
+ "exam"
707
+ ],
708
+ [
709
+ "Psychology note.pdf",
710
+ "General Psychology",
711
+ "note"
712
+ ],
713
+ [
714
+ "Chapter 1-3 summary psychology.pdf",
715
+ "General Psychology",
716
+ "note"
717
+ ],
718
+ [
719
+ "ፊዚክስ ማስታወሻ.pdf",
720
+ "General Physics",
721
+ "note"
722
+ ],
723
+ [
724
+ "Economics handout.pdf",
725
+ "Introduction to Economics",
726
+ "note"
727
+ ],
728
+ [
729
+ "Entrepreneurship chapter 2 notes.pdf",
730
+ "Entrepreneurship",
731
+ "note"
732
+ ],
733
+ [
734
+ "Anthropology module.pdf",
735
+ "Social Anthropology",
736
+ "note"
737
+ ],
738
+ [
739
+ "Physics short note for mid.pdf",
740
+ "General Physics",
741
+ "note"
742
+ ],
743
+ [
744
+ "Lecture 3 physics.pdf",
745
+ "General Physics",
746
+ "slide"
747
+ ],
748
+ [
749
+ "Chapter 1 Entrepreneurship.pptx",
750
+ "Entrepreneurship",
751
+ "slide"
752
+ ],
753
+ [
754
+ "ስላይድ ኢኮኖሚክስ.pdf",
755
+ "Introduction to Economics",
756
+ "slide"
757
+ ],
758
+ [
759
+ "General Chemistry textbook.pdf",
760
+ "General Chemistry",
761
+ "book"
762
+ ],
763
+ [
764
+ "ባዮሎጂ መጽሐፍ.pdf",
765
+ "General Biology",
766
+ "book"
767
+ ],
768
+ [
769
+ "document.pdf",
770
+ null,
771
+ "note"
772
+ ],
773
+ [
774
+ "IMG_2034.pdf",
775
+ null,
776
+ "note"
777
+ ],
778
+ [
779
+ "1698765432.pdf",
780
+ null,
781
+ "note"
782
+ ],
783
+ [
784
+ "PSYCH_1007_Module(final).pdf",
785
+ "General Psychology",
786
+ "note"
787
+ ],
788
+ [
789
+ "Psys1007 worksheet 2.pdf",
790
+ "General Psychology",
791
+ "note"
792
+ ],
793
+ [
794
+ "physics lab report.pdf",
795
+ "General Physics",
796
+ "note"
797
+ ],
798
+ [
799
+ "economics assignment 1.pdf",
800
+ "Introduction to Economics",
801
+ "note"
802
+ ],
803
+ [
804
+ "Exm 2014 geography.pdf",
805
+ "Geography of Ethiopia and the Horn",
806
+ "exam"
807
+ ],
808
+ [
809
+ "Psychology Exam collection (2013-2015).pdf",
810
+ "General Psychology",
811
+ "exam"
812
+ ],
813
+ [
814
+ "philosophy notes.pdf",
815
+ null,
816
+ "note"
817
+ ],
818
+ [
819
+ "geometry worked examples.pdf",
820
+ null,
821
+ "note"
822
+ ],
823
+ [
824
+ "notebook.pdf",
825
+ null,
826
+ "note"
827
+ ],
828
+ [
829
+ "finalize registration.pdf",
830
+ null,
831
+ "note"
832
+ ],
833
+ [
834
+ "midnight reading.pdf",
835
+ null,
836
+ "note"
837
+ ],
838
+ [
839
+ "latest news.pdf",
840
+ null,
841
+ "note"
842
+ ]
843
+ ]
844
+
845
+
846
+ def test_realistic_student_file_names():
847
+ """Amharic names, typos, mid/final/collection, chapter..., plus words that must NOT be misread."""
848
+ import os
849
+
850
+ wrong = []
851
+ for name, want_course, want_kind in REAL_NAMES:
852
+ best, confident, _ = C.decide(C.rank_courses(COURSES, [(name, "name")]))
853
+ course = best.name if (best and confident) else None
854
+ kind, _why = C.detect_kind(name, "", "", None, None, os.path.splitext(name)[1].lower())
855
+ if (course, kind) != (want_course, want_kind):
856
+ wrong.append((name, course, kind))
857
+ assert not wrong, wrong
858
+
859
+
860
  if __name__ == "__main__":
861
  for name, fn in list(globals().items()):
862
  if name.startswith("test_"):