Pablan, as it stands

Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
"""Permission boundaries and hybrid plumbing of rag.retrieval.search.
Uses deterministic fake embeddings (fake_embed fixture): identical text →
distance 0; retrieval semantics with real embeddings live in tests/evals.
"""
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.retrieval import search, text_search
pytestmark = pytest.mark.usefixtures("fake_embed")
class Setup:
pablo: User # Engineering
max: User # Sales
norbert: User # no department
pub: Document
dept_eng: Document
restricted_ben: 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.norbert = await _user(db, "norbert@test.dev", None)
s.pub = await _doc(
db,
title="Kaffeemaschine",
content="## Pflege\n\nDie Kaffeemaschine wird jeden Freitag entkalkt.",
author=s.pablo,
department_id=engineering.id,
visibility=DocumentVisibility.public,
)
s.dept_eng = await _doc(
db,
title="Bandschleifer BS-100",
content="## Wartung\n\nDer Bandschleifer braucht wöchentlich ein neues Schleifband.",
author=s.pablo,
department_id=engineering.id,
visibility=DocumentVisibility.department,
)
s.restricted_ben = 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 für das Ersatzteillager 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_eng, s.restricted_ben, s.granted_eng, s.draft):
await reindex_document(db, document)
await db.commit()
return s
def _doc_ids(results) -> set[uuid.UUID]:
return {result.document_id for result in results}
async def test_results_carry_citation_metadata(db: AsyncSession, setup: Setup) -> None:
# Every query term must exist in the target chunk: websearch_to_tsquery
# ANDs terms, and the fake embeddings carry no semantics.
results = await search(db, "Kaffeemaschine entkalkt", user=setup.pablo)
assert results, "public document not found"
top = results[0]
assert top.document_id == setup.pub.id
assert top.title == "Kaffeemaschine"
assert top.heading_path == "Kaffeemaschine Pflege"
assert top.content
assert top.score > 0
async def test_department_visibility(db: AsyncSession, setup: Setup) -> None:
query = "Schleifband für den Bandschleifer"
assert setup.dept_eng.id in _doc_ids(await search(db, query, user=setup.pablo))
assert setup.dept_eng.id not in _doc_ids(await search(db, query, user=setup.max))
assert setup.dept_eng.id not in _doc_ids(
await search(db, query, user=setup.norbert)
)
async def test_restricted_needs_grant_or_authorship(
db: AsyncSession, setup: Setup
) -> None:
query = "Zugangskarte Ersatzteillager Tresorfach"
# Engineering has an explicit grant.
assert setup.granted_eng.id in _doc_ids(await search(db, query, user=setup.pablo))
# The author always sees their own document.
assert setup.granted_eng.id in _doc_ids(await search(db, query, user=setup.max))
# No department, no grant, no authorship: nothing.
assert setup.granted_eng.id not in _doc_ids(
await search(db, query, user=setup.norbert)
)
async def test_restricted_document_never_leaks(db: AsyncSession, setup: Setup) -> None:
"""Acceptance: user A can NEVER retrieve chunks of user B's restricted
document — tested through both retrieval branches."""
# Full-text branch: the exact distinctive term.
fts_results = await search(db, "Rabattdeckel", user=setup.pablo)
assert setup.restricted_ben.id not in _doc_ids(fts_results)
# Vector branch: query IS the exact chunk content (distance 0 — it would
# be the top hit if the filter leaked).
chunk = (
await db.execute(
select(Chunk.content, Chunk.meta).where(
Chunk.document_id == setup.restricted_ben.id
)
)
).one()
chunk_content = embedding_text(chunk.meta["heading_path"], chunk.content)
vec_results = await search(db, chunk_content, user=setup.pablo)
assert setup.restricted_ben.id not in _doc_ids(vec_results)
# The author, of course, finds it.
assert setup.restricted_ben.id in _doc_ids(
await search(db, "Rabattdeckel", user=setup.max)
)
async def test_unpublished_documents_are_never_searchable(
db: AsyncSession, setup: Setup
) -> None:
"""Draft chunks exist in the table but must never surface — not even for
the author, not even for a query that is the exact chunk content."""
chunk = (
await db.execute(
select(Chunk.content, Chunk.meta).where(Chunk.document_id == setup.draft.id)
)
).one()
chunk_content = embedding_text(chunk.meta["heading_path"], chunk.content)
for query in ("Pausenregelung Entwurf", chunk_content):
assert setup.draft.id not in _doc_ids(await search(db, query, user=setup.pablo))
async def test_status_change_applies_without_reindex(
db: AsyncSession, setup: Setup
) -> None:
query = "Zugangskarte Ersatzteillager Tresorfach"
assert setup.granted_eng.id in _doc_ids(await search(db, query, user=setup.pablo))
setup.granted_eng.status = DocumentStatus.archived
await db.commit()
# Chunks still exist, but the live status filter hides them instantly.
assert setup.granted_eng.id not in _doc_ids(
await search(db, query, user=setup.pablo)
)
async def test_visibility_change_applies_without_reindex(
db: AsyncSession, setup: Setup
) -> None:
"""The permission filter reads the documents table, never the stale
denormalized copy in chunk meta."""
query = "Schleifband für den Bandschleifer"
assert setup.dept_eng.id in _doc_ids(await search(db, query, user=setup.pablo))
setup.dept_eng.visibility = DocumentVisibility.restricted
setup.dept_eng.author_id = setup.max.id # take authorship out of the way
await db.commit()
assert setup.dept_eng.id not in _doc_ids(await search(db, query, user=setup.pablo))
async def test_vector_branch_finds_exact_content(
db: AsyncSession, setup: Setup
) -> None:
chunk = (
await db.execute(
select(Chunk.content, Chunk.meta).where(Chunk.document_id == setup.pub.id)
)
).one()
chunk_content = embedding_text(chunk.meta["heading_path"], chunk.content)
results = await search(db, chunk_content, user=setup.norbert)
assert results
top = results[0]
assert top.document_id == setup.pub.id
assert top.vector_distance is not None
assert top.vector_distance < 0.001
async def test_top_k_limits_results(db: AsyncSession, setup: Setup) -> None:
results = await search(db, "Kaffeemaschine entkalkt", user=setup.pablo, top_k=1)
assert len(results) <= 1
async def test_search_records_metrics(db: AsyncSession, setup: Setup) -> None:
await search(db, "Kaffeemaschine", user=setup.pablo)
snapshot = metrics.snapshot()
assert snapshot["counters"]["retrieval_searches_total"][0]["value"] >= 1
assert "retrieval_seconds" in snapshot["histograms"]
async def test_text_search_needs_no_embedding_and_keeps_the_permission_filter(
db: AsyncSession, setup: Setup, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The fallback for a dead embedding endpoint: keyword matching over the
tsvector index alone, with the same permission CTE as `search`."""
async def _no_endpoint(texts: list[str], *, role: str = "embedding"):
raise AssertionError("text_search must not embed")
monkeypatch.setattr("app.rag.retrieval.embed", _no_endpoint)
results = await text_search(db, "Kaffeemaschine entkalkt", user=setup.pablo)
assert setup.pub.id in _doc_ids(results)
assert all(result.fts_match for result in results)
assert all(result.vector_distance is None for result in results)
# Same boundaries as the hybrid path: someone else's restricted document
# stays invisible, an unpublished draft stays out.
assert setup.restricted_ben.id not in _doc_ids(
await text_search(db, "Rabattdeckel", user=setup.pablo)
)
assert setup.restricted_ben.id in _doc_ids(
await text_search(db, "Rabattdeckel", user=setup.max)
)
async def test_text_search_returns_nothing_for_an_unmatched_query(
db: AsyncSession, setup: Setup
) -> None:
"""No fuzzy rescue without vectors: a word nobody wrote finds nothing,
which is what the UI has to be able to say."""
assert await text_search(db, "Quantenverschraenkung", user=setup.pablo) == []
async def test_text_search_answers_a_whole_question(
db: AsyncSession, setup: Setup
) -> None:
"""A question is typed as a sentence, and without a vector half to carry
the recall, requiring every word in one chunk would find nothing."""
results = await text_search(
db, "Wie wird die Kaffeemaschine eigentlich entkalkt?", user=setup.pablo
)
assert setup.pub.id in _doc_ids(results)
async def test_text_search_ignores_a_query_of_only_stop_words(
db: AsyncSession, setup: Setup
) -> None:
"""Nothing to search for is an empty result, not a database error."""
assert await text_search(db, "und der die", user=setup.pablo) == []