DocDoeAI / tests /test_evidence_contract.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
3bcdb36
Raw
History Blame Contribute Delete
40.1 kB
"""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}