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
246 lines
12 KiB
Python
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()
|
|
|