pdf-quiz-generator/backend/tests/test_hybrid_search.py
Daniel 3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:27:51 +02:00

157 lines
7.3 KiB
Python

"""Hybrid retrieval: lexical and semantic rankers fused, never intersected.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
"""
import os
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models.media import MediaAsset # noqa — the health report walks every embeddable table.
from app.models.question import Question
from app.models.user import User
from app.services import search_service
class HybridSearchTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.db.add(User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator"))
for qid, text in [
(1, "A child with fever and a seizure"),
(2, "An infant with jaundice on day three"),
(3, "A toddler with a febrile convulsion"),
]:
self.db.add(Question(id=qid, user_id=1, question_text=text,
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_result_is_the_union_of_both_rankers(self):
# "convulsion" never appears in question 1, and "fever" never in 3, so an
# intersection would return one of them; the union must return both.
with patch.object(search_service, "_semantic_ranked", return_value=[3]):
ids, semantic = search_service.hybrid_question_ids(self.db, "fever")
self.assertEqual(set(ids), {1, 3})
self.assertEqual(semantic, {3})
def test_agreement_between_rankers_outranks_a_single_hit(self):
with patch.object(search_service, "_semantic_ranked", return_value=[3, 1]):
ids, _ = search_service.hybrid_question_ids(self.db, "fever")
# Question 1 is first lexically and second semantically; 3 only appears once
# in the lexical list, so fusion should put 1 ahead of the semantic-only hit.
self.assertEqual(ids[0], 1)
def test_a_failing_ranker_degrades_instead_of_erroring(self):
with patch.object(search_service, "_semantic_ranked", side_effect=RuntimeError("no pgvector")):
ids, semantic = search_service.hybrid_question_ids(self.db, "fever")
self.assertEqual(ids, [1])
self.assertEqual(semantic, set())
with patch.object(search_service, "_lexical_ranked", side_effect=RuntimeError("no index")), \
patch.object(search_service, "_semantic_ranked", return_value=[2]):
ids, _ = search_service.hybrid_question_ids(self.db, "fever")
self.assertEqual(ids, [2])
def test_blank_query_returns_nothing(self):
self.assertEqual(search_service.hybrid_question_ids(self.db, " "), ([], set()))
class EmbeddingProvenanceTests(unittest.TestCase):
"""A model change must be visible, not a silent quality regression."""
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.db.add(User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator"))
for qid in (1, 2, 3):
self.db.add(Question(id=qid, user_id=1, question_text=f"Question {qid}",
question_type="mcq", options=["yes", "no"], correct_answer="yes"))
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_embedding_records_the_model_that_produced_it(self):
from app.services import embedding_service
question = self.db.get(Question, 1)
with patch.object(embedding_service, "_get_embedding_model", return_value="model-a"), \
patch.object(embedding_service, "generate_embedding", return_value=[0.1] * 1024):
self.assertTrue(embedding_service.embed_question(question))
self.assertEqual(question.embedding_model, "model-a")
self.assertIsNotNone(question.embedded_at)
def test_a_failed_embedding_leaves_no_stale_provenance(self):
from app.services import embedding_service
question = self.db.get(Question, 1)
with patch.object(embedding_service, "_get_embedding_model", return_value="model-a"), \
patch.object(embedding_service, "generate_embedding", return_value=None):
self.assertFalse(embedding_service.embed_question(question))
self.assertIsNone(question.embedding_model)
def test_health_separates_missing_from_stale(self):
from app.services import embedding_service
# One current, one from a retired model, one never embedded.
self.db.query(Question).filter(Question.id == 1).update(
{"embedding": [0.1] * 1024, "embedding_model": "model-a"})
self.db.query(Question).filter(Question.id == 2).update(
{"embedding": [0.2] * 1024, "embedding_model": "model-old"})
self.db.commit()
with patch.object(embedding_service, "_get_embedding_model", return_value="model-a"):
health = embedding_service.stale_embedding_counts(self.db)
self.assertEqual(health["active_model"], "model-a")
self.assertEqual((health["current"], health["stale"], health["missing"]), (1, 1, 1))
self.assertTrue(health["needs_regeneration"])
# Switching the model makes every stored vector stale, and says so.
with patch.object(embedding_service, "_get_embedding_model", return_value="model-b"):
after = embedding_service.stale_embedding_counts(self.db)
self.assertEqual((after["current"], after["stale"]), (0, 2))
class ProvenanceMatchesGeneratorTests(unittest.TestCase):
"""The stamp must name the model that actually produced the vector."""
def test_generator_and_stamp_resolve_the_same_model(self):
from app.config import settings
from app.services import embedding_service
calls = {}
def fake_post(url, **kwargs):
calls["model"] = kwargs["content"] if "content" in kwargs else kwargs.get("json")
raise RuntimeError("stop after capturing the request")
with patch.object(settings, "LITELLM_EMBEDDING_MODEL", "from-env"), \
patch.object(settings, "LITELLM_API_KEY", "k"), \
patch.object(settings, "LITELLM_API_BASE", "https://proxy.test"), \
patch.object(embedding_service, "_get_embedding_model", return_value="from-resolver") as resolver, \
patch("httpx.post", side_effect=fake_post):
embedding_service.generate_embedding("some question text")
# The request must carry the resolver's model, not the raw env value:
# otherwise embed_question would stamp a different name than it embedded with.
self.assertIn("from-resolver", str(calls.get("model")))
self.assertNotIn("from-env", str(calls.get("model")))
self.assertTrue(resolver.called)
if __name__ == "__main__":
unittest.main()