sahil-12kumar/IL_CMS_Tools
1
1"""Multi-subject support: AI Tagging and LO Classifier accept several subjects.2 3The taxonomy dicts were already subject-keyed ({subject: {chapter: ...}}), so the4unit tests here cover the pure multi-subject paths in ai_assist / lo_assist that5the new chips UI and the /tag/prep, /tag/apply, /lo/prep, /lo/apply routes rely on.6"""7import json8 9import ai_assist10import lo_assist11 12 13TAX = {14 "Physics": {15 "Kinematics": {16 "Motion in a Straight Line": {17 "Uniform Motion": ["Define uniform motion", "Apply v = u + at"],18 },19 },20 },21 "Chemistry": {22 "Chemical Bonding": {23 "Basics": {24 "Octet Rule": ["State the octet rule"],25 },26 },27 },28}29 30OPTIONS = {31 "objective": ["Theory"],32 "bloom_taxonomy": ["Knowledge"],33 "relevance": ["Relevant to NEET"],34 "concept_level": ["Chapter level"],35 "syllabus": ["NCERT-based"],36 "source": [],37}38 39 40def _fixture_tax():41 return TAX42 43 44# ── ai_assist (AI Tagging) ────────────────────────────────────────────────────45def test_ai_parse_and_validate_multisubject():46 js = json.dumps([47 {"question_id": "ILQ-1", "subject": "Physics",48 "chapter": "Kinematics", "topic": "Motion in a Straight Line",49 "subtopic": "Uniform Motion", "grade": "Grade 11", "difficulty": "easy",50 "objective": "Theory", "bloom_taxonomy": "Knowledge",51 "relevance": "Relevant to NEET", "concept_level": "Chapter level",52 "syllabus": "NCERT-based"},53 {"question_id": "ILQ-2", "subject": "Chemistry",54 "chapter": "Chemical Bonding", "topic": "Basics",55 "subtopic": "Octet Rule", "grade": "Grade 12", "difficulty": "moderate"},56 ])57 rows, warnings = ai_assist.parse_and_validate(js, TAX, OPTIONS, "NEET")58 assert {r["question_id"]: r["subject"] for r in rows} == {59 "ILQ-1": "Physics", "ILQ-2": "Chemistry"}60 assert not warnings61 62 63def test_ai_parse_and_validate_unknown_subject_warns():64 js = json.dumps([{"question_id": "ILQ-9", "subject": "Biology",65 "chapter": "X", "topic": "Y", "subtopic": "Z"}])66 rows, warnings = ai_assist.parse_and_validate(js, TAX, OPTIONS, "NEET")67 assert rows[0]["subject"] == "Biology" # kept, only warned68 assert any("TOC path not found" in w for w in warnings)69 70 71# ── lo_assist (LO Classifier) ─────────────────────────────────────────────────72def test_lo_merged(monkeypatch):73 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)74 m = lo_assist.merged(["Physics", "Chemistry"], chapter_filter=["kin"])75 assert list(m["Physics"]) == ["Kinematics"]76 assert m["Chemistry"] == {} # "Chemical Bonding" has no "kin"77 full = lo_assist.merged(["Physics", "Chemistry"])78 assert set(full) == {"Physics", "Chemistry"}79 80 81def test_lo_parse_and_validate_multisubject(monkeypatch):82 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)83 js = json.dumps([84 {"question_id": "ILQ-1", "subject": "Physics",85 "chapter": "Kinematics", "topic": "Motion in a Straight Line",86 "subtopic": "Uniform Motion", "lo": "Define uniform motion"},87 {"question_id": "ILQ-2", "subject": "Chemistry",88 "chapter": "Chemical Bonding", "topic": "Basics",89 "subtopic": "Octet Rule", "lo": "State the octet rule"},90 ])91 valid, rejected, warnings = lo_assist.parse_and_validate(js, ["Physics", "Chemistry"])92 assert not rejected and not warnings93 assert {r["question_id"]: r["subject"] for r in valid} == {94 "ILQ-1": "Physics", "ILQ-2": "Chemistry"}95 assert valid[0]["chapter"] == "Kinematics"96 assert valid[1]["chapter"] == "Chemical Bonding"97 98 99def test_lo_parse_and_validate_single_subject_backwards_compat(monkeypatch):100 """A row without a subject defaults to the single selected subject."""101 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)102 js = json.dumps([103 {"question_id": "ILQ-3", "chapter": "Kinematics",104 "topic": "Motion in a Straight Line", "subtopic": "Uniform Motion",105 "lo": "Define uniform motion"},106 ])107 valid, rejected, _ = lo_assist.parse_and_validate(js, "Physics")108 assert not rejected109 assert valid[0]["subject"] == "Physics"110 111 112def test_lo_parse_and_validate_missing_subject_rejected(monkeypatch):113 """With several subjects selected, a row must say which subject it belongs to."""114 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)115 js = json.dumps([116 {"question_id": "ILQ-4", "chapter": "Kinematics",117 "topic": "Motion in a Straight Line", "subtopic": "Uniform Motion",118 "lo": "Define uniform motion"},119 ])120 valid, rejected, _ = lo_assist.parse_and_validate(js, ["Physics", "Chemistry"])121 assert not valid122 assert len(rejected) == 1123 assert "subject" in rejected[0]["reason"]124 125 126def test_lo_parse_and_validate_unknown_subject_rejected(monkeypatch):127 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)128 js = json.dumps([129 {"question_id": "ILQ-5", "subject": "Biology",130 "chapter": "Kinematics", "topic": "Motion in a Straight Line",131 "subtopic": "Uniform Motion", "lo": "Define uniform motion"},132 ])133 valid, rejected, _ = lo_assist.parse_and_validate(js, ["Physics", "Chemistry"])134 assert not valid135 assert len(rejected) == 1136 137 138def test_lo_build_prompt_multisubject(monkeypatch):139 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)140 tree = lo_assist.merged(["Physics", "Chemistry"])141 p = lo_assist.build_prompt(142 [{"question_id": "ILQ-1", "question_text": "A sample question."}],143 ["Physics", "Chemistry"], tree)144 assert "### Physics" in p and "### Chemistry" in p145 assert "subject, question_id, chapter" in p # output keys now include subject146 147 148# ── lo_assist: question level (easy / moderate / difficult) ───────────────────149def _lo_row(**over):150 row = {"question_id": "ILQ-1", "subject": "Physics", "chapter": "Kinematics",151 "topic": "Motion in a Straight Line", "subtopic": "Uniform Motion",152 "lo": "Define uniform motion"}153 row.update(over)154 return json.dumps([row])155 156 157def test_lo_level_valid(monkeypatch):158 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)159 valid, rejected, warnings = lo_assist.parse_and_validate(160 _lo_row(level="Moderate"), "Physics")161 assert not rejected and not warnings162 assert valid[0]["level"] == "moderate" # normalised to the sheet vocab163 164 165def test_lo_level_difficulty_alias(monkeypatch):166 """The CMS calls this difficulty_level, so models reach for "difficulty"."""167 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)168 valid, _, warnings = lo_assist.parse_and_validate(169 _lo_row(difficulty="difficult"), "Physics")170 assert valid[0]["level"] == "difficult"171 assert not warnings172 173 174def test_lo_level_invalid_warns_but_keeps_row(monkeypatch):175 """A bad level must not throw away a row whose taxonomy path validated."""176 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)177 valid, rejected, warnings = lo_assist.parse_and_validate(178 _lo_row(level="hard"), "Physics")179 assert not rejected180 assert valid[0]["level"] == ""181 assert len(warnings) == 1 and "hard" in warnings[0]182 183 184def test_lo_level_missing_is_silent(monkeypatch):185 """No level at all is blank WITHOUT a warning — only a wrong one warns."""186 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)187 valid, rejected, warnings = lo_assist.parse_and_validate(_lo_row(), "Physics")188 assert not rejected and not warnings189 assert valid[0]["level"] == ""190 191 192def test_lo_build_prompt_has_level_rubric(monkeypatch):193 monkeypatch.setattr(lo_assist, "taxonomy", _fixture_tax)194 p = lo_assist.build_prompt(195 [{"question_id": "ILQ-1", "question_text": "A sample question."}],196 "Physics", lo_assist.merged(["Physics"]))197 assert "easy | moderate | difficult" in p198 assert "single concept, or a direct formula" in p199 assert "two or three concepts" in p200 assert "multiple concepts combined" in p201 assert "subtopic, lo, level" in p # level is an OUTPUT key202 203 204def test_lo_bank_cols_has_level_between_lo_and_cc():205 cols = lo_assist.BANK_COLS206 assert cols.index("lo") + 1 == cols.index("level") == cols.index("cc_code") - 1207 208 209def test_lo_bank_append_backfills_a_missing_column(tmp_path, monkeypatch):210 """A bank written before `level` existed must GAIN the column on its next211 save. bank_append deliberately appends by the FILE's header, so without the212 backfill every pre-existing workbook would drop the value forever."""213 from openpyxl import Workbook, load_workbook214 215 monkeypatch.setattr(lo_assist, "BANK_DIR", str(tmp_path))216 old_cols = [c for c in lo_assist.BANK_COLS if c != "level"] # pre-level bank217 wb = Workbook()218 ws = wb.active219 ws.append(old_cols)220 ws.append(["2026-01-01 09:00", "someone", "Physics", "OLD-1", "old q",221 "Kinematics", "T", "S", "L", "", "", ""])222 wb.save(lo_assist.bank_path("Physics"))223 224 lo_assist.bank_append([{"subject": "Physics", "question_id": "NEW-1",225 "chapter": "Kinematics", "topic": "T", "subtopic": "S",226 "lo": "L", "level": "moderate", "cc_code": "",227 "alt_los": [], "note": ""}], added_by="test")228 229 ws = load_workbook(lo_assist.bank_path("Physics")).active230 header = [str(c.value or "") for c in ws[1]]231 assert header[:len(old_cols)] == old_cols # existing columns unmoved232 assert header.count("level") == 1 # appended exactly once233 lvl = header.index("level") + 1234 assert ws.cell(row=2, column=lvl).value in (None, "") # historic row blank235 assert ws.cell(row=2, column=header.index("question_id") + 1).value == "OLD-1"236 assert ws.cell(row=3, column=lvl).value == "moderate" # new row populated237 