pdf-quiz-generator/backend/tests/test_article_search_index.py
Daniel 17f238bded feat: three answers, chosen by a number rather than by the model
Retrieval could not say "nothing". `hybrid_ids` fuses two rankers by reciprocal
rank and throws the distances away, and it returns the union — so the shortlist
was never empty, the "nothing matches" branch never fired, and a question about
photosynthesis came back with six paediatric sources and an instruction to
answer only from them.

So the fix is not more scenarios in the prompt. It is one calibrated number,
and three short prompts chosen by it in code. Asking a model to work out which
situation it is in is the part that does not work, and it is also the part that
makes prompts long.

Measured against this corpus with the bodies now embedded — eight clearly
on-topic questions and eight clearly off-topic:

  off-topic  0.339 – 0.499   the French revolution … photosynthesis
  on-topic   0.586 – 0.740   what causes croup … posterior urethral valves

The thresholds sit in the gap. They are deliberately not the retrieval floor:
that one decides what is worth putting in a list, where a weak hit costs a
reader a glance. These decide whether an answer claims to come from the
library, and a wrong claim costs them their trust in every other answer.

Above 0.55 the answer is sourced and cited, as before. Between 0.50 and 0.55 it
says nothing covers this directly, names what the closest material is, and
marks which parts came from where. Below, it says so in one line and then helps
anyway from general knowledge, citing nothing — refusing outright reads as a
broken assistant rather than a careful one, and the shortlist is not handed to
a model that has just been told the library does not cover the question.

An unmeasurable closeness is not a low one. No vector database or a downed
encoder returns None, and retrieval still found its rows by other means, so
those are still cited; dropping every citation because the ruler is missing
would be the worse failure.

Also: only published articles are indexed now. A draft is unfinished by
definition and has no business in a search result or in that shortlist. The
index follows publication both ways, and the fifteen-minute sweeper drops rows
whose article has been deleted or unpublished — an article that is never edited
again would otherwise keep its rows for good.

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

246 lines
12 KiB
Python

"""What an article's vector is made of, and whether the section index keeps up.
Both halves of article search read from a projection rather than from the
article row: `articles.content` is NULL for all but the eight hand-seeded
samples, so anything that only looked at title, summary and `content` was
describing an empty body. These fix that in place, on disposable SQLite, with
no network — the embedder returns nothing without credentials, which is exactly
the "leave it for the retry task" path the routes are meant to survive.
"""
import unittest
import unittest.mock
from unittest.mock import patch
import test_quiz_builder as fixtures
from app.models.article import Article, ArticleSectionIndex
from app.routers import articles
from app.services.embedding_service import article_embedding_text
def _article(section_count=12, body_chars=1500):
sections = [{
"id": f"{index:032d}", "slug": f"section-{index}", "variant": "long",
"title": f"Heading {index}",
"content": f"OPENING{index} " + ("filler " * (body_chars // 7)) + f" CLOSING{index}",
} for index in range(section_count)]
return Article(title="Laryngomalacia", summary="Inspiratory stridor in an infant",
content=None, sections=sections)
class ArticleEmbeddingTextTests(unittest.TestCase):
def test_body_reaches_the_vector_at_all(self):
article = _article()
text = article_embedding_text(article)
self.assertIn("Laryngomalacia", text)
self.assertIn("Inspiratory stridor", text)
# The whole point: something from the body, which used to contribute
# nothing because it is in `sections` and not in `content`.
self.assertIn("OPENING0", text)
def test_every_section_is_represented_not_just_the_head(self):
"""Head truncation would stop in the pathophysiology and drop treatment."""
article = _article()
text = article_embedding_text(article)
for index in range(12):
self.assertIn(f"Heading {index}", text, "section outline is missing an entry")
self.assertIn(f"OPENING{index}", text, "a later section contributed nothing")
def test_stays_inside_the_embedder_clamp(self):
# `generate_embedding` truncates at 4000 characters without saying so,
# which is how an index comes to be silently wrong.
self.assertLessEqual(len(article_embedding_text(_article(14, 4000))), 4000)
def test_short_article_is_carried_whole(self):
article = _article(section_count=2, body_chars=100)
text = article_embedding_text(article)
for index in range(2):
self.assertIn(f"CLOSING{index}", text)
def test_missing_and_malformed_sections_do_not_raise(self):
empty = Article(title="Stub", summary=None, content=None, sections=None)
self.assertEqual(article_embedding_text(empty), "Stub")
odd = Article(title="Stub", summary=None, content=None,
sections=["not a dict", {"title": "Only a heading"}])
self.assertIn("Only a heading", article_embedding_text(odd))
class BatchEmbeddingTests(unittest.TestCase):
"""A batch attaching one document's vector to another is silent and permanent."""
def _proxy(self, payload):
from app.config import settings
from app.services import embedding_service
response = unittest.mock.Mock()
response.raise_for_status.return_value = None
response.json.return_value = payload
return patch.multiple(settings, LITELLM_API_KEY="k", LITELLM_API_BASE="https://proxy.test"), \
patch.object(embedding_service, "_get_embedding_model", return_value="m"), \
patch("httpx.post", return_value=response)
def _vectors(self, count, dim):
return {"data": [{"index": index, "embedding": [float(index)] * dim}
for index in range(count)]}
def test_vectors_land_on_the_row_they_were_made_from(self):
from app.config import settings
from app.services import embedding_service
dim = settings.EMBEDDING_DIMENSIONS
# Returned out of order, as a proxy is entitled to do.
payload = {"data": list(reversed(self._vectors(3, dim)["data"]))}
for context in self._proxy(payload):
context.start()
self.addCleanup(context.stop)
out = embedding_service.generate_embeddings(["one", "two", "three"])
self.assertEqual([vector[0] for vector in out], [0.0, 1.0, 2.0])
def test_blank_inputs_keep_their_place_in_the_result(self):
from app.config import settings
from app.services import embedding_service
dim = settings.EMBEDDING_DIMENSIONS
for context in self._proxy(self._vectors(2, dim)):
context.start()
self.addCleanup(context.stop)
out = embedding_service.generate_embeddings(["one", "", " ", "two"])
self.assertIsNone(out[1])
self.assertIsNone(out[2])
self.assertEqual([out[0][0], out[3][0]], [0.0, 1.0])
class RollupFusionTests(unittest.TestCase):
"""A section that matched at rank one is the strongest evidence there is."""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.db = self.bank.db
self.db.add_all([
Article(id=91, slug='a', title='Weak whole-article match', sections=[], status='published'),
Article(id=92, slug='b', title='Another weak one', sections=[], status='published'),
Article(id=93, slug='c', title='Body match only', sections=[], status='published'),
])
self.db.add(ArticleSectionIndex(id=1, article_id=93, section_id='c' * 32,
title='Treatment', content='Griseofulvin'))
self.db.flush()
def tearDown(self):
self.bank.tearDown()
def test_a_section_only_hit_is_not_parked_behind_every_article_hit(self):
from app.services import search_service
def fake(db, query, kind='question', limit=200):
return ([91, 92], set()) if kind == 'article' else ([1], set())
with patch.object(search_service, 'hybrid_ids', side_effect=fake):
ranked, by_article = search_service.article_ids_with_sections(self.db, 'griseofulvin')
# Concatenation put 93 last however well its section scored.
self.assertLess(ranked.index(93), ranked.index(92))
self.assertEqual([row.title for row in by_article[93]], ['Treatment'])
def test_one_article_cannot_win_on_number_of_matching_sections(self):
from app.services import search_service
self.db.add_all([
ArticleSectionIndex(id=2, article_id=91, section_id='d' * 32, title='B', content='x'),
ArticleSectionIndex(id=3, article_id=91, section_id='e' * 32, title='C', content='x'),
ArticleSectionIndex(id=4, article_id=91, section_id='f' * 32, title='D', content='x'),
])
self.db.flush()
def fake(db, query, kind='question', limit=200):
# 93's single section leads; 91 has three, all behind it.
return ([], set()) if kind == 'article' else ([1, 2, 3, 4], set())
with patch.object(search_service, 'hybrid_ids', side_effect=fake):
ranked, _ = search_service.article_ids_with_sections(self.db, 'griseofulvin')
self.assertEqual(ranked[0], 93, "a longer article outranked a better one on volume")
class SectionIndexInStepTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(articles.router, prefix='/articles')
self.db = self.bank.db
self.bank.user = self.bank.mod
def tearDown(self):
self.bank.tearDown()
def rows(self, article_id):
return {row.section_id: (row.title, row.content) for row in
self.db.query(ArticleSectionIndex).filter_by(article_id=article_id).all()}
def payload(self, sections, **overrides):
return {"title": "Laryngomalacia", "slug": "laryngomalacia",
"summary": "Inspiratory stridor", "sections": sections, **overrides}
def publish(self, article_id, published=True):
"""Only a published article is indexed, and creating one does not publish it."""
return self.client.post(f"/articles/{article_id}/publish", json={"published": published})
def test_index_follows_create_edit_and_delete_of_a_section(self):
first = {"id": "a" * 32, "slug": "definition", "title": "Definition", "content": "Dynamic collapse"}
second = {"id": "b" * 32, "slug": "treatment", "title": "Treatment", "content": "Supraglottoplasty"}
article = self.client.post('/articles/', json=self.payload([first, second])).json()
# A draft is not indexed, so nothing exists until it is published.
self.assertEqual(self.rows(article['id']), {})
self.publish(article['id'])
self.assertEqual(set(self.rows(article['id'])), {"a" * 32, "b" * 32})
self.assertEqual(self.rows(article['id'])["b" * 32][1], "Supraglottoplasty")
# Edit one, drop the other, add a third.
edited = {**second, "content": "Supraglottoplasty for severe cases"}
third = {"id": "c" * 32, "slug": "prognosis", "title": "Prognosis", "content": "Resolves by two years"}
self.client.patch(f"/articles/{article['id']}", json=self.payload([edited, third]))
rows = self.rows(article['id'])
self.assertEqual(set(rows), {"b" * 32, "c" * 32}, "a removed section left its row behind")
self.assertEqual(rows["b" * 32][1], "Supraglottoplasty for severe cases")
def test_a_term_only_in_a_section_body_finds_its_article(self):
"""The article row says nothing about it; the section row is the only hit."""
sections = [{"id": "a" * 32, "slug": "treatment", "title": "Treatment",
"content": "Oral griseofulvin for six to eight weeks"}]
article = self.client.post('/articles/', json=self.payload(
sections, title="Tinea capitis", slug="tinea-capitis",
summary="Scalp ringworm")).json()
self.publish(article['id'])
found = self.client.get('/articles/', params={'q': 'griseofulvin'}).json()
self.assertEqual([a['id'] for a in found], [article['id']])
def test_an_orphaned_index_row_cannot_resurrect_a_deleted_article(self):
sections = [{"id": "a" * 32, "slug": "treatment", "title": "Treatment", "content": "Griseofulvin"}]
article = self.client.post('/articles/', json=self.payload(sections)).json()
self.publish(article['id'])
self.client.patch(f"/articles/{article['id']}", json=self.payload([]))
self.assertEqual(self.rows(article['id']), {})
self.assertEqual(self.client.get('/articles/', params={'q': 'griseofulvin'}).json(), [])
def test_a_draft_is_not_searchable_and_unpublishing_takes_it_back_out(self):
sections = [{"id": "a" * 32, "slug": "treatment", "title": "Treatment",
"content": "Oral griseofulvin for six to eight weeks"}]
article = self.client.post('/articles/', json=self.payload(
sections, title="Tinea capitis", slug="tinea-capitis")).json()
# Unfinished prose has no business in a search result, or in the
# shortlist the assistant answers from.
self.assertEqual(self.rows(article['id']), {})
self.publish(article['id'])
self.assertEqual(len(self.rows(article['id'])), 1)
self.publish(article['id'], published=False)
self.assertEqual(self.rows(article['id']), {},
"unpublishing left the body searchable")
if __name__ == '__main__':
unittest.main()