"""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()