| """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 |
|
|
|
|
|
|
| |
|
|
| 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() |
| |
| 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 |
|
|
|
|
| |
|
|
| 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: |
| |
| 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 |
| |
| 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 |
|
|
| |
| class MockSession: |
| def scalars(self, *args, **kwargs): |
| class MockResult: |
| def all(self_): |
| return [] |
| return MockResult() |
|
|
| ctx = build_evidence_context(MockSession(), "test_user") |
| 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") |
| 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 |
|
|
| 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: |
| return [MockPaper(y) for y in range(2015, 2025)] |
| return [] |
|
|
| return MockResult() |
|
|
| ctx = build_evidence_context(MockSession(), "test_user") |
| 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 |
|
|
| 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: |
| return [MockPaper()] |
| return [] |
|
|
| return MockResult() |
|
|
| ctx = build_evidence_context(MockSession(), "test_user") |
| 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. |
| """ |
|
|
| |
|
|
| @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() |
|
|
| |
|
|
| 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() |
|
|
| |
| ctx_bio = build_evidence_context(db, uid, subject="Biology") |
| assert ctx_bio.evidence_level == VERIFIED_10YR |
|
|
| |
| ctx_eng = build_evidence_context(db, uid, subject="English") |
| assert ctx_eng.evidence_level == MATERIAL_ONLY |
| assert ctx_eng.paper_count == 0 |
|
|
| |
|
|
| 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), |
| "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), |
| "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), |
| "user", |
| subject="Physics", |
| board="CBSE", |
| ) |
| assert ctx.evidence_level == MATERIAL_ONLY |
| assert ctx.paper_count == 0 |
|
|
| |
|
|
| 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), |
| "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), |
| "user", |
| subject="Physics", |
| class_level="+1", |
| ) |
| assert ctx.evidence_level == MATERIAL_ONLY |
| assert ctx.paper_count == 0 |
|
|
| |
|
|
| 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), |
| "user", |
| subject="Chemistry", |
| ) |
| assert ctx.evidence_level == MULTI_PAPER |
| assert ctx.paper_count == 3 |
| assert ctx.year_count == 0 |
|
|
| |
|
|
| 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), |
| "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} |
|
|