"""Tests for the Evidence-Bound Answer Contract. Verifies: 1. Correct evidence level determination based on uploaded paper count/years. 2. Prompt rules block the right hallucinated language for each level. 3. Correct evidence labels. 4. API responses include evidence_label field. 5. Chat 'pyq' intent system prompt is evidence-correct (no fake frequency claims when no papers are uploaded). """ from __future__ import annotations # ── Unit tests (pure helpers, no DB) ───────────────────────────────────────── class TestEvidenceLevelDetermination: """_determine_level logic.""" def test_zero_papers_is_material_only(self) -> None: from app.services.evidence_contract import _determine_level, MATERIAL_ONLY assert _determine_level(0, 0) == MATERIAL_ONLY def test_one_paper_is_single_paper(self) -> None: from app.services.evidence_contract import _determine_level, SINGLE_PAPER assert _determine_level(1, 1) == SINGLE_PAPER def test_two_papers_low_years_is_multi(self) -> None: from app.services.evidence_contract import _determine_level, MULTI_PAPER assert _determine_level(2, 2) == MULTI_PAPER def test_many_papers_low_years_is_multi(self) -> None: from app.services.evidence_contract import _determine_level, MULTI_PAPER assert _determine_level(7, 5) == MULTI_PAPER def test_8_years_is_verified_10yr(self) -> None: from app.services.evidence_contract import _determine_level, VERIFIED_10YR assert _determine_level(8, 8) == VERIFIED_10YR def test_10_years_is_verified_10yr(self) -> None: from app.services.evidence_contract import _determine_level, VERIFIED_10YR assert _determine_level(10, 10) == VERIFIED_10YR def test_7_distinct_years_is_still_multi(self) -> None: from app.services.evidence_contract import _determine_level, MULTI_PAPER assert _determine_level(7, 7) == MULTI_PAPER class TestEvidenceLabels: """_make_label output matches spec.""" def test_material_only_label(self) -> None: from app.services.evidence_contract import _make_label, MATERIAL_ONLY label = _make_label(MATERIAL_ONLY, 0, 0, None, None, None) assert label == "Based on uploaded material only" def test_single_paper_label(self) -> None: from app.services.evidence_contract import _make_label, SINGLE_PAPER label = _make_label(SINGLE_PAPER, 1, 1, None, None, None) assert label == "Based on 1 uploaded question paper" def test_multi_paper_label_shows_count(self) -> None: from app.services.evidence_contract import _make_label, MULTI_PAPER label = _make_label(MULTI_PAPER, 4, 3, None, None, None) assert "4" in label assert "question paper" in label def test_verified_10yr_label_shows_years(self) -> None: from app.services.evidence_contract import _make_label, VERIFIED_10YR label = _make_label(VERIFIED_10YR, 10, 10, None, None, None) assert "10" in label assert "PYQ" in label class TestPromptRulesContent: """Prompt rules must block the right phrases for each level.""" def test_material_only_blocks_pyq_pattern(self) -> None: from app.services.evidence_contract import _make_rules, MATERIAL_ONLY rules = _make_rules(MATERIAL_ONLY, 0, 0, None, None, None) assert "PYQ pattern" in rules assert "PROHIBITED" in rules def test_material_only_blocks_cbse_year(self) -> None: from app.services.evidence_contract import _make_rules, MATERIAL_ONLY rules = _make_rules(MATERIAL_ONLY, 0, 0, None, None, None) assert "CBSE 2023" in rules def test_material_only_blocks_state_board_year(self) -> None: from app.services.evidence_contract import _make_rules, MATERIAL_ONLY rules = _make_rules(MATERIAL_ONLY, 0, 0, None, None, None) assert "State Board 2024" in rules def test_material_only_blocks_10year_trend(self) -> None: from app.services.evidence_contract import _make_rules, MATERIAL_ONLY rules = _make_rules(MATERIAL_ONLY, 0, 0, None, None, None) assert "10-year trend" in rules def test_material_only_blocks_asked_every_year(self) -> None: from app.services.evidence_contract import _make_rules, MATERIAL_ONLY rules = _make_rules(MATERIAL_ONLY, 0, 0, None, None, None) assert "asked every year" in rules def test_material_only_allows_based_on_material(self) -> None: from app.services.evidence_contract import _make_rules, MATERIAL_ONLY rules = _make_rules(MATERIAL_ONLY, 0, 0, None, None, None) assert "Likely from this material" in rules def test_single_paper_blocks_trend(self) -> None: from app.services.evidence_contract import _make_rules, SINGLE_PAPER rules = _make_rules(SINGLE_PAPER, 1, 1, None, None, None) assert "trend" in rules assert "PROHIBITED" in rules def test_single_paper_allows_in_this_paper(self) -> None: from app.services.evidence_contract import _make_rules, SINGLE_PAPER rules = _make_rules(SINGLE_PAPER, 1, 1, None, None, None) assert "In this uploaded paper" in rules def test_multi_paper_blocks_10yr_trend(self) -> None: from app.services.evidence_contract import _make_rules, MULTI_PAPER rules = _make_rules(MULTI_PAPER, 3, 3, None, None, None) assert "10-year trend" in rules assert "PROHIBITED" in rules def test_multi_paper_allows_across_uploaded(self) -> None: from app.services.evidence_contract import _make_rules, MULTI_PAPER rules = _make_rules(MULTI_PAPER, 3, 3, None, None, None) assert "Across uploaded papers" in rules def test_verified_10yr_allows_10year_claim(self) -> None: from app.services.evidence_contract import _make_rules, VERIFIED_10YR rules = _make_rules(VERIFIED_10YR, 10, 10, None, None, None) assert "10-year PYQ pattern" in rules def test_verified_10yr_still_says_use_uploaded_only(self) -> None: from app.services.evidence_contract import _make_rules, VERIFIED_10YR rules = _make_rules(VERIFIED_10YR, 10, 10, None, None, None) assert "uploaded papers" in rules.lower() class TestPyqSystemPrompts: """get_pyq_system_prompt returns the evidence-correct prompt.""" def test_material_only_pyq_prompt_blocks_fake_claims(self) -> None: from app.services.evidence_contract import get_pyq_system_prompt, MATERIAL_ONLY prompt = get_pyq_system_prompt(MATERIAL_ONLY) assert "Do NOT claim" in prompt assert "PYQ" in prompt.upper() or "previous" in prompt.lower() # Must NOT contain the hallucination-prone old language assert "every year" not in prompt.lower() or "do not" in prompt.lower() def test_material_only_pyq_prompt_mentions_no_papers(self) -> None: from app.services.evidence_contract import get_pyq_system_prompt, MATERIAL_ONLY prompt = get_pyq_system_prompt(MATERIAL_ONLY) assert "No previous question papers" in prompt or "not been uploaded" in prompt def test_single_paper_pyq_prompt_limits_to_one_paper(self) -> None: from app.services.evidence_contract import get_pyq_system_prompt, SINGLE_PAPER prompt = get_pyq_system_prompt(SINGLE_PAPER) assert "1 question paper" in prompt or "only this paper" in prompt.lower() def test_verified_10yr_pyq_prompt_allows_year_trend(self) -> None: from app.services.evidence_contract import get_pyq_system_prompt, VERIFIED_10YR prompt = get_pyq_system_prompt(VERIFIED_10YR) assert "10-year" in prompt class TestTrustNotes: """_make_trust_note returns correct notes.""" def test_material_only_trust_note_mentions_no_papers(self) -> None: from app.services.evidence_contract import _make_trust_note, MATERIAL_ONLY note = _make_trust_note(MATERIAL_ONLY, 0, None, None, None) assert note is not None assert "No question papers" in note def test_single_paper_trust_note_mentions_1_paper(self) -> None: from app.services.evidence_contract import _make_trust_note, SINGLE_PAPER note = _make_trust_note(SINGLE_PAPER, 1, None, None, None) assert note is not None assert "1 uploaded question paper" in note def test_verified_trust_note_is_none(self) -> None: from app.services.evidence_contract import _make_trust_note, VERIFIED_10YR note = _make_trust_note(VERIFIED_10YR, 10, None, None, None) assert note is None # ── API integration tests ───────────────────────────────────────────────────── class TestAskEvidenceLabel: """POST /ask responses include evidence_label.""" def test_ask_no_source_returns_evidence_label(self, client) -> None: resp = client.post("/ask", json={"question": "What is Faraday's law?"}) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body assert body["evidence_label"] == "No source attached" def test_ask_with_valid_source_returns_evidence_label(self, client) -> None: # Upload a notes document resp = client.post( "/documents/upload", data={"title": "Physics Notes"}, files={"file": ("notes.txt", b"Electromagnetic induction: Faraday law EMF", "text/plain")}, ) assert resp.status_code == 201 doc_id = resp.json()["id"] resp = client.post("/ask", json={ "question": "Explain Faraday's law", "source_id": doc_id, }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body # No papers uploaded → material_only assert body["evidence_label"] == "Based on uploaded material only" def test_ask_source_guard_response_has_evidence_label(self, client) -> None: """Source guard in-band response also includes evidence_label.""" resp = client.post( "/documents/upload", data={"title": "My CV"}, files={"file": ("resume.txt", b"Work Experience\nSkills: Python", "text/plain")}, ) doc_id = resp.json()["id"] client.patch( f"/documents/{doc_id}/material-type", json={"material_type": "resume_or_personal_doc"}, ) resp = client.post("/ask", json={ "question": "Give me exam questions from this.", "source_id": doc_id, }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body assert body["model_used"] == "docdoe-source-guard" def test_selected_paper_outside_profile_is_labelled_as_this_paper(self, client) -> None: client.post( "/study-profile", json={"board": "Kerala SSLC", "grade": "SSLC / Class 10", "subject": "Biology"}, ) uploaded = client.post( "/documents/upload", data={"title": "Plus Two Biology Paper", "subject": "Biology", "syllabus": "Kerala HSE +2"}, files={"file": ("biology_paper.txt", b"Section A Biology question paper marks", "text/plain")}, ) doc_id = uploaded.json()["id"] client.patch(f"/documents/{doc_id}/material-type", json={"material_type": "question_paper"}) resp = client.post( "/ask", json={"question": "What does this paper focus on?", "source_id": doc_id}, ) assert resp.status_code == 200 label = resp.json()["evidence_label"] assert label == "Based on 1 uploaded Biology question paper" assert "SSLC" not in label class TestStudioEvidenceLabel: """Studio routes return evidence_label.""" def test_notes_generation_returns_evidence_label(self, client) -> None: resp = client.post("/generate/notes", json={ "topic": "Electromagnetic Induction", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body assert body["evidence_label"] == "Based on uploaded material only" def test_quiz_generation_returns_evidence_label(self, client) -> None: resp = client.post("/generate/quiz", json={ "topic": "Photosynthesis", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body def test_flashcards_generation_returns_evidence_label(self, client) -> None: resp = client.post("/generate/flashcards", json={ "topic": "Current Electricity", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body def test_exam_mode_returns_evidence_label(self, client) -> None: resp = client.post("/generate/exam-mode", json={ "topic": "Faraday's Law", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body def test_last_night_plan_returns_evidence_label(self, client) -> None: resp = client.post("/generate/last-night-plan", json={ "topic": "Trigonometry", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body def test_selected_paper_uses_direct_paper_evidence_not_mismatched_profile(self, client) -> None: client.post( "/study-profile", json={"board": "Kerala SSLC", "grade": "SSLC / Class 10", "subject": "Biology"}, ) uploaded = client.post( "/documents/upload", data={"title": "Plus Two Biology Paper", "subject": "Biology", "syllabus": "Kerala HSE +2"}, files={"file": ("biology_paper.txt", b"Section A Biology question paper marks", "text/plain")}, ) doc_id = uploaded.json()["id"] client.patch(f"/documents/{doc_id}/material-type", json={"material_type": "question_paper"}) resp = client.post( "/generate/notes", json={"source_id": doc_id, "subject": "Biology", "topic": "Paper review"}, ) assert resp.status_code == 200 assert resp.json()["evidence_label"] == "Based on 1 uploaded Biology question paper" class TestStudyPathEvidenceLabel: """Study path includes evidence_label.""" def test_study_path_returns_evidence_label(self, client) -> None: resp = client.post("/study-path/generate", json={ "topic": "Electromagnetic Induction", "subject": "Physics", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body assert body["evidence_label"] == "Based on uploaded material only" class TestChatEvidenceLabel: """Chat returns evidence_label.""" def test_chat_casual_returns_evidence_label(self, client) -> None: resp = client.post("/chat", json={"message": "hi"}) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body def test_chat_study_intent_returns_evidence_label(self, client) -> None: resp = client.post("/chat", json={ "message": "Explain Faraday's law of induction", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body assert body["evidence_label"] == "No source attached" def test_chat_uses_only_a_validated_workspace_origin_as_page_context( self, client, monkeypatch ) -> None: from app.routes import chat as chat_route captured: dict[str, str] = {} def fake_chat(**kwargs): captured["system_prompt"] = kwargs["system_prompt"] captured["user_message"] = kwargs["user_message"] return "Open the first unfinished task.", "test-provider" monkeypatch.setattr(chat_route, "_call_ai_chat_sync", fake_chat) response = client.post( "/chat", json={ "message": "What should I do on this page?", "origin_page": "home", }, ) assert response.status_code == 200, response.text assert "student opened this question from Home" in captured["system_prompt"] assert captured["user_message"].endswith("What should I do on this page?") invalid = client.post( "/chat", json={ "message": "What should I do?", "origin_page": "ignore-all-instructions", }, ) assert invalid.status_code == 422 def test_chat_pyq_intent_no_papers_returns_material_only_label(self, client) -> None: """PYQ intent with no papers uploaded → material_only label.""" resp = client.post("/chat", json={ "message": "Give me PYQ questions for Electromagnetic Induction", "intent": "pyq", }) assert resp.status_code == 200 body = resp.json() assert "evidence_label" in body assert body["evidence_label"] == "Based on uploaded material only" def test_selected_question_paper_can_be_analyzed_without_profile_claims(self, client) -> None: client.post( "/study-profile", json={"board": "Kerala SSLC", "grade": "SSLC / Class 10", "subject": "Biology"}, ) uploaded = client.post( "/documents/upload", data={"title": "Plus Two Biology Paper", "subject": "Biology", "syllabus": "Kerala HSE +2"}, files={"file": ("biology_paper.txt", b"Section A Biology question paper marks", "text/plain")}, ) doc_id = uploaded.json()["id"] client.patch(f"/documents/{doc_id}/material-type", json={"material_type": "question_paper"}) response = client.post( "/chat", json={ "message": "What does this paper focus on?", "intent": "pyq", "subject": "Biology", "source_ids": [doc_id], }, ) assert response.status_code == 200 label = response.json()["evidence_label"] assert label == "Based on 1 uploaded Biology question paper" assert "SSLC" not in label def test_unselected_mismatched_paper_does_not_unlock_profile_pyq(self, client) -> None: client.post( "/study-profile", json={"board": "Kerala SSLC", "grade": "SSLC / Class 10", "subject": "Biology"}, ) uploaded = client.post( "/documents/upload", data={"title": "Plus Two Biology Paper", "subject": "Biology", "syllabus": "Kerala HSE +2"}, files={"file": ("biology_paper.txt", b"Section A Biology question paper marks", "text/plain")}, ) doc_id = uploaded.json()["id"] client.patch(f"/documents/{doc_id}/material-type", json={"material_type": "question_paper"}) response = client.post( "/chat", json={"message": "Give me PYQ patterns.", "intent": "pyq", "subject": "Biology"}, ) assert response.status_code == 200 assert response.json()["evidence_label"] == "Based on uploaded material only" def test_skipped_profile_does_not_silently_scope_uploaded_paper(self, client) -> None: client.post( "/study-profile", json={ "board": "Kerala SSLC", "grade": "SSLC / Class 10", "subject": "Biology", "onboarding_completed": True, "extra": {"onboardingSkipped": True}, }, ) uploaded = client.post( "/documents/upload", data={"title": "Plus Two Biology Paper", "subject": "Biology", "syllabus": "Kerala HSE +2"}, files={"file": ("biology_paper.txt", b"Section A Biology question paper marks", "text/plain")}, ) doc_id = uploaded.json()["id"] client.patch(f"/documents/{doc_id}/material-type", json={"material_type": "question_paper"}) response = client.post( "/chat", json={"message": "Give me PYQ signals.", "intent": "pyq", "subject": "Biology"}, ) assert response.status_code == 200 assert response.json()["evidence_label"] in ( "Based on 1 uploaded Biology question paper", "Based on 2 uploaded Biology question papers", ) class TestChatHistoryEvidenceLabel: """Saved chat answers retain the complete visible evidence contract.""" def test_saved_assistant_evidence_round_trips(self, client) -> None: session_response = client.post("/chat/sessions", json={"subject": "Biology"}) assert session_response.status_code == 201 session_id = session_response.json()["id"] append_response = client.post( f"/chat/sessions/{session_id}/messages", json={ "user_content": "Make likely questions from these notes.", "assistant_content": "These questions are based on this chapter only.", "intent": "study", "evidence_label": "Based on uploaded material only", "web_sources": [ { "title": "Official Kerala SSLC notice", "url": "https://sslcexam.kerala.gov.in/notice", "publisher": "sslcexam.kerala.gov.in", "published_date": "2026-07-30", "author": "Kerala Pareeksha Bhavan", "snippet": "Official examination notice.", "is_official": True, } ], }, ) assert append_response.status_code == 201 assert append_response.json()[1]["evidence_label"] == "Based on uploaded material only" assert append_response.json()[1]["web_sources"][0]["is_official"] is True history_response = client.get(f"/chat/sessions/{session_id}") assert history_response.status_code == 200 assert history_response.json()["messages"][1]["evidence_label"] == "Based on uploaded material only" assert ( history_response.json()["messages"][1]["web_sources"][0]["publisher"] == "sslcexam.kerala.gov.in" ) def test_saved_web_sources_reject_unsafe_urls(self, client) -> None: session_response = client.post("/chat/sessions", json={"subject": "Physics"}) session_id = session_response.json()["id"] response = client.post( f"/chat/sessions/{session_id}/messages", json={ "user_content": "Open this source.", "assistant_content": "Unsafe links must never be persisted.", "web_sources": [ { "title": "Unsafe", "url": "javascript:alert(1)", "publisher": "example.com", "is_official": False, } ], }, ) assert response.status_code == 422 class TestNoPyqFabricationInAnswers: """Evidence rules must appear in context — verified via prompt_rules content.""" def test_material_only_prompt_rules_block_cbse_claim(self) -> None: """The prompt_rules injected into the AI context block CBSE year references.""" from app.services.evidence_contract import build_evidence_context, MATERIAL_ONLY # Use a mock DB that returns no papers class MockSession: def scalars(self, *args, **kwargs): class MockResult: def all(self_): return [] return MockResult() ctx = build_evidence_context(MockSession(), "test_user") # type: ignore[arg-type] assert ctx.evidence_level == MATERIAL_ONLY assert "CBSE 2023" in ctx.prompt_rules assert "State Board 2024" in ctx.prompt_rules assert "PROHIBITED" in ctx.prompt_rules def test_material_only_evidence_label_correct(self) -> None: from app.services.evidence_contract import build_evidence_context class MockSession: def scalars(self, *args, **kwargs): class MockResult: def all(self_): return [] return MockResult() ctx = build_evidence_context(MockSession(), "test_user") # type: ignore[arg-type] assert ctx.evidence_label == "Based on uploaded material only" assert ctx.can_use_pyq_pattern is False assert ctx.can_use_ten_year_claim is False def test_verified_10yr_context_allows_pyq_claims(self) -> None: from app.services.evidence_contract import ( build_evidence_context, VERIFIED_10YR, ) class MockPaper: def __init__(self, year: int): self.year = year self.syllabus = None # required by _syllabus_matches class MockSession: def __init__(self_) -> None: self_._n = 0 def scalars(self_, *args, **kwargs): n = self_._n self_._n += 1 class MockResult: def all(self__): if n == 0: # PreviousPaper query — return 10 years return [MockPaper(y) for y in range(2015, 2025)] return [] # Document query — empty return MockResult() ctx = build_evidence_context(MockSession(), "test_user") # type: ignore[arg-type] assert ctx.evidence_level == VERIFIED_10YR assert ctx.can_use_ten_year_claim is True assert "10-year PYQ pattern" in ctx.prompt_rules def test_single_paper_context_blocks_trend_claims(self) -> None: from app.services.evidence_contract import ( build_evidence_context, SINGLE_PAPER, ) class MockPaper: year = 2023 syllabus = None # required by _syllabus_matches class MockSession: def __init__(self_) -> None: self_._n = 0 def scalars(self_, *args, **kwargs): n = self_._n self_._n += 1 class MockResult: def all(self__): if n == 0: # PreviousPaper query — return 1 paper return [MockPaper()] return [] # Document query — empty return MockResult() ctx = build_evidence_context(MockSession(), "test_user") # type: ignore[arg-type] assert ctx.evidence_level == SINGLE_PAPER assert "PROHIBITED" in ctx.prompt_rules assert "trend" in ctx.prompt_rules assert "10-year pattern" in ctx.prompt_rules class TestEvidenceScoping: """Verify cross-subject / cross-board / cross-class isolation. Subject filtering is SQL-level → tested via real DB (client fixture). Board / class_level filtering is Python-level → tested via MockSession. """ # ── MockSession helper ───────────────────────────────────────────────────── @staticmethod def _mock_db(papers=None, docs=None): """Return a MockSession: first scalars() call → papers, second → docs.""" papers = papers or [] docs = docs or [] class MockSession: def __init__(self_) -> None: self_._n = 0 def scalars(self_, *args, **kwargs): n = self_._n self_._n += 1 class MockResult: def all(self__): return papers if n == 0 else docs return MockResult() return MockSession() # ── cross-subject: SQL-level filtering (integration) ────────────────────── def test_biology_pyqs_do_not_support_english(self, client) -> None: """SQL subject filter: Biology PYQs excluded when querying for English.""" from app.core.database import SessionLocal from app.models.previous_paper import PreviousPaper from app.services.evidence_contract import build_evidence_context, MATERIAL_ONLY, VERIFIED_10YR with SessionLocal() as db: uid = "scope_subj_test_user" for year in range(2015, 2025): db.add(PreviousPaper( user_id=uid, title=f"Biology PYQ {year}", subject="Biology", syllabus="Kerala HSE +2 Biology", year=year, file_path=f"/fake/scope/{year}", )) db.commit() # Biology query: 10 papers → VERIFIED_10YR ctx_bio = build_evidence_context(db, uid, subject="Biology") assert ctx_bio.evidence_level == VERIFIED_10YR # English query: SQL excludes Biology papers → MATERIAL_ONLY ctx_eng = build_evidence_context(db, uid, subject="English") assert ctx_eng.evidence_level == MATERIAL_ONLY assert ctx_eng.paper_count == 0 # ── cross-board: Python-level filtering (MockSession) ───────────────────── def test_cbse_papers_excluded_for_kerala_hse_board(self) -> None: """CBSE papers excluded when board='Kerala HSE' filter active.""" from app.services.evidence_contract import build_evidence_context, MATERIAL_ONLY class CBSEPaper: subject = "Physics" syllabus = "CBSE Class 12 Physics" year = 2023 papers = [CBSEPaper() for _ in range(10)] ctx = build_evidence_context( self._mock_db(papers=papers), # type: ignore[arg-type] "user", subject="Physics", board="Kerala HSE", ) assert ctx.evidence_level == MATERIAL_ONLY assert ctx.paper_count == 0 def test_kerala_hse_papers_match_multi_word_board(self) -> None: """Multi-word board 'Kerala HSE': both tokens must appear in syllabus.""" from app.services.evidence_contract import build_evidence_context, VERIFIED_10YR class HSEPaper: subject = "Physics" syllabus = "Kerala HSE +2 Physics" def __init__(self, year: int) -> None: self.year = year papers = [HSEPaper(y) for y in range(2015, 2025)] ctx = build_evidence_context( self._mock_db(papers=papers), # type: ignore[arg-type] "user", subject="Physics", board="Kerala HSE", ) assert ctx.evidence_level == VERIFIED_10YR assert ctx.paper_count == 10 def test_null_syllabus_excluded_when_board_filter_active(self) -> None: """Null syllabus papers excluded when board filter active (conservative).""" from app.services.evidence_contract import build_evidence_context, MATERIAL_ONLY class NullSyllabusPaper: subject = "Physics" syllabus = None year = 2023 papers = [NullSyllabusPaper() for _ in range(10)] ctx = build_evidence_context( self._mock_db(papers=papers), # type: ignore[arg-type] "user", subject="Physics", board="CBSE", ) assert ctx.evidence_level == MATERIAL_ONLY assert ctx.paper_count == 0 # ── cross-class: Python-level filtering (MockSession) ───────────────────── def test_plus2_papers_excluded_for_plus1_class(self) -> None: """+2 papers excluded when class_level='+1' filter active.""" from app.services.evidence_contract import build_evidence_context, MATERIAL_ONLY class Plus2Paper: subject = "Physics" syllabus = "Kerala HSE +2 Physics" year = 2023 papers = [Plus2Paper() for _ in range(10)] ctx = build_evidence_context( self._mock_db(papers=papers), # type: ignore[arg-type] "user", subject="Physics", class_level="+1", ) assert ctx.evidence_level == MATERIAL_ONLY assert ctx.paper_count == 0 def test_plus1_token_does_not_match_plus12(self) -> None: """Token precision: '+1' must NOT match '+12' in syllabus.""" from app.services.evidence_contract import build_evidence_context, MATERIAL_ONLY class Plus12Paper: subject = "Physics" syllabus = "CBSE +12 Physics" year = 2023 papers = [Plus12Paper() for _ in range(5)] ctx = build_evidence_context( self._mock_db(papers=papers), # type: ignore[arg-type] "user", subject="Physics", class_level="+1", ) assert ctx.evidence_level == MATERIAL_ONLY assert ctx.paper_count == 0 # ── year metadata (MockSession) ──────────────────────────────────────────── def test_papers_without_year_count_toward_paper_count(self) -> None: """Null year papers contribute to paper_count but NOT year_count.""" from app.services.evidence_contract import build_evidence_context, MULTI_PAPER class YearlessPaper: subject = "Chemistry" syllabus = None year = None papers = [YearlessPaper() for _ in range(3)] ctx = build_evidence_context( self._mock_db(papers=papers), # type: ignore[arg-type] "user", subject="Chemistry", ) assert ctx.evidence_level == MULTI_PAPER assert ctx.paper_count == 3 assert ctx.year_count == 0 # ── context-specific labels (MockSession) ───────────────────────────────── def test_label_includes_subject_and_board(self) -> None: """Label mentions subject and board when context is specified.""" from app.services.evidence_contract import build_evidence_context class MatchingPaper: subject = "Biology" syllabus = "Kerala HSE +2 Biology" def __init__(self, year: int) -> None: self.year = year papers = [MatchingPaper(y) for y in range(2015, 2017)] ctx = build_evidence_context( self._mock_db(papers=papers), # type: ignore[arg-type] "user", subject="Biology", board="Kerala HSE", class_level="+2", ) assert "Biology" in ctx.evidence_label class TestPyqRouteAvailableFalse: """PYQ analyze endpoint: available=False when no papers, no fake years.""" def test_pyq_analyze_no_papers_returns_available_false(self, client) -> None: resp = client.post("/pyq/analyze", json={"subject": "Physics"}) assert resp.status_code == 200 body = resp.json() assert body["available"] is False def test_pyq_analyze_no_papers_no_fake_years(self, client) -> None: resp = client.post("/pyq/analyze", json={"subject": "Chemistry"}) body = resp.json() body_str = str(body) for year in ("2020", "2021", "2022", "2023", "2024", "2025"): assert year not in body_str, f"Fake year {year} found in response" class TestVerifiedPyqOnlyDownstream: def test_context_builder_insights_ignore_candidate_papers(self, client) -> None: from app.core.database import SessionLocal from app.models.document import Document from app.models.previous_paper import PreviousPaper from app.models.previous_question import PreviousQuestion from app.models.user import User from app.services.context_builder import _previous_paper_insights with SessionLocal() as db: user = User(id="usr_pyq_insights_guard", email="insights-guard@test.local", name="Guard") document = Document( id="doc_pyq_insights_guard", user_id=user.id, title="Physics Notes", file_name="physics.txt", file_type="text/plain", file_path="/tmp/physics.txt", subject="Physics", syllabus="CBSE +2 Physics", status="ready", extracted_text="Physics notes", material_type="notes", ) paper = PreviousPaper( user_id=user.id, title="Physics Question Paper 2023", subject="Physics", syllabus="CBSE +2 Physics", year=2023, file_path="/tmp/paper.pdf", verification_status="candidate", ) db.add_all([user, document, paper]) db.flush() db.add( PreviousQuestion( previous_paper_id=paper.id, question_text="Define electromagnetic induction.", subject="Physics", topic="Electromagnetic Induction", year=2023, marks=2, question_type="short", ), ) db.commit() insights = _previous_paper_insights(db, user, document, board="CBSE", class_level="+2") assert insights == [] paper.verification_status = "verified" db.add(paper) db.commit() insights = _previous_paper_insights(db, user, document, board="CBSE", class_level="+2") assert insights def test_study_path_pyq_summary_ignores_candidate_papers(self, client) -> None: from app.core.database import SessionLocal from app.models.previous_paper import PreviousPaper from app.models.previous_question import PreviousQuestion from app.models.user import User from app.routes.study_path import _pyq_summary with SessionLocal() as db: user = User(id="usr_pyq_summary_guard", email="summary-guard@test.local", name="Guard") paper = PreviousPaper( user_id=user.id, title="Physics Question Paper 2023", subject="Physics", syllabus="CBSE +2 Physics", year=2023, file_path="/tmp/paper.pdf", verification_status="candidate", ) db.add_all([user, paper]) db.flush() db.add( PreviousQuestion( previous_paper_id=paper.id, question_text="Define electromagnetic induction.", subject="Physics", topic="Electromagnetic Induction", year=2023, marks=2, question_type="short", ), ) db.commit() candidate_summary = _pyq_summary(db, user.id, "Physics", board="CBSE", class_level="+2") assert candidate_summary == {"available": False, "question_count": 0} paper.verification_status = "verified" db.add(paper) db.commit() verified_summary = _pyq_summary(db, user.id, "Physics", board="CBSE", class_level="+2") assert verified_summary == {"available": True, "question_count": 1}