"""The shared similarity mechanic: one permission-filtered vector search, two calibrated thresholds. Uses deterministic fake embeddings (fake_embed): identical text → distance 0, unrelated text → near-orthogonal. That is enough to prove the SQL plumbing, the permission boundary and the threshold behaviour; whether the thresholds are set at useful VALUES is measured against the real model in tests/evals/test_duplicate_eval.py. """ import uuid import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.passwords import hash_password from app.metrics import metrics from app.models import ( Chunk, Department, DocPermission, Document, DocumentStatus, DocumentVisibility, User, UserRole, ) from app.rag.indexing import embedding_text, reindex_document from app.rag.similarity import ( CAPTURE_CONTEXT_MAX_DISTANCE, DUPLICATE_MAX_DISTANCE, similar_chunks, similar_documents, ) pytestmark = pytest.mark.usefixtures("fake_embed") class Setup: pablo: User # Engineering max: User # Sales pub: Document dept_sales: Document restricted_sales: Document granted_eng: Document draft: Document async def _user(db: AsyncSession, email: str, department_id) -> User: user = User( email=email, name=email.split("@")[0], role=UserRole.member, password_hash=hash_password("secret123"), department_id=department_id, ) db.add(user) await db.flush() return user async def _doc( db: AsyncSession, *, title: str, content: str, author: User, department_id=None, visibility: DocumentVisibility, status: DocumentStatus = DocumentStatus.published, ) -> Document: document = Document( title=title, status=status, visibility=visibility, content_md=content, author_id=author.id, department_id=department_id, ) db.add(document) await db.flush() return document @pytest.fixture async def setup(db: AsyncSession) -> Setup: s = Setup() engineering = Department(name="Engineering") sales = Department(name="Sales") db.add_all([engineering, sales]) await db.flush() s.pablo = await _user(db, "pablo@test.dev", engineering.id) s.max = await _user(db, "max@test.dev", sales.id) s.pub = await _doc( db, title="Wartung der Kaffeemaschine", content="## Pflege\n\nDie Kaffeemaschine wird jeden Freitag entkalkt.", author=s.pablo, department_id=engineering.id, visibility=DocumentVisibility.public, ) s.dept_sales = await _doc( db, title="Angebotsfristen", content="## Fristen\n\nAngebote gelten dreißig Tage.", author=s.max, department_id=sales.id, visibility=DocumentVisibility.department, ) s.restricted_sales = await _doc( db, title="Geheime Preisliste", content="## Preise\n\nDer Rabattdeckel liegt bei zwölf Prozent.", author=s.max, department_id=sales.id, visibility=DocumentVisibility.restricted, ) s.granted_eng = await _doc( db, title="Ersatzteillager", content="## Zugang\n\nDie Zugangskarte liegt im Tresorfach drei.", author=s.max, department_id=sales.id, visibility=DocumentVisibility.restricted, ) db.add(DocPermission(document_id=s.granted_eng.id, department_id=engineering.id)) s.draft = await _doc( db, title="Pausenregelung", content="## Entwurf\n\nNeue Pausenregelung ab Oktober.", author=s.pablo, department_id=engineering.id, visibility=DocumentVisibility.public, status=DocumentStatus.draft, ) for document in ( s.pub, s.dept_sales, s.restricted_sales, s.granted_eng, s.draft, ): await reindex_document(db, document) await db.commit() return s async def _chunk_text(db: AsyncSession, document: Document) -> str: """A chunk exactly as it was embedded — heading path and all, so a search for it lands at distance 0 (see indexing.embedding_text).""" row = ( await db.execute( select(Chunk.content, Chunk.meta).where(Chunk.document_id == document.id) ) ).first() return embedding_text(row.meta["heading_path"], row.content) def _doc_ids(results) -> set[uuid.UUID]: return {result.document_id for result in results} async def test_finds_the_matching_chunk_with_a_usable_distance( db: AsyncSession, setup: Setup ) -> None: text = await _chunk_text(db, setup.pub) results = await similar_chunks( db, text, user=setup.pablo, max_distance=DUPLICATE_MAX_DISTANCE ) assert results top = results[0] assert top.document_id == setup.pub.id assert top.title == "Wartung der Kaffeemaschine" # Unlike the hybrid path, a distance is always present — that is the # whole reason this search exists. assert top.distance < 0.001 async def test_unrelated_text_is_filtered_by_the_threshold( db: AsyncSession, setup: Setup ) -> None: loose = await similar_chunks( db, "Völlig anderes Thema ohne Bezug zu irgendetwas", user=setup.pablo, max_distance=CAPTURE_CONTEXT_MAX_DISTANCE, ) assert loose == [] async def test_the_tight_threshold_rejects_what_the_loose_one_accepts( db: AsyncSession, setup: Setup ) -> None: """One mechanic, two thresholds: the same call with a smaller limit is strictly more selective.""" text = await _chunk_text(db, setup.pub) near_miss = text + " Zusätzlich wird der Wasserfilter getauscht." loose = await similar_chunks(db, near_miss, user=setup.pablo, max_distance=1.0) assert loose, "the near miss should be retrievable at all" distance = loose[0].distance accepted = await similar_chunks( db, near_miss, user=setup.pablo, max_distance=distance ) rejected = await similar_chunks( db, near_miss, user=setup.pablo, max_distance=distance / 2 ) assert accepted and not rejected async def test_restricted_document_never_surfaces( db: AsyncSession, setup: Setup ) -> None: """The permission filter is the same CTE search() uses: Pablo has no grant for the Sales price list, so no threshold can reveal it.""" text = await _chunk_text(db, setup.restricted_sales) results = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) assert setup.restricted_sales.id not in _doc_ids(results) # Max authored it, so he still finds it — the filter is about the user, # not about the document being hidden from everyone. mine = await similar_chunks(db, text, user=setup.max, max_distance=1.0) assert setup.restricted_sales.id in _doc_ids(mine) async def test_excluding_a_document_drops_its_own_chunks( db: AsyncSession, setup: Setup ) -> None: """A document must never ground a suggestion on itself: excluding its id removes its own chunks even when the query is its exact text.""" text = await _chunk_text(db, setup.pub) included = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) assert setup.pub.id in _doc_ids(included) excluded = await similar_chunks( db, text, user=setup.pablo, max_distance=1.0, exclude_document_id=setup.pub.id, ) assert setup.pub.id not in _doc_ids(excluded) async def test_department_grant_is_honoured(db: AsyncSession, setup: Setup) -> None: text = await _chunk_text(db, setup.granted_eng) results = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) assert setup.granted_eng.id in _doc_ids(results) async def test_unpublished_documents_are_never_similar( db: AsyncSession, setup: Setup ) -> None: """Why duplicate detection cannot match the draft it just created: an unpublished document is outside the searchable set by construction.""" text = await _chunk_text(db, setup.draft) results = await similar_chunks(db, text, user=setup.pablo, max_distance=1.0) assert setup.draft.id not in _doc_ids(results) async def test_documents_are_grouped_by_their_closest_chunk( db: AsyncSession, setup: Setup ) -> None: document = await _doc( db, title="Mehrteilige Anleitung", content=( "## Erster Abschnitt\n\nHier steht der erste Teil der Anleitung.\n\n" "## Zweiter Abschnitt\n\nHier steht der zweite Teil der Anleitung." ), author=setup.pablo, department_id=setup.pablo.department_id, visibility=DocumentVisibility.public, ) await reindex_document(db, document) await db.commit() chunks = ( await db.execute( select(Chunk.content, Chunk.meta).where(Chunk.document_id == document.id) ) ).all() assert len(chunks) > 1, "fixture needs a multi-chunk document" query = embedding_text(chunks[1].meta["heading_path"], chunks[1].content) results = await similar_documents(db, query, user=setup.pablo, max_distance=1.0) mine = [r for r in results if r.document_id == document.id] assert len(mine) == 1, "a document must appear once, not once per chunk" assert mine[0].distance < 0.001, "grouping must keep the CLOSEST chunk's distance" async def test_similar_documents_respects_top_k(db: AsyncSession, setup: Setup) -> None: results = await similar_documents( db, "Kaffeemaschine", user=setup.pablo, top_k=1, max_distance=1.0 ) assert len(results) <= 1 async def test_similarity_records_metrics(db: AsyncSession, setup: Setup) -> None: await similar_chunks(db, "Kaffeemaschine", user=setup.pablo, max_distance=1.0) snapshot = metrics.snapshot() assert snapshot["counters"]["similarity_searches_total"][0]["value"] >= 1 assert "similarity_seconds" in snapshot["histograms"] async def test_similarity_logs_no_content( db: AsyncSession, setup: Setup, caplog: pytest.LogCaptureFixture ) -> None: """Rule 12: the searched text is user content and never reaches a log.""" secret = "GEHEIM-SUCHTEXT-42 Kaffeemaschine entkalken" with caplog.at_level("INFO", logger="pablan.rag"): await similar_chunks(db, secret, user=setup.pablo, max_distance=1.0) rendered = "\n".join( record.getMessage() + str(record.__dict__) for record in caplog.records ) assert "GEHEIM-SUCHTEXT-42" not in rendered