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:
@@ -0,0 +1,176 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from alembic import command
|
||||
from app.auth.passwords import hash_password
|
||||
from app.config import get_settings
|
||||
from app.db import get_db
|
||||
from app.llm import client as llm_client
|
||||
from app.main import app
|
||||
from app.metrics import metrics
|
||||
from app.models import Department, User, UserRole
|
||||
from tests.fake_openai import FakeOpenAI
|
||||
|
||||
# Tests run against a dedicated database, never the dev one.
|
||||
TEST_DB_NAME = "pablan_test"
|
||||
|
||||
# Every table a test may write to — a new table must be added here, or
|
||||
# state leaks into the next test.
|
||||
_TABLES = (
|
||||
"messages, conversations, doc_permissions, document_events, chunks, "
|
||||
"documents, auth_sessions, users, templates, jobs, departments, "
|
||||
"llm_settings, prompt_settings"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_db_url() -> str:
|
||||
"""Drop, recreate and migrate the test database once per test session."""
|
||||
admin_url = make_url(get_settings().database_url)
|
||||
assert admin_url.database != TEST_DB_NAME
|
||||
test_url = admin_url.set(database=TEST_DB_NAME)
|
||||
|
||||
async def prepare() -> None:
|
||||
engine = create_async_engine(
|
||||
admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
|
||||
)
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(
|
||||
text(f"DROP DATABASE IF EXISTS {TEST_DB_NAME} WITH (FORCE)")
|
||||
)
|
||||
await conn.execute(text(f"CREATE DATABASE {TEST_DB_NAME}"))
|
||||
await engine.dispose()
|
||||
|
||||
# Sync fixture: no event loop is running here, so asyncio.run is safe —
|
||||
# as is alembic's command API (env.py calls asyncio.run itself).
|
||||
asyncio.run(prepare())
|
||||
|
||||
# str(URL) masks the password as "***" — render it explicitly.
|
||||
url_string = test_url.render_as_string(hide_password=False)
|
||||
config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini"))
|
||||
config.set_main_option("sqlalchemy.url", url_string.replace("%", "%%"))
|
||||
command.upgrade(config, "head")
|
||||
return url_string
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine(test_db_url: str) -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_async_engine(test_db_url, poolclass=NullPool)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_tables(db_engine: AsyncEngine) -> None:
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(text(f"TRUNCATE TABLE {_TABLES} CASCADE"))
|
||||
# The prompt-override cache is module-level and outlives a truncated
|
||||
# prompt_settings, so drop it too or an override leaks into the next test.
|
||||
from app.prompts import overrides as prompt_overrides
|
||||
|
||||
prompt_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
|
||||
factory = async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db_engine: AsyncEngine) -> AsyncIterator[AsyncClient]:
|
||||
factory = async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
|
||||
async def override_get_db() -> AsyncIterator[AsyncSession]:
|
||||
async with factory() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_user(db: AsyncSession) -> User:
|
||||
department = Department(name="Engineering")
|
||||
db.add(department)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email="pablo@test.dev",
|
||||
name="Pablo Test",
|
||||
role=UserRole.member,
|
||||
password_hash=hash_password("secret123"),
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_admin(db: AsyncSession) -> User:
|
||||
department = Department(name="Administration")
|
||||
db.add(department)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email="florian@test.dev",
|
||||
name="Florian Test",
|
||||
role=UserRole.admin,
|
||||
password_hash=hash_password("secret123"),
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_metrics() -> Iterator[None]:
|
||||
metrics.reset()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_embed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Deterministic embeddings instead of llm.client.embed — indexing and
|
||||
retrieval tests never need a live embedding endpoint."""
|
||||
from app.rag import indexing, retrieval, similarity
|
||||
from tests.embedding_stub import fake_embed as stub
|
||||
|
||||
# Every module that embeds: patching one and forgetting another shows up
|
||||
# as a live endpoint call in a test that was supposed to be offline.
|
||||
monkeypatch.setattr(indexing, "embed", stub)
|
||||
monkeypatch.setattr(retrieval, "embed", stub)
|
||||
monkeypatch.setattr(similarity, "embed", stub)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_llm(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeOpenAI]:
|
||||
"""Route the LLM client at a fake OpenAI-compatible ASGI app."""
|
||||
fake = FakeOpenAI()
|
||||
monkeypatch.setattr(
|
||||
llm_client,
|
||||
"_http_client_factory",
|
||||
lambda: httpx.AsyncClient(transport=ASGITransport(app=fake.app)),
|
||||
)
|
||||
llm_client._client_for.cache_clear()
|
||||
yield fake
|
||||
llm_client._client_for.cache_clear()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Deterministic fake embeddings for indexing/retrieval tests.
|
||||
|
||||
Identical text maps to an identical unit vector (cosine distance 0);
|
||||
unrelated texts land near-orthogonal. Semantics are exercised by the evals
|
||||
against the real endpoint — these tests cover the SQL plumbing.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
|
||||
from app.models import EMBEDDING_DIM
|
||||
|
||||
|
||||
def deterministic_embedding(text: str) -> list[float]:
|
||||
seed = hashlib.sha256(text.encode()).digest()
|
||||
rng = random.Random(seed)
|
||||
vector = [rng.uniform(-1.0, 1.0) for _ in range(EMBEDDING_DIM)]
|
||||
norm = math.sqrt(sum(value * value for value in vector))
|
||||
return [value / norm for value in vector]
|
||||
|
||||
|
||||
async def fake_embed(texts: list[str], *, role: str = "embedding") -> list[list[float]]:
|
||||
return [deterministic_embedding(text) for text in texts]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Section-refinement eval (`make eval`).
|
||||
|
||||
Runs against the CONFIGURED chat endpoint — it must be live. Proves the core
|
||||
of writing-first capture on a real model: a rough section becomes mature
|
||||
prose, its heading is kept, ONLY that section comes back (FIM), the language
|
||||
is preserved, and no load-bearing fact is dropped.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.authoring.prompts import render_refine_prompt
|
||||
from app.llm.client import chat_stream
|
||||
|
||||
pytestmark = pytest.mark.eval
|
||||
|
||||
# Same flag the /refine endpoint uses: turn off the reasoning channel so the
|
||||
# call is fast and the eval measures the answer, not the thinking.
|
||||
_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
PREFIX = "## Zweck\nDieser Ablauf beschreibt die monatliche Rechnungsstellung."
|
||||
SECTION = (
|
||||
"## Ablauf\n"
|
||||
"also man macht das am monatsanfang. erst die stunden exportieren, dann in "
|
||||
"die vorlage kopieren und per mail raus. bei einer PO muss die nummer drauf."
|
||||
)
|
||||
SUFFIX = "## Fallstricke"
|
||||
|
||||
|
||||
async def _refine(
|
||||
section: str,
|
||||
prefix: str,
|
||||
suffix: str,
|
||||
knowledge: list[str] | None = None,
|
||||
) -> str:
|
||||
messages = render_refine_prompt(
|
||||
section,
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
persona="Du bist ein präziser Fachredakteur, der Abläufe dokumentiert.",
|
||||
hint="Die Schritte in Reihenfolge, als Liste.",
|
||||
knowledge=knowledge,
|
||||
)
|
||||
parts: list[str] = []
|
||||
async for token in chat_stream(
|
||||
messages, role="chat", temperature=0.4, extra_body=_NO_THINKING
|
||||
):
|
||||
parts.append(token)
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
async def test_refinement_matures_only_the_active_section() -> None:
|
||||
out = await _refine(SECTION, PREFIX, SUFFIX)
|
||||
|
||||
assert out, "refinement returned nothing"
|
||||
# Kept the section's own heading.
|
||||
assert out.lstrip().startswith("## Ablauf")
|
||||
# ONLY this section: the surrounding headings must not be re-emitted.
|
||||
assert "## Zweck" not in out
|
||||
assert "## Fallstricke" not in out
|
||||
# Used the input and kept the load-bearing PO fact.
|
||||
assert any(token in out for token in ("PO", "Purchase", "Bestell"))
|
||||
# Language preserved (German): a switch to English would be a regression.
|
||||
lowered = out.lower()
|
||||
assert any(word in lowered for word in (" der ", " die ", " und ", " wird "))
|
||||
|
||||
|
||||
# A related document the company already has. It shares the topic but carries a
|
||||
# distinctive fact the section itself never mentions.
|
||||
GROUNDING = [
|
||||
'From "Zahlungsbedingungen" (Fristen): Rechnungen sind binnen 14 Tagen '
|
||||
"fällig, mit zwei Prozent Skonto bei Zahlung binnen sieben Tagen."
|
||||
]
|
||||
|
||||
|
||||
async def test_grounding_informs_without_being_copied_in() -> None:
|
||||
out = await _refine(SECTION, PREFIX, SUFFIX, knowledge=GROUNDING)
|
||||
|
||||
assert out, "refinement returned nothing"
|
||||
# Grounding does not break the one-section contract.
|
||||
assert out.lstrip().startswith("## Ablauf")
|
||||
assert "## Zweck" not in out and "## Fallstricke" not in out
|
||||
# The section keeps its own load-bearing fact.
|
||||
assert any(token in out for token in ("PO", "Purchase", "Bestell"))
|
||||
# Grounding is a reference, not a fact source: the related document's own
|
||||
# detail must not be imported into this section, and the reference framing
|
||||
# must not be echoed back.
|
||||
assert "Skonto" not in out
|
||||
assert "Zahlungsbedingungen" not in out
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Retrieval quality eval over the golden query set (`make eval`).
|
||||
|
||||
Runs against the CONFIGURED embedding endpoint — it must be live. The
|
||||
corpus is indexed as public documents for a single eval user: this measures
|
||||
retrieval QUALITY; permission behavior is covered by the unit tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
from app.models import (
|
||||
Department,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from app.rag.indexing import reindex_document
|
||||
from app.rag.retrieval import results_are_low_confidence, search
|
||||
from tests.fixtures.loader import load_corpus, load_golden_queries
|
||||
|
||||
pytestmark = pytest.mark.eval
|
||||
|
||||
RECALL_FLOOR = 0.8 # baseline 2026-07: 25/25 = 1.00
|
||||
|
||||
|
||||
async def test_retrieval_golden_set(db: AsyncSession) -> None:
|
||||
corpus = load_corpus()
|
||||
queries = load_golden_queries()
|
||||
|
||||
department = Department(name="Eval")
|
||||
db.add(department)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email="eval@test.dev",
|
||||
name="Eval User",
|
||||
role=UserRole.member,
|
||||
password_hash=hash_password("eval-only"),
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
slug_by_document_id = {}
|
||||
for doc in corpus:
|
||||
document = Document(
|
||||
title=doc.title,
|
||||
status=DocumentStatus.published,
|
||||
visibility=DocumentVisibility.public,
|
||||
content_md=doc.content_md,
|
||||
meta={"slug": doc.slug},
|
||||
author_id=user.id,
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
slug_by_document_id[document.id] = doc.slug
|
||||
await reindex_document(db, document) # real embeddings
|
||||
await db.commit()
|
||||
|
||||
hits = 0
|
||||
expected_total = 0
|
||||
misses: list[str] = []
|
||||
no_answer_violations: list[str] = []
|
||||
report: list[str] = []
|
||||
|
||||
for entry in queries:
|
||||
results = await search(db, entry["query"], user=user, top_k=5)
|
||||
top_slugs = [slug_by_document_id[r.document_id] for r in results]
|
||||
if entry["expected"]:
|
||||
expected_total += 1
|
||||
hit = any(slug in entry["expected"] for slug in top_slugs)
|
||||
hits += int(hit)
|
||||
if not hit:
|
||||
misses.append(entry["query"])
|
||||
report.append(
|
||||
f"{'HIT ' if hit else 'MISS'} {entry['query'][:58]!r} -> {top_slugs[:3]}"
|
||||
)
|
||||
else:
|
||||
top = results[0] if results else None
|
||||
fts = top.fts_match if top else False
|
||||
distance = top.vector_distance if top else None
|
||||
report.append(
|
||||
f"NOANS {entry['query'][:58]!r} fts={fts} distance={distance:.3f}"
|
||||
if distance is not None
|
||||
else f"NOANS {entry['query'][:58]!r} fts={fts} distance=None"
|
||||
)
|
||||
confident_nothing = results_are_low_confidence(results)
|
||||
if not confident_nothing:
|
||||
no_answer_violations.append(entry["query"])
|
||||
|
||||
recall = hits / expected_total
|
||||
print("\n".join(report))
|
||||
print(f"\nrecall@5: {hits}/{expected_total} = {recall:.2f}")
|
||||
|
||||
assert recall >= RECALL_FLOOR, f"recall {recall:.2f} below floor; misses: {misses}"
|
||||
assert not no_answer_violations, (
|
||||
f"no-answer queries returned confident results: {no_answer_violations}"
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Does Pablan find its own help pages when asked about itself? (`make eval`)
|
||||
|
||||
Runs against the CONFIGURED embedding endpoint — it must be live.
|
||||
|
||||
The help pages under `help/` are imported into `documents` on every start and
|
||||
are searchable like anything else, which is what lets Pablan answer "how do I
|
||||
share a document?" from its own retrieval path instead of from a hardcoded
|
||||
FAQ. That only works if the question actually retrieves the right page, and
|
||||
the pages are written in one voice about one product, so they compete with
|
||||
each other far more than the corpus documents do. This measures exactly that:
|
||||
a question a user would type, against the help pages as shipped.
|
||||
|
||||
The company corpus is indexed alongside them on purpose — an instance is
|
||||
never only help pages, and "how do I report a fault?" must not pull the help
|
||||
page about writing documents.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
from app.config import get_settings
|
||||
from app.help_import import parse_help_document
|
||||
from app.models import (
|
||||
Department,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from app.rag.indexing import reindex_document
|
||||
from app.rag.retrieval import results_are_low_confidence, search
|
||||
from tests.fixtures.loader import load_corpus
|
||||
|
||||
pytestmark = pytest.mark.eval
|
||||
|
||||
RECALL_FLOOR = 0.8 # baseline 2026-08: 10/10 = 1.00
|
||||
|
||||
# Questions in the words a user would use, and the help page (`key` in the
|
||||
# file's frontmatter) that has to be in the top 5.
|
||||
QUESTIONS: list[tuple[str, set[str]]] = [
|
||||
("Wie funktioniert Pablan?", {"pablan-ueberblick"}),
|
||||
("Was ist der Unterschied zwischen Fragen und Festhalten?", {"pablan-ueberblick"}),
|
||||
(
|
||||
"Wie teile ich ein Dokument mit einer anderen Abteilung?",
|
||||
{"dokumente-und-sichtbarkeit"},
|
||||
),
|
||||
("Was bedeutet der Status Entwurf?", {"dokumente-und-sichtbarkeit"}),
|
||||
(
|
||||
"Wie bitte ich eine Kollegin, ein Dokument zu prüfen?",
|
||||
{"dokumente-und-sichtbarkeit", "wissen-festhalten"},
|
||||
),
|
||||
("Warum sehe ich unter einer Antwort Quellen?", {"fragen-und-antworten"}),
|
||||
("Wie schreibe ich ein Dokument mit der Assistenz?", {"wissen-festhalten"}),
|
||||
("Wo stelle ich die Sprachmodell-Endpunkte ein?", {"administration"}),
|
||||
("Wie lege ich eine neue Abteilung an?", {"administration"}),
|
||||
("Wo finde ich das Profil einer Kollegin?", {"kolleginnen-und-profil"}),
|
||||
]
|
||||
|
||||
# A question about the company, asked in an instance that also has help pages:
|
||||
# the help must stay out of the way.
|
||||
COMPANY_QUESTIONS = [
|
||||
"Welcher Solldruck gilt für die Hydraulikpresse?",
|
||||
"Wie beantrage ich Urlaub?",
|
||||
"Was bedeutet Fehlercode E-203?",
|
||||
]
|
||||
|
||||
|
||||
async def test_help_pages_answer_questions_about_the_product(
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
department = Department(name="Eval")
|
||||
db.add(department)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email="eval@test.dev",
|
||||
name="Eval User",
|
||||
role=UserRole.member,
|
||||
password_hash=hash_password("eval-only"),
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
help_key_by_document_id: dict = {}
|
||||
for path in sorted(Path(get_settings().help_dir).glob("*.md")):
|
||||
key, title, body = parse_help_document(path.read_text())
|
||||
document = Document(
|
||||
title=title,
|
||||
status=DocumentStatus.published,
|
||||
visibility=DocumentVisibility.public,
|
||||
content_md=body,
|
||||
meta={"help": key},
|
||||
is_builtin=True,
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
help_key_by_document_id[document.id] = key
|
||||
await reindex_document(db, document)
|
||||
|
||||
# The company corpus, so the help pages have real competition.
|
||||
for doc in load_corpus():
|
||||
document = Document(
|
||||
title=doc.title,
|
||||
status=DocumentStatus.published,
|
||||
visibility=DocumentVisibility.public,
|
||||
content_md=doc.content_md,
|
||||
meta={"slug": doc.slug},
|
||||
author_id=user.id,
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
await reindex_document(db, document)
|
||||
await db.commit()
|
||||
|
||||
hits = 0
|
||||
report: list[str] = []
|
||||
for question, expected in QUESTIONS:
|
||||
results = await search(db, question, user=user, top_k=5)
|
||||
found = [help_key_by_document_id.get(result.document_id) for result in results]
|
||||
hit = any(key in expected for key in found if key)
|
||||
hits += int(hit)
|
||||
report.append(
|
||||
f"{'HIT ' if hit else 'MISS'} {question[:52]!r} -> {[f for f in found][:3]}"
|
||||
)
|
||||
|
||||
# A product question must also be ANSWERABLE, not just retrieved: a
|
||||
# low-confidence result set is presented as "nothing documented", which
|
||||
# for a question about Pablan itself is simply wrong.
|
||||
unanswered = []
|
||||
for question, _ in QUESTIONS:
|
||||
results = await search(db, question, user=user, top_k=5)
|
||||
if results_are_low_confidence(results):
|
||||
unanswered.append(question)
|
||||
|
||||
leaked = []
|
||||
for question in COMPANY_QUESTIONS:
|
||||
results = await search(db, question, user=user, top_k=5)
|
||||
top = results[0] if results else None
|
||||
if top is not None and top.document_id in help_key_by_document_id:
|
||||
leaked.append(f"{question!r} -> {help_key_by_document_id[top.document_id]}")
|
||||
|
||||
recall = hits / len(QUESTIONS)
|
||||
print("\n" + "\n".join(report))
|
||||
print(f"\nself-knowledge recall@5: {hits}/{len(QUESTIONS)} = {recall:.2f}")
|
||||
|
||||
assert not unanswered, f"asked about itself and had no answer: {unanswered}"
|
||||
assert not leaked, f"a help page outranked the company documents: {leaked}"
|
||||
assert recall >= RECALL_FLOOR, f"self-knowledge recall {recall:.2f}"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Topic-summary vs raw-message retrieval (`make eval`).
|
||||
|
||||
Query mode retrieves over the last user message today. On a topic-losing
|
||||
follow-up ("Hi", "what was my first question") that message finds nothing, even
|
||||
when the conversation is clearly about a documented subject. An LLM topic
|
||||
summary of the whole conversation should recover it. This eval measures how
|
||||
much better — the number that decides whether query mode should adopt it.
|
||||
Runs against the CONFIGURED embedding + utility endpoints.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.passwords import hash_password
|
||||
from app.authoring.prompts import render_topic_summary_prompt
|
||||
from app.llm.client import chat_json
|
||||
from app.models import (
|
||||
Department,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from app.rag.indexing import reindex_document
|
||||
from app.rag.retrieval import search
|
||||
from tests.fixtures.loader import load_conversation_snippets, load_corpus
|
||||
|
||||
pytestmark = pytest.mark.eval
|
||||
|
||||
_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
|
||||
|
||||
class _Topic(BaseModel):
|
||||
topic: str
|
||||
|
||||
|
||||
def _transcript(messages: list[dict[str, str]]) -> str:
|
||||
return "\n".join(f"{message['role']}: {message['content']}" for message in messages)
|
||||
|
||||
|
||||
async def _hit(db: AsyncSession, query: str, user: User, slug_by_id, expected) -> bool:
|
||||
results = await search(db, query, user=user, top_k=5)
|
||||
slugs = {slug_by_id.get(result.document_id) for result in results}
|
||||
return bool(expected & slugs)
|
||||
|
||||
|
||||
async def test_topic_summary_beats_the_raw_message(db: AsyncSession) -> None:
|
||||
corpus = load_corpus()
|
||||
snippets = load_conversation_snippets()
|
||||
|
||||
department = Department(name="Eval")
|
||||
db.add(department)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email="eval@test.dev",
|
||||
name="Eval User",
|
||||
role=UserRole.member,
|
||||
password_hash=hash_password("eval-only"),
|
||||
department_id=department.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
slug_by_id: dict = {}
|
||||
for doc in corpus:
|
||||
document = Document(
|
||||
title=doc.title,
|
||||
status=DocumentStatus.published,
|
||||
visibility=DocumentVisibility.public,
|
||||
content_md=doc.content_md,
|
||||
author_id=user.id,
|
||||
department_id=department.id,
|
||||
meta={"slug": doc.slug},
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
await reindex_document(db, document)
|
||||
slug_by_id[document.id] = doc.slug
|
||||
await db.commit()
|
||||
|
||||
raw_hits = 0
|
||||
topic_hits = 0
|
||||
for snippet in snippets:
|
||||
expected = set(snippet["expected"])
|
||||
last = snippet["messages"][-1]["content"]
|
||||
raw_hit = await _hit(db, last, user, slug_by_id, expected)
|
||||
|
||||
topic = (
|
||||
await chat_json(
|
||||
render_topic_summary_prompt(_transcript(snippet["messages"])),
|
||||
_Topic,
|
||||
extra_body=_NO_THINKING,
|
||||
)
|
||||
).topic
|
||||
topic_hit = await _hit(db, topic, user, slug_by_id, expected)
|
||||
|
||||
raw_hits += int(raw_hit)
|
||||
topic_hits += int(topic_hit)
|
||||
print(
|
||||
f"[{'HIT ' if topic_hit else 'MISS'}] expected={expected} "
|
||||
f"raw={'hit' if raw_hit else 'miss'} topic={topic!r}"
|
||||
)
|
||||
|
||||
n = len(snippets)
|
||||
print(f"\nraw recall {raw_hits}/{n} | topic-summary recall {topic_hits}/{n}")
|
||||
# The whole point: a topic summary must not do worse than the raw message,
|
||||
# and must actually recover the documented subject on these follow-ups.
|
||||
assert topic_hits >= raw_hits
|
||||
assert topic_hits >= max(1, raw_hits + 1)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Minimal fake OpenAI-compatible server as an ASGI app (no real LLM).
|
||||
|
||||
Wire it into the client via httpx.ASGITransport — see the fake_llm fixture.
|
||||
Chat behavior is scripted through `chat_responses`; each entry is one of:
|
||||
{"content": "..."} plain completion content
|
||||
{"chunks": ["a", "b"]} streamed delta pieces
|
||||
{"status": 500} induced HTTP error
|
||||
An empty script falls back to `default_content`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
_USAGE = {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeOpenAI:
|
||||
chat_responses: list[dict[str, Any]] = field(default_factory=list)
|
||||
default_content: str = "pong"
|
||||
embedding_dim: int = 8
|
||||
# What GET /v1/models reports; None means the route is missing (404).
|
||||
served_models: list[str] | None = field(
|
||||
default_factory=lambda: ["gemma-3-27b", "bge-m3"]
|
||||
)
|
||||
requests: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.app = self._build_app()
|
||||
|
||||
def _build_app(self) -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request) -> Any:
|
||||
body = await request.json()
|
||||
self.requests.append(body)
|
||||
plan = (
|
||||
self.chat_responses.pop(0)
|
||||
if self.chat_responses
|
||||
else {"content": self.default_content}
|
||||
)
|
||||
if "status" in plan:
|
||||
return JSONResponse(
|
||||
{"error": {"message": "induced failure", "type": "server_error"}},
|
||||
status_code=plan["status"],
|
||||
)
|
||||
content = plan.get("content", self.default_content)
|
||||
if body.get("stream"):
|
||||
pieces = plan.get("chunks", [content])
|
||||
include_usage = bool(
|
||||
(body.get("stream_options") or {}).get("include_usage")
|
||||
)
|
||||
return StreamingResponse(
|
||||
self._stream(body["model"], pieces, include_usage),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": "cmpl-fake",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": body["model"],
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": content},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": _USAGE,
|
||||
}
|
||||
)
|
||||
|
||||
@app.post("/v1/embeddings")
|
||||
async def embeddings(request: Request) -> Any:
|
||||
body = await request.json()
|
||||
self.requests.append(body)
|
||||
inputs = body["input"]
|
||||
if isinstance(inputs, str):
|
||||
inputs = [inputs]
|
||||
return JSONResponse(
|
||||
{
|
||||
"object": "list",
|
||||
"model": body["model"],
|
||||
"data": [
|
||||
{
|
||||
"object": "embedding",
|
||||
"index": i,
|
||||
"embedding": [float(i)] * self.embedding_dim,
|
||||
}
|
||||
for i in range(len(inputs))
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": len(inputs),
|
||||
"total_tokens": len(inputs),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def models() -> Any:
|
||||
# `served_models = None` mimics an endpoint without the route,
|
||||
# which plenty of OpenAI-compatible servers genuinely lack.
|
||||
if self.served_models is None:
|
||||
return JSONResponse(
|
||||
{"error": {"message": "not found", "type": "invalid_request"}},
|
||||
status_code=404,
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": name, "object": "model", "owned_by": "fake"}
|
||||
for name in self.served_models
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
async def _stream(model: str, pieces: list[str], include_usage: bool) -> Any:
|
||||
def chunk(delta: dict[str, Any], finish: str | None) -> str:
|
||||
payload = {
|
||||
"id": "cmpl-fake",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
||||
}
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
for piece in pieces:
|
||||
yield chunk({"content": piece}, None)
|
||||
yield chunk({}, "stop")
|
||||
if include_usage:
|
||||
final = {
|
||||
"id": "cmpl-fake",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": model,
|
||||
"choices": [],
|
||||
"usage": _USAGE,
|
||||
}
|
||||
yield f"data: {json.dumps(final)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
@@ -0,0 +1,41 @@
|
||||
# Short chats whose LAST message loses the topic (a greeting, a meta-question),
|
||||
# even though the conversation is clearly about a documented subject. Used by
|
||||
# tests/evals/test_topic_retrieval_eval.py to compare retrieval over an LLM
|
||||
# topic summary against retrieval over the raw last message. `expected` is the
|
||||
# corpus slug the conversation is really about. German product content.
|
||||
|
||||
- messages:
|
||||
- role: user
|
||||
content: "Wie beantrage ich Urlaub?"
|
||||
- role: assistant
|
||||
content: "Urlaub beantragst du digital im Personalportal unter Abwesenheit, neuer Antrag."
|
||||
- role: user
|
||||
content: "Und was war eigentlich meine erste Frage?"
|
||||
expected: [urlaubsantrag-prozess]
|
||||
|
||||
- messages:
|
||||
- role: user
|
||||
content: "Wie oft muss die CNC-Fräse F-350 gewartet werden?"
|
||||
- role: assistant
|
||||
content: "Die Intervalle stehen im Wartungsplan der F-350, gestaffelt nach Betriebsstunden."
|
||||
- role: user
|
||||
content: "Alles klar, danke dir."
|
||||
expected: [wartungsplan-cnc-f350]
|
||||
|
||||
- messages:
|
||||
- role: user
|
||||
content: "Wie rechne ich eine Dienstreise ab?"
|
||||
- role: assistant
|
||||
content: "Reisekosten reichst du mit allen Belegen über das übliche Formular ein."
|
||||
- role: user
|
||||
content: "Hi"
|
||||
expected: [reisekosten]
|
||||
|
||||
- messages:
|
||||
- role: user
|
||||
content: "Was bedeutet der Fehlercode an der S7-Steuerung in Halle 2?"
|
||||
- role: assistant
|
||||
content: "Die Fehlercodes der S7 sind dokumentiert, jeder Code hat eine Ursache und Behebung."
|
||||
- role: user
|
||||
content: "Kannst du das nochmal wiederholen?"
|
||||
expected: [fehlercodes-sps-s7]
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: angebotskalkulation
|
||||
title: "Angebotskalkulation Sondermaschinen"
|
||||
department: Sales
|
||||
visibility: restricted
|
||||
grants: [Sales]
|
||||
---
|
||||
|
||||
# Angebotskalkulation Sondermaschinen
|
||||
|
||||
Vertraulich — enthält unsere Kalkulationslogik und Zuschlagssätze.
|
||||
|
||||
## Kalkulationsschema
|
||||
|
||||
Basis ist die Stückliste aus der Konstruktion plus die geplanten
|
||||
Fertigungsstunden der Arbeitsvorbereitung (AV). Darauf kommen:
|
||||
|
||||
- Materialgemeinkosten: 12 %
|
||||
- Fertigungsstundensatz: laut aktueller Satztabelle im Laufwerk
|
||||
V:\Kalkulation (wird jährlich zum 1. März aktualisiert)
|
||||
- Engineering-Stunden werden separat ausgewiesen, nie in den
|
||||
Maschinenpreis eingerechnet
|
||||
- Projektzuschlag für Risiko: 5 % bei Standardnähe, 15 % bei Neuland
|
||||
|
||||
## Untergrenzen
|
||||
|
||||
Angebote unter Deckungsbeitrag 2 gehen nicht raus. Ausnahmen genehmigt
|
||||
ausschließlich die Geschäftsführung schriftlich — „strategischer Kunde"
|
||||
ist kein Selbstbedienungsargument.
|
||||
|
||||
## Gültigkeit und Nachkalkulation
|
||||
|
||||
Angebote gelten 60 Tage. Nach Auftragsabschluss macht die AV eine
|
||||
Nachkalkulation; Abweichungen über 10 % werden im Vertriebsmeeting
|
||||
besprochen, damit die Sätze realistisch bleiben.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: crm-leitfaden
|
||||
title: "CRM-Pflege: Leitfaden für den Vertrieb"
|
||||
department: Sales
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# CRM-Pflege: Leitfaden für den Vertrieb
|
||||
|
||||
Unser CRM ist nur so gut wie seine Daten. Angebote ohne gepflegte
|
||||
Kontakthistorie sind im Urlaubsfall für niemanden nachvollziehbar.
|
||||
|
||||
## Pflichtfelder je Kontakt
|
||||
|
||||
- Firma, Ansprechpartner mit Funktion
|
||||
- Branche und Maschinenpark (Freitextfeld „Ausstattung")
|
||||
- Nächster vereinbarter Schritt mit Datum
|
||||
|
||||
## Aktivitäten erfassen
|
||||
|
||||
Jedes Telefonat und jeder Besuch wird noch am selben Tag als Aktivität
|
||||
erfasst. Kurzform reicht: Anlass, Ergebnis, nächster Schritt. E-Mails
|
||||
zieht das CRM automatisch, wenn die Adresse am Kontakt hinterlegt ist.
|
||||
|
||||
## Verkaufschancen
|
||||
|
||||
Eine Verkaufschance wird angelegt, sobald der Kunde ein konkretes
|
||||
Projekt nennt — nicht erst beim Angebot. Phasen: Anfrage, Angebot,
|
||||
Verhandlung, Auftrag/Verloren. Beim Schließen als „Verloren" immer den
|
||||
Grund auswählen; die Auswertung geht quartalsweise an die
|
||||
Geschäftsführung.
|
||||
|
||||
## Wiedervorlagen
|
||||
|
||||
Wiedervorlagen gehören ins CRM, nicht in Outlook. Der Montagsbericht
|
||||
zieht automatisch alle überfälligen Wiedervorlagen — wer seine Liste
|
||||
leer hält, taucht dort nicht auf.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
id: datenschutz-grundlagen
|
||||
title: "Datenschutz im Arbeitsalltag"
|
||||
department: Administration
|
||||
visibility: public
|
||||
---
|
||||
|
||||
# Datenschutz im Arbeitsalltag
|
||||
|
||||
Kurzfassung der wichtigsten DSGVO-Regeln für den Alltag bei Nordwind.
|
||||
Die vollständige Datenschutzrichtlinie liegt im Intranet; Ansprechpartner
|
||||
ist der externe Datenschutzbeauftragte (Kontakt am Schwarzen Brett).
|
||||
|
||||
## Grundregeln
|
||||
|
||||
- Personenbezogene Daten nur erheben, wenn sie für die Aufgabe nötig sind
|
||||
- Keine Kundendaten auf private Geräte oder in private Cloud-Speicher
|
||||
- Bildschirm sperren beim Verlassen des Platzes (Windows-Taste + L)
|
||||
- Unterlagen mit Personenbezug in den Datenschutztonnen entsorgen, nicht
|
||||
im Papiermüll
|
||||
|
||||
## E-Mail und Verteiler
|
||||
|
||||
Bei Rundmails an externe Empfänger immer BCC verwenden. Bewerbungen
|
||||
werden ausschließlich von der Personalabteilung weitergeleitet — auch
|
||||
intern nicht „mal eben" an Kollegen schicken.
|
||||
|
||||
## Datenpannen melden
|
||||
|
||||
Verlorener Laptop, falsch versendete E-Mail mit Personendaten,
|
||||
verdächtige Anmeldungen: sofort an it@nordwind-maschinenbau.example
|
||||
UND die Personalabteilung melden. Die 72-Stunden-Meldefrist der DSGVO
|
||||
beginnt, sobald irgendjemand im Unternehmen von der Panne weiß.
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
---
|
||||
id: edi-rechnungen
|
||||
title: "EDI-Rechnungsversand an Großkunden"
|
||||
department: Administration
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# EDI-Rechnungsversand an Großkunden
|
||||
|
||||
EDI steht für Electronic Data Interchange — den elektronischen
|
||||
Datenaustausch strukturierter Belege direkt zwischen den ERP-Systemen.
|
||||
Drei Großkunden erhalten ihre Rechnungen ausschließlich per EDI;
|
||||
Papier- oder PDF-Rechnungen werden dort automatisch abgewiesen.
|
||||
|
||||
## Angebundene Kunden
|
||||
|
||||
- Bremer & Söhne: EDIFACT INVOIC über den Provider Retarus
|
||||
- MK Antriebstechnik: ZUGFeRD-PDF per E-Mail an deren Rechnungseingang
|
||||
- Feldmann Gruppe: XRechnung über das Kundenportal
|
||||
|
||||
## Tagesablauf
|
||||
|
||||
Der Rechnungslauf erzeugt die EDI-Nachrichten automatisch um 17:00 Uhr.
|
||||
Danach im ERP unter „EDI-Monitor" prüfen, ob alle Nachrichten den Status
|
||||
„übertragen" haben.
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Bleibt eine Nachricht auf „fehlerhaft" stehen:
|
||||
|
||||
1. Fehlertext im EDI-Monitor öffnen — meist fehlt die Bestellnummer des
|
||||
Kunden auf der Auftragsposition
|
||||
2. Auftrag korrigieren und die Nachricht erneut auslösen
|
||||
3. Bei Übertragungsfehlern (Provider nicht erreichbar) eine Stunde
|
||||
warten, dann erneut senden; danach Ticket beim Provider öffnen
|
||||
|
||||
Unklare Fälle bitte nicht liegen lassen: Bei Bremer & Söhne führt jede
|
||||
verspätete Rechnung zu Skontoabzug-Diskussionen.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
id: fehlercodes-sps-s7
|
||||
title: "Fehlercodes der S7-Steuerung (Halle 2)"
|
||||
department: Engineering
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# Fehlercodes der S7-Steuerung (Halle 2)
|
||||
|
||||
Die Anlagen in Halle 2 melden Störungen über das Panel mit E-Nummern.
|
||||
Die SPS (speicherprogrammierbare Steuerung) schreibt zusätzlich ein
|
||||
Diagnosepuffer-Protokoll, das bei Servicefällen exportiert werden muss.
|
||||
|
||||
## Häufige Fehlercodes
|
||||
|
||||
| Code | Bedeutung | Sofortmaßnahme |
|
||||
|-------|------------------------------------|----------------|
|
||||
| E-101 | Not-Aus-Kreis unterbrochen | Alle Not-Aus-Taster prüfen, dann quittieren |
|
||||
| E-115 | Türkontakt Schutzhaube offen | Haube schließen, Kontakt auf Verschleiß prüfen |
|
||||
| E-203 | Hydraulikdruck unter Sollwert | Aggregat prüfen, siehe Dokument HP-20 |
|
||||
| E-207 | Kühlmitteldurchfluss zu gering | Filter am Kühlmittelkreislauf tauschen |
|
||||
| E-311 | Werkzeugwechsler Timeout | Späne im Greifer? Wechsler im Handbetrieb freifahren |
|
||||
| E-408 | Kommunikation zum Panel gestört | PROFINET-Stecker am Schaltschrank prüfen |
|
||||
|
||||
## Quittieren von Störungen
|
||||
|
||||
Störungen werden am Panel mit der blauen Taste quittiert. E-101 und
|
||||
E-115 sind sicherheitsgerichtet und erfordern zusätzlich die Freigabe
|
||||
durch den Schichtleiter mit dem Schlüsselschalter.
|
||||
|
||||
## Diagnosepuffer exportieren
|
||||
|
||||
1. Am Panel: Menü „Service" → „Diagnose" → „Export USB"
|
||||
2. USB-Stick aus dem Schaltschrank-Fach verwenden (kein privater Stick!)
|
||||
3. Datei an instandhaltung@nordwind-maschinenbau.example mailen
|
||||
|
||||
Bei wiederkehrenden E-203-Meldungen bitte immer auch den Ölstand des
|
||||
Hydraulikaggregats dokumentieren, bevor der Servicetechniker kommt.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: hydraulik-presse-hp20
|
||||
title: "Hydraulikpresse HP-20: Anfahren und Störungen"
|
||||
department: Engineering
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# Hydraulikpresse HP-20: Anfahren und Störungen
|
||||
|
||||
Die HP-20 wird nur von eingewiesenem Personal gefahren. Die Einweisung
|
||||
dokumentiert der Schichtleiter im Schulungsordner.
|
||||
|
||||
## Anfahren nach Stillstand
|
||||
|
||||
1. Hauptschalter ein, Steuerung hochfahren lassen (ca. 90 Sekunden)
|
||||
2. Ölstand am Aggregat prüfen: Schauglas muss zwischen Min und Max stehen
|
||||
3. Pumpe im Leerlauf starten und zwei Minuten warmlaufen lassen
|
||||
4. Probehub ohne Werkstück fahren, Druckanzeige beobachten
|
||||
5. Solldruck 180 bar; Abweichungen über ±10 bar melden
|
||||
|
||||
## Typische Störungen
|
||||
|
||||
### Druck fällt unter Sollwert
|
||||
|
||||
Meldet die Steuerung E-203 (siehe Fehlercode-Liste), zuerst Ölstand
|
||||
prüfen. Häufigste Ursache ist eine undichte Verschraubung an der
|
||||
Druckleitung — Leckagen sofort der Instandhaltung melden, nicht selbst
|
||||
nachziehen, solange die Anlage unter Druck steht.
|
||||
|
||||
### Presse fährt nicht in Grundstellung
|
||||
|
||||
Meist steht der Wahlschalter noch auf „Einrichten". In Stellung
|
||||
„Automatik" bringt die Steuerung den Stößel selbstständig in die
|
||||
Grundstellung.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Der Lichtvorhang darf niemals überbrückt werden, auch nicht beim
|
||||
Einrichten. Für Einrichtbetrieb gibt es den Zustimmtaster am Bedienpult.
|
||||
Jede Manipulation an Sicherheitseinrichtungen ist ein Kündigungsgrund.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: it-onboarding-arbeitsplatz
|
||||
title: "IT-Ausstattung neuer Arbeitsplätze"
|
||||
department: Administration
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# IT-Ausstattung neuer Arbeitsplätze
|
||||
|
||||
Checkliste für die Verwaltung, damit neue Kolleginnen und Kollegen am
|
||||
ersten Tag arbeitsfähig sind. Vorlauf: mindestens zwei Wochen vor
|
||||
Eintritt.
|
||||
|
||||
## Standardausstattung
|
||||
|
||||
- Notebook aus dem Standardwarenkorb (Büro) oder Terminal-Zugang
|
||||
(Fertigung)
|
||||
- Benutzerkonto im Verzeichnisdienst, E-Mail-Postfach
|
||||
- Zugänge: ERP-Rolle laut Abteilungsprofil, CRM nur für den Vertrieb
|
||||
- Telefonnebenstelle bzw. DECT-Gerät in der Fertigung
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. Personalabteilung meldet den Eintritt über das IT-Ticketportal
|
||||
2. IT legt Konto und Postfach an, richtet die ERP-Rolle ein
|
||||
3. Verwaltung bestellt Hardware und bereitet den Arbeitsplatz vor
|
||||
4. Am ersten Tag: Übergabeprotokoll unterschreiben lassen, Einweisung
|
||||
in Passwortrichtlinie und Datenschutz-Grundlagen
|
||||
|
||||
## Zugangskarten
|
||||
|
||||
Zugangskarten erstellt der Empfang. Fertigungsmitarbeiter erhalten
|
||||
zusätzlich die Berechtigung für Halle 1/2 erst nach der
|
||||
Sicherheitsunterweisung durch den Schichtleiter.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: messevorbereitung
|
||||
title: "Messevorbereitung: Checkliste Hannover"
|
||||
department: Sales
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# Messevorbereitung: Checkliste Hannover
|
||||
|
||||
Erfahrungswerte aus den letzten drei Messeauftritten. Verantwortlich ist
|
||||
der Vertriebsinnendienst, Start der Vorbereitung: 16 Wochen vor Messe.
|
||||
|
||||
## 16 bis 8 Wochen vorher
|
||||
|
||||
- Standfläche und Standbau bestätigen (Vertrag prüfen: Strom, Druckluft!)
|
||||
- Exponat festlegen — Abstimmung mit Fertigung, ob die Maschine
|
||||
rechtzeitig aus der Produktion genommen werden kann
|
||||
- Hotelkontingent buchen (Innenstadt ist 12 Monate vorher ausgebucht,
|
||||
Ausweichoption Laatzen)
|
||||
|
||||
## 8 Wochen bis Messebeginn
|
||||
|
||||
- Transport des Exponats mit Spedition Grothe terminieren
|
||||
- Standdienstplan erstellen: immer mindestens ein Techniker am Stand
|
||||
- Gesprächsleitfaden und Preislisten-Auszug drucken (keine vollständigen
|
||||
Preislisten am Stand!)
|
||||
|
||||
## Nach der Messe
|
||||
|
||||
Alle Messekontakte innerhalb von fünf Arbeitstagen im CRM erfassen und
|
||||
mit dem Kennzeichen der Messe versehen. Die Nachverfolgung läuft über
|
||||
den normalen CRM-Wiedervorlagen-Prozess.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: netzwerk-produktions-it
|
||||
title: "Produktions-IT: Netzwerk und Maschinenanbindung"
|
||||
department: Engineering
|
||||
visibility: restricted
|
||||
---
|
||||
|
||||
# Produktions-IT: Netzwerk und Maschinenanbindung
|
||||
|
||||
Vertraulich — Zugriff nur für die Produktions-IT. Enthält
|
||||
Netzwerkstruktur und Zugangsdaten-Speicherorte.
|
||||
|
||||
## Netzsegmente
|
||||
|
||||
Die Produktion ist vom Büronetz vollständig getrennt. Es gibt drei
|
||||
VLANs: Maschinen (nur Maschinensteuerungen), Panels (Bedienpanels und
|
||||
Terminals) und Erfassung (BDE-Terminals der Betriebsdatenerfassung).
|
||||
Übergänge laufen ausschließlich über die Firewall in Schrank R2.
|
||||
|
||||
## Maschinenanbindung
|
||||
|
||||
Neue Maschinen werden über OPC UA angebunden. Der OPC-UA-Server läuft
|
||||
auf dem Edge-Rechner in Halle 2; Zertifikate liegen im Passwort-Tresor
|
||||
der IT (Eintrag „OPC-UA Edge"). Die alte F-500 spricht kein OPC UA und
|
||||
wird über eine serielle Brücke ausgelesen — Finger weg von dem grauen
|
||||
Kasten neben ihrem Schaltschrank.
|
||||
|
||||
## Fernwartung
|
||||
|
||||
Servotec erhält Fernzugriff nur über die Wartungs-VPN mit
|
||||
Einmal-Freischaltung durch die IT. Dauerhafte Fernzugänge sind nicht
|
||||
zulässig und werden von der Firewall geblockt.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: offboarding-krause-instandhaltung
|
||||
title: "Wissenssicherung: Werner Krause (Instandhaltung)"
|
||||
department: Engineering
|
||||
visibility: restricted
|
||||
grants: [Engineering]
|
||||
---
|
||||
|
||||
# Wissenssicherung: Werner Krause (Instandhaltung)
|
||||
|
||||
Werner Krause geht im September 2026 nach 31 Jahren in den Ruhestand.
|
||||
Dieses Dokument fasst sein Erfahrungswissen aus dem Abschlussinterview
|
||||
zusammen.
|
||||
|
||||
## Undokumentierte Eigenheiten der Maschinen
|
||||
|
||||
Die F-350 verliert nach einem Stromausfall gelegentlich die Position der
|
||||
vierten Achse, obwohl die Referenzfahrt fehlerfrei durchläuft. Werner
|
||||
fährt in dem Fall die Achse einmal manuell auf Endlage und wieder
|
||||
zurück, danach stimmt die Position wieder. Servotec kennt das Problem,
|
||||
konnte es aber nie reproduzieren.
|
||||
|
||||
Beim Umbau der HP-20 im Jahr 2019 wurde ein Zwischenring am
|
||||
Stößel eingebaut, der nicht in den Zeichnungen auftaucht. Bei
|
||||
Ersatzteilbestellungen für den Stößel immer zuerst den Ring ausmessen.
|
||||
|
||||
## Lieferanten und Ansprechpartner
|
||||
|
||||
- Servotec: Herr Balke ist der einzige Techniker, der die F-350 wirklich
|
||||
kennt. Bei Terminen explizit nach ihm fragen.
|
||||
- Hydraulik-Ersatzteile: Firma Prüßmann liefert schneller als der
|
||||
Hersteller, Qualität identisch.
|
||||
|
||||
## Was der Nachfolger zuerst lernen sollte
|
||||
|
||||
1. Diagnosepuffer der S7 lesen und exportieren
|
||||
2. Zentralschmierung der Fräsen (Dosierventile reagieren empfindlich
|
||||
auf falsches Öl)
|
||||
3. Den Schichtbuch-Rhythmus: alles, was nicht dokumentiert ist,
|
||||
ist nach zwei Wochen vergessen
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: qualitaetspruefung-wareneingang
|
||||
title: "Qualitätsprüfung im Wareneingang"
|
||||
department: Engineering
|
||||
visibility: public
|
||||
---
|
||||
|
||||
# Qualitätsprüfung im Wareneingang
|
||||
|
||||
Jede Anlieferung durchläuft die Qualitätssicherung (QS), bevor sie ins
|
||||
Lager gebucht wird. Ungeprüfte Ware steht in der gelben Zone und darf
|
||||
nicht entnommen werden.
|
||||
|
||||
## Prüfumfang
|
||||
|
||||
Standardteile prüfen wir nach AQL-Stichprobenplan (Annehmbare
|
||||
Qualitätsgrenzlage, Stufe II). Zeichnungsteile von Neulieferanten werden
|
||||
in den ersten drei Lieferungen zu 100 % gemessen, danach nach
|
||||
Stichprobenplan.
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. Lieferschein mit Bestellung im ERP abgleichen
|
||||
2. Sichtprüfung auf Transportschäden
|
||||
3. Stichprobe ziehen laut AQL-Tabelle (hängt am QS-Arbeitsplatz)
|
||||
4. Messwerte im ERP unter „WE-Prüfung" erfassen
|
||||
5. Bei i.O.: grünes Etikett, Buchung ins Lager
|
||||
6. Bei n.i.O.: Sperrbestand, QS-Meldung an den Einkauf
|
||||
|
||||
## Sonderfreigaben
|
||||
|
||||
Eine Sonderfreigabe gesperrter Ware darf nur die QS-Leitung erteilen,
|
||||
schriftlich im ERP. Mündliche Freigaben gelten nicht — auch nicht,
|
||||
wenn die Fertigung auf das Material wartet.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: rabattrichtlinie
|
||||
title: "Rabatt- und Konditionenrichtlinie"
|
||||
department: Sales
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# Rabatt- und Konditionenrichtlinie
|
||||
|
||||
Gültig ab 01.01.2026, ersetzt alle älteren Regelungen.
|
||||
|
||||
## Rabattstufen Ersatzteile
|
||||
|
||||
- Bis 5 %: eigenverantwortlich durch den Vertriebsmitarbeiter
|
||||
- 5–10 %: Freigabe durch den Vertriebsleiter
|
||||
- Über 10 %: nur mit schriftlicher Freigabe der Geschäftsführung
|
||||
|
||||
## Maschinen und Sondermaschinen
|
||||
|
||||
Für Maschinen gibt es keine Standardrabatte. Preisnachlässe entstehen
|
||||
ausschließlich über den Verhandlungsrahmen, der in der Kalkulation
|
||||
hinterlegt ist.
|
||||
|
||||
## Zahlungsbedingungen
|
||||
|
||||
Standard: 30 % bei Auftrag, 60 % bei Liefermeldung, 10 % nach
|
||||
Inbetriebnahme. Abweichende Zahlungspläne prüft die Buchhaltung auf
|
||||
Bonität, bevor der Vertrag unterschrieben wird.
|
||||
|
||||
## Skonto
|
||||
|
||||
Skonto gewähren wir grundsätzlich nicht. Bestandskunden mit
|
||||
Altverträgen (2 % / 14 Tage) behalten ihre Kondition bis zur nächsten
|
||||
Vertragsverlängerung.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: reisekosten
|
||||
title: "Reisekostenabrechnung"
|
||||
department: Administration
|
||||
visibility: public
|
||||
---
|
||||
|
||||
# Reisekostenabrechnung
|
||||
|
||||
Reisekosten werden monatlich abgerechnet, Abgabefrist ist der 5. des
|
||||
Folgemonats. Später eingereichte Abrechnungen rutschen in den nächsten
|
||||
Lauf.
|
||||
|
||||
## Was wird erstattet
|
||||
|
||||
- Fahrten mit Privat-PKW: 0,30 € pro Kilometer laut Routenplaner
|
||||
- Bahn: 2. Klasse, Tickets über das Firmenkonto im Bahnportal buchen
|
||||
- Hotel: bis 120 € pro Nacht ohne Rückfrage, darüber vorher genehmigen
|
||||
lassen
|
||||
- Verpflegungsmehraufwand: gesetzliche Pauschalen, das Formular rechnet
|
||||
automatisch
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. Formular „Reisekosten" aus dem Intranet verwenden (aktuelle Version!)
|
||||
2. Belege als PDF anhängen — Fotos sind okay, solange sie lesbar sind
|
||||
3. An buchhaltung@nordwind-maschinenbau.example senden
|
||||
4. Erstattung kommt mit der nächsten Gehaltsabrechnung
|
||||
|
||||
## Firmenwagen und Poolfahrzeuge
|
||||
|
||||
Für Poolfahrzeuge wird nur getankt (Tankkarte im Handschuhfach), keine
|
||||
Kilometer abgerechnet. Das Fahrtenbuch im Fahrzeug ist Pflicht und wird
|
||||
von der Verwaltung monatlich geprüft.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: reklamationsprozess
|
||||
title: "Reklamationsprozess (Kundenreklamationen)"
|
||||
department: Sales
|
||||
visibility: public
|
||||
---
|
||||
|
||||
# Reklamationsprozess (Kundenreklamationen)
|
||||
|
||||
Gilt für alle Kundenreklamationen, unabhängig davon, wer sie
|
||||
entgegennimmt. Ziel: Erstantwort an den Kunden innerhalb von 24 Stunden.
|
||||
|
||||
## Ablauf in acht Schritten
|
||||
|
||||
1. **Eingang erfassen**: Reklamation im ERP als Vorgang „REK" anlegen,
|
||||
Kunde, Auftragsnummer und Fehlerbeschreibung erfassen.
|
||||
2. **Eingangsbestätigung**: Der Vertrieb bestätigt dem Kunden den
|
||||
Eingang innerhalb von 24 Stunden mit der REK-Nummer.
|
||||
3. **Ersteinschätzung**: QS bewertet, ob es sich um einen Sachmangel,
|
||||
einen Transportschaden oder einen Bedienfehler handelt.
|
||||
4. **Sofortmaßnahme**: Falls der Kunde stillsteht, entscheidet der
|
||||
Vertriebsleiter über Ersatzlieferung oder Technikereinsatz.
|
||||
5. **Ursachenanalyse**: QS und Fertigung ermitteln die Ursache
|
||||
(5-Why-Methode, Ergebnis im REK-Vorgang dokumentieren).
|
||||
6. **Abstellmaßnahme**: Maßnahme festlegen, Verantwortlichen und
|
||||
Termin eintragen.
|
||||
7. **Kundenantwort**: Der Vertrieb formuliert die Antwort auf Basis der
|
||||
Analyse — keine Rohdaten aus der QS unkommentiert weiterleiten.
|
||||
8. **Wirksamkeitsprüfung**: QS prüft nach drei Monaten, ob die Maßnahme
|
||||
gewirkt hat, und schließt den Vorgang.
|
||||
|
||||
## Zuständigkeiten
|
||||
|
||||
Der Vorgangsverantwortliche ist immer der Vertriebsmitarbeiter des
|
||||
Kunden. QS unterstützt bei Analyse und Bewertung, übernimmt aber nicht
|
||||
die Kundenkommunikation.
|
||||
|
||||
## Eskalation
|
||||
|
||||
Reklamationen mit Stillstand beim Kunden oder Streitwert über 10.000 €
|
||||
gehen sofort an die Geschäftsführung.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: schmierstoffe-wartung
|
||||
title: "Schmierstoffe und Wartungsintervalle"
|
||||
department: Engineering
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# Schmierstoffe und Wartungsintervalle
|
||||
|
||||
Übersicht der freigegebenen Schmierstoffe für alle Maschinen in Halle 1
|
||||
und Halle 2. Nicht gelistete Öle und Fette dürfen ohne Freigabe der
|
||||
Instandhaltung nicht eingesetzt werden — falsche Schmierstoffe sind die
|
||||
häufigste Ursache für Garantieverlust.
|
||||
|
||||
## Freigegebene Schmierstoffe
|
||||
|
||||
- **Getriebeöl GX-220**: Zentralschmierung der CNC-Fräsen (F-350, F-500),
|
||||
Nachfüllintervall monatlich
|
||||
- **Bettbahnöl BB-68**: Führungsbahnen, wöchentlich nach Reinigung
|
||||
- **Hochdruckfett HF-2**: Lagerstellen Hydraulikpresse HP-20,
|
||||
alle 500 Betriebsstunden
|
||||
- **Spindelöl SP-10**: nur durch Servotec bei der Jahreswartung
|
||||
|
||||
## Intervalle je Maschine
|
||||
|
||||
### CNC-Fräse F-350
|
||||
|
||||
Die F-350 hat eine automatische Zentralschmierung; der Behälter wird
|
||||
monatlich mit GX-220 aufgefüllt. Die Führungsbahnen zusätzlich wöchentlich
|
||||
mit BB-68 benetzen. Der vollständige Plan steht im „Wartungsplan
|
||||
CNC-Fräse F-350".
|
||||
|
||||
### Hydraulikpresse HP-20
|
||||
|
||||
Lagerstellen alle 500 Betriebsstunden mit HF-2 abschmieren
|
||||
(Betriebsstundenzähler am Panel). Hydrauliköl wird NICHT von uns
|
||||
gewechselt — Ölwechsel nur durch den Hersteller-Service.
|
||||
|
||||
## Lagerung und Entsorgung
|
||||
|
||||
Schmierstoffe lagern im Gefahrstoffschrank in Halle 1. Altöl kommt in
|
||||
die gekennzeichneten Behälter am Wertstoffplatz; die Entsorgung holt
|
||||
die Firma Reko alle sechs Wochen ab. Sicherheitsdatenblätter hängen am
|
||||
Gefahrstoffschrank aus.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: urlaubsantrag-prozess
|
||||
title: "Urlaubsanträge und Abwesenheiten"
|
||||
department: Administration
|
||||
visibility: public
|
||||
---
|
||||
|
||||
# Urlaubsanträge und Abwesenheiten
|
||||
|
||||
Gilt für alle Beschäftigten. Urlaubsanträge laufen seit 2025 digital
|
||||
über das Personalportal — Papieranträge werden nicht mehr angenommen.
|
||||
|
||||
## Urlaub beantragen
|
||||
|
||||
1. Personalportal → „Abwesenheit" → „Neuer Antrag"
|
||||
2. Zeitraum wählen; das Portal zeigt den Resturlaub automatisch an
|
||||
3. Antrag geht zur Genehmigung an die Führungskraft
|
||||
4. Nach Genehmigung erscheint der Urlaub im Teamkalender
|
||||
|
||||
Anträge für mehr als zwei Wochen am Stück bitte mindestens acht Wochen
|
||||
vorher stellen. In der Fertigung gilt zusätzlich: pro Schicht dürfen
|
||||
maximal zwei Personen gleichzeitig Urlaub haben.
|
||||
|
||||
## Krankmeldung
|
||||
|
||||
Am ersten Krankheitstag bis 9:00 Uhr telefonisch beim Schichtleiter
|
||||
bzw. bei der Führungskraft melden. Die Arbeitsunfähigkeitsbescheinigung
|
||||
kommt elektronisch von der Krankenkasse; ein Papierschein ist nur noch
|
||||
für Privatversicherte nötig.
|
||||
|
||||
## Sonderurlaub
|
||||
|
||||
Sonderurlaub (Umzug, Hochzeit, Todesfall) regelt der Manteltarifvertrag.
|
||||
Im Zweifel vor der Buchung bei der Personalabteilung nachfragen —
|
||||
nachträgliche Umbuchungen sind aufwendig.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: wartungsplan-cnc-f350
|
||||
title: "Wartungsplan CNC-Fräse F-350"
|
||||
department: Engineering
|
||||
visibility: department
|
||||
---
|
||||
|
||||
# Wartungsplan CNC-Fräse F-350
|
||||
|
||||
Die F-350 ist unsere meistgenutzte Fräse in Halle 2. Ausfälle blockieren
|
||||
direkt die Fertigung der Serienteile für Bremer & Söhne. Deshalb gilt der
|
||||
folgende Plan verbindlich; Abweichungen bitte immer im Schichtbuch
|
||||
dokumentieren.
|
||||
|
||||
## Tägliche Kontrolle (Schichtbeginn)
|
||||
|
||||
- Kühlschmierstoff-Stand am Schauglas prüfen (Sollbereich grün)
|
||||
- Späneförderer auf Blockaden kontrollieren
|
||||
- Referenzfahrt durchführen und auf ungewöhnliche Geräusche achten
|
||||
- Absaugung: Filteranzeige darf nicht im roten Bereich stehen
|
||||
|
||||
## Wöchentliche Wartung
|
||||
|
||||
Jeden Freitag in der Spätschicht, Dauer ca. 45 Minuten:
|
||||
|
||||
1. Führungsbahnen mit Pinsel reinigen, danach mit Bettbahnöl benetzen
|
||||
2. Werkzeugaufnahmen (WKZ-Kegel) mit Reinigungskegel abfahren
|
||||
3. Kühlschmierstoff-Konzentration mit dem Refraktometer messen
|
||||
(Soll: 8–10 %)
|
||||
4. Druckluftwartungseinheit: Kondensat ablassen
|
||||
|
||||
## Monatliche Wartung
|
||||
|
||||
### Schmierung
|
||||
|
||||
Die Zentralschmierung versorgt die Linearführungen automatisch, der
|
||||
Vorratsbehälter muss aber monatlich mit Getriebeöl GX-220 aufgefüllt
|
||||
werden. Nur GX-220 verwenden — andere Öle verharzen die Dosierventile.
|
||||
Details zu Schmierstoffen und Freigaben stehen im Dokument
|
||||
„Schmierstoffe und Wartungsintervalle".
|
||||
|
||||
### Geometrieprüfung
|
||||
|
||||
- Rundlaufprüfung an der Spindel mit Messuhr (Toleranz 0,01 mm)
|
||||
- Referenzwerte im Maschinenordner ablegen
|
||||
|
||||
## Jahreswartung (extern)
|
||||
|
||||
Die Jahreswartung führt die Firma Servotec durch (Vertrag NW-2019-114).
|
||||
Termin koordiniert die Arbeitsvorbereitung (AV). Vorher unbedingt den
|
||||
Werkzeugwechsler leerräumen und die Paletten aus dem Speicher nehmen.
|
||||
|
||||
## Ansprechpartner
|
||||
|
||||
Instandhaltung: Werner Krause (bis 09/2026), danach Team Instandhaltung
|
||||
über das Schichtbuch. Ersatzteile bestellt ausschließlich der Einkauf.
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Golden retrieval queries against the fixture corpus.
|
||||
# `expected`: document slugs — a query counts as a hit (recall@5) if ANY
|
||||
# expected document appears in the top 5 results. An empty `expected` list
|
||||
# marks a no-answer case: the corpus contains nothing relevant.
|
||||
|
||||
- query: "Wie oft muss die Zentralschmierung der F-350 aufgefüllt werden?"
|
||||
expected: [wartungsplan-cnc-f350, schmierstoffe-wartung]
|
||||
# Declarative statements must retrieve the same documents as the equivalent
|
||||
# question. The hybrid German full-text component closes the statement-vs-
|
||||
# question gap the pure-cosine similarity path is more sensitive to (notes.md).
|
||||
- query: "Die Zentralschmierung der F-350 muss regelmäßig aufgefüllt werden."
|
||||
expected: [wartungsplan-cnc-f350, schmierstoffe-wartung]
|
||||
- query: "Der Solldruck der Hydraulikpresse ist fest vorgegeben."
|
||||
expected: [hydraulik-presse-hp20]
|
||||
- query: "Welches Öl kommt in die Zentralschmierung der Fräse?"
|
||||
expected: [wartungsplan-cnc-f350, schmierstoffe-wartung]
|
||||
- query: "Was bedeutet Fehlercode E-203?"
|
||||
expected: [fehlercodes-sps-s7, hydraulik-presse-hp20]
|
||||
- query: "Wie exportiere ich den Diagnosepuffer der Steuerung?"
|
||||
expected: [fehlercodes-sps-s7]
|
||||
- query: "Wer muss eine Not-Aus-Störung freigeben?"
|
||||
expected: [fehlercodes-sps-s7]
|
||||
- query: "Welcher Solldruck gilt für die Hydraulikpresse?"
|
||||
expected: [hydraulik-presse-hp20]
|
||||
- query: "Die Presse fährt nicht in die Grundstellung zurück, was tun?"
|
||||
expected: [hydraulik-presse-hp20]
|
||||
- query: "Welche Schmierstoffe sind bei uns freigegeben?"
|
||||
expected: [schmierstoffe-wartung]
|
||||
- query: "Welcher Servotec-Techniker kennt die F-350 am besten?"
|
||||
expected: [offboarding-krause-instandhaltung]
|
||||
- query: "Die vierte Achse verliert nach einem Stromausfall die Position."
|
||||
expected: [offboarding-krause-instandhaltung]
|
||||
- query: "Wie werden neue Maschinen an das Produktionsnetzwerk angebunden?"
|
||||
expected: [netzwerk-produktions-it]
|
||||
- query: "Stichprobenprüfung bei Anlieferungen" # synonym for AQL/Wareneingang
|
||||
expected: [qualitaetspruefung-wareneingang]
|
||||
- query: "Wer darf gesperrte Ware freigeben?"
|
||||
expected: [qualitaetspruefung-wareneingang]
|
||||
- query: "Welche Pflichtfelder müssen im CRM gepflegt werden?"
|
||||
expected: [crm-leitfaden]
|
||||
- query: "Wie schnell muss auf eine Reklamation reagiert werden?"
|
||||
expected: [reklamationsprozess]
|
||||
- query: "Ablauf bei Kundenbeschwerden" # synonym: Beschwerde vs. Reklamation
|
||||
expected: [reklamationsprozess]
|
||||
- query: "Wie hoch ist der Zuschlag für Materialgemeinkosten?"
|
||||
expected: [angebotskalkulation]
|
||||
- query: "Wie lange sind unsere Angebote gültig?"
|
||||
expected: [angebotskalkulation]
|
||||
- query: "Wo übernachten wir während der Messe in Hannover?"
|
||||
expected: [messevorbereitung]
|
||||
- query: "Wie viel Rabatt darf ich auf Ersatzteile geben?"
|
||||
expected: [rabattrichtlinie]
|
||||
- query: "Wie beantrage ich Urlaub?"
|
||||
expected: [urlaubsantrag-prozess]
|
||||
- query: "Kilometerpauschale für Dienstfahrten mit dem Privatauto" # synonym: PKW
|
||||
expected: [reisekosten]
|
||||
- query: "elektronischer Datenaustausch von Rechnungen mit Großkunden" # spelled-out EDI
|
||||
expected: [edi-rechnungen]
|
||||
- query: "Was muss vor dem ersten Arbeitstag eines neuen Mitarbeiters vorbereitet werden?"
|
||||
expected: [it-onboarding-arbeitsplatz]
|
||||
- query: "Wie melde ich eine Datenpanne?"
|
||||
expected: [datenschutz-grundlagen]
|
||||
|
||||
# --- no-answer cases: nothing in the corpus covers these ---
|
||||
- query: "Wie konfiguriere ich den Farblaserdrucker im dritten Obergeschoss?"
|
||||
expected: []
|
||||
- query: "Welche Gerichte gibt es in der Kantine für Veganer?"
|
||||
expected: []
|
||||
- query: "Wie stelle ich den Beamer im Konferenzraum scharf?"
|
||||
expected: []
|
||||
- query: "Gibt es einen Zuschuss zum Deutschlandticket?"
|
||||
expected: []
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
"""Loader for the shared fixture corpus — seeds and tests draw from it.
|
||||
|
||||
The corpus is product content (German knowledge documents of the fictional
|
||||
SME "Nordwind Maschinenbau GmbH"). PyYAML is available through
|
||||
uvicorn[standard]; it becomes a declared dependency with the template
|
||||
import in M6.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent
|
||||
CORPUS_DIR = FIXTURES_DIR / "corpus"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorpusDoc:
|
||||
slug: str
|
||||
title: str
|
||||
department: str
|
||||
visibility: str
|
||||
content_md: str
|
||||
grants: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_corpus() -> list[CorpusDoc]:
|
||||
docs: list[CorpusDoc] = []
|
||||
for path in sorted(CORPUS_DIR.glob("*.md")):
|
||||
text = path.read_text()
|
||||
if not text.startswith("---\n"):
|
||||
raise ValueError(f"corpus file without frontmatter: {path.name}")
|
||||
_, frontmatter, body = text.split("---\n", 2)
|
||||
meta = yaml.safe_load(frontmatter)
|
||||
docs.append(
|
||||
CorpusDoc(
|
||||
slug=meta["id"],
|
||||
title=meta["title"],
|
||||
department=meta["department"],
|
||||
visibility=meta["visibility"],
|
||||
grants=list(meta.get("grants", [])),
|
||||
content_md=body.strip() + "\n",
|
||||
)
|
||||
)
|
||||
return docs
|
||||
|
||||
|
||||
def load_golden_queries() -> list[dict[str, Any]]:
|
||||
return yaml.safe_load((FIXTURES_DIR / "golden_queries.yaml").read_text())
|
||||
|
||||
|
||||
def load_conversation_snippets() -> list[dict[str, Any]]:
|
||||
"""Short chats whose LAST message is a topic-losing follow-up, with the
|
||||
corpus slug the conversation is really about — for comparing topic-summary
|
||||
retrieval against retrieval over the raw last message."""
|
||||
return yaml.safe_load((FIXTURES_DIR / "conversation_snippets.yaml").read_text())
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Self-service password change: prove the old password, keep this session,
|
||||
drop every other one."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.account import PERSONAL_BLUEPRINT
|
||||
from app.models import (
|
||||
AuthSession,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
Template,
|
||||
User,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("fake_llm")
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, password: str = "secret123") -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": "pablo@test.dev", "password": password}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _session_count(db: AsyncSession, user_id) -> int:
|
||||
"""Takes the id, not the ORM object: callers expire the session first,
|
||||
and a detached attribute access would need lazy IO."""
|
||||
return (
|
||||
await db.execute(
|
||||
select(func.count(AuthSession.id)).where(AuthSession.user_id == user_id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def test_change_password_keeps_this_session_and_drops_the_others(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
user_id = seeded_user.id
|
||||
# A second device: log in twice, then change the password on the second.
|
||||
await _login(client)
|
||||
await client.post("/api/auth/logout") # keeps the row count honest below
|
||||
await _login(client)
|
||||
other = await client.post(
|
||||
"/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert other.status_code == 200
|
||||
assert await _session_count(db, user_id) >= 2
|
||||
|
||||
changed = await client.post(
|
||||
"/api/account/password",
|
||||
json={"current_password": "secret123", "new_password": "neues-geheimnis"},
|
||||
)
|
||||
assert changed.status_code == 204
|
||||
|
||||
# The caller stays signed in...
|
||||
assert (await client.get("/api/auth/me")).status_code == 200
|
||||
# ...and is now the only session left.
|
||||
db.expire_all()
|
||||
assert await _session_count(db, user_id) == 1
|
||||
|
||||
# The new password works, the old one does not.
|
||||
await client.post("/api/auth/logout")
|
||||
await _login(client, "neues-geheimnis")
|
||||
await client.post("/api/auth/logout")
|
||||
rejected = await client.post(
|
||||
"/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert rejected.status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_current_password_changes_nothing(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
user_id = seeded_user.id
|
||||
await _login(client)
|
||||
before = await _session_count(db, user_id)
|
||||
|
||||
response = await client.post(
|
||||
"/api/account/password",
|
||||
json={"current_password": "falsch", "new_password": "neues-geheimnis"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "invalid_current_password"
|
||||
|
||||
db.expire_all()
|
||||
assert await _session_count(db, user_id) == before
|
||||
assert (await client.get("/api/auth/me")).status_code == 200
|
||||
|
||||
|
||||
async def test_short_passwords_are_rejected(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client)
|
||||
response = await client.post(
|
||||
"/api/account/password",
|
||||
json={"current_password": "secret123", "new_password": "kurz"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_password_change_requires_a_session(client: AsyncClient) -> None:
|
||||
response = await client.post(
|
||||
"/api/account/password",
|
||||
json={"current_password": "secret123", "new_password": "neues-geheimnis"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_locale_is_pinned_and_cleared(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
"""A pinned language follows the person to every device, so it rides on
|
||||
the user row rather than in browser storage."""
|
||||
await _login(client)
|
||||
assert (await client.get("/api/auth/me")).json()["locale"] is None
|
||||
|
||||
assert (
|
||||
await client.put("/api/account/locale", json={"locale": "de"})
|
||||
).status_code == 204
|
||||
assert (await client.get("/api/auth/me")).json()["locale"] == "de"
|
||||
|
||||
# It survives a new session.
|
||||
await client.post("/api/auth/logout")
|
||||
await _login(client)
|
||||
assert (await client.get("/api/auth/me")).json()["locale"] == "de"
|
||||
|
||||
# null puts it back to following the browser.
|
||||
assert (
|
||||
await client.put("/api/account/locale", json={"locale": None})
|
||||
).status_code == 204
|
||||
assert (await client.get("/api/auth/me")).json()["locale"] is None
|
||||
|
||||
|
||||
async def test_unsupported_locale_is_rejected(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client)
|
||||
assert (
|
||||
await client.put("/api/account/locale", json={"locale": "fr"})
|
||||
).status_code == 422
|
||||
|
||||
|
||||
async def test_setting_a_locale_requires_a_session(client: AsyncClient) -> None:
|
||||
response = await client.put("/api/account/locale", json={"locale": "de"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_the_personal_document_says_what_to_start_from(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
"""Nothing written yet: the profile page gets the blueprint to start from,
|
||||
and no document."""
|
||||
template = Template(
|
||||
name="Onboarding",
|
||||
version="1.0",
|
||||
config={"id": PERSONAL_BLUEPRINT, "name": "Onboarding"},
|
||||
)
|
||||
db.add(template)
|
||||
await db.commit()
|
||||
|
||||
await _login(client)
|
||||
body = (await client.get("/api/account/document")).json()
|
||||
assert body["document_id"] is None
|
||||
assert body["template_id"] == str(template.id)
|
||||
|
||||
|
||||
async def test_the_personal_document_is_found_once_written(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
"""Authorship is the whole rule: your own document from the person
|
||||
blueprint, never one someone else wrote."""
|
||||
mine = Document(
|
||||
title="Onboarding: Pablo",
|
||||
status=DocumentStatus.draft,
|
||||
visibility=DocumentVisibility.department,
|
||||
content_md="## Rolle",
|
||||
meta={"template": PERSONAL_BLUEPRINT},
|
||||
author_id=seeded_user.id,
|
||||
)
|
||||
other = Document(
|
||||
title="Onboarding: jemand anders",
|
||||
status=DocumentStatus.published,
|
||||
visibility=DocumentVisibility.public,
|
||||
content_md="## Rolle",
|
||||
meta={"template": PERSONAL_BLUEPRINT},
|
||||
author_id=None,
|
||||
)
|
||||
db.add_all([mine, other])
|
||||
await db.commit()
|
||||
|
||||
await _login(client)
|
||||
body = (await client.get("/api/account/document")).json()
|
||||
assert body["document_id"] == str(mine.id)
|
||||
assert body["title"] == "Onboarding: Pablo"
|
||||
assert body["status"] == "draft"
|
||||
|
||||
|
||||
async def test_the_personal_document_requires_a_session(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/account/document")).status_code == 401
|
||||
@@ -0,0 +1,83 @@
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import User
|
||||
from tests.fake_openai import FakeOpenAI
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_llm_test_requires_authentication(client: AsyncClient) -> None:
|
||||
response = await client.post("/api/admin/llm/test")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_llm_test_requires_admin_role(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client, "pablo@test.dev")
|
||||
response = await client.post("/api/admin/llm/test")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["code"] == "forbidden"
|
||||
|
||||
|
||||
async def test_llm_test_reports_all_roles_healthy(
|
||||
client: AsyncClient, seeded_admin: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
response = await client.post("/api/admin/llm/test")
|
||||
assert response.status_code == 200
|
||||
roles = {entry["role"]: entry for entry in response.json()["roles"]}
|
||||
assert set(roles) == {"chat", "utility", "embedding"}
|
||||
for entry in roles.values():
|
||||
assert entry["ok"] is True
|
||||
assert entry["error"] is None
|
||||
assert isinstance(entry["latency_ms"], int)
|
||||
assert entry["model"]
|
||||
assert entry["base_url"]
|
||||
|
||||
|
||||
async def test_llm_test_reports_broken_roles(
|
||||
client: AsyncClient, seeded_admin: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
# Both chat-completion roles fail; embeddings stay healthy. Four
|
||||
# errors: two roles x one SDK retry each.
|
||||
fake_llm.chat_responses.extend([{"status": 500}] * 4)
|
||||
await _login(client, "florian@test.dev")
|
||||
response = await client.post("/api/admin/llm/test")
|
||||
roles = {entry["role"]: entry for entry in response.json()["roles"]}
|
||||
assert roles["chat"]["ok"] is False
|
||||
assert "chat_stream failed" in roles["chat"]["error"]
|
||||
assert "induced failure" not in roles["chat"]["error"]
|
||||
assert roles["utility"]["ok"] is False
|
||||
assert roles["embedding"]["ok"] is True
|
||||
|
||||
|
||||
async def test_metrics_endpoint_reflects_llm_calls(
|
||||
client: AsyncClient, seeded_admin: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
await client.post("/api/admin/llm/test")
|
||||
|
||||
response = await client.get("/api/admin/metrics")
|
||||
assert response.status_code == 200
|
||||
snapshot = response.json()
|
||||
counted_roles = {
|
||||
entry["labels"]["role"]
|
||||
for entry in snapshot["counters"]["llm_calls_total"]
|
||||
if entry["labels"]["status"] == "ok"
|
||||
}
|
||||
assert counted_roles == {"chat", "utility", "embedding"}
|
||||
assert "llm_call_seconds" in snapshot["histograms"]
|
||||
|
||||
|
||||
async def test_metrics_endpoint_requires_admin(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client, "pablo@test.dev")
|
||||
response = await client.get("/api/admin/metrics")
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,339 @@
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import (
|
||||
AuthSession,
|
||||
Conversation,
|
||||
ConversationMode,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
Message,
|
||||
MessageRole,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_users_crud_requires_admin(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client, "pablo@test.dev")
|
||||
assert (await client.get("/api/admin/users")).status_code == 403
|
||||
|
||||
|
||||
async def test_full_user_lifecycle(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
|
||||
department = (
|
||||
await client.post("/api/admin/departments", json={"name": "QS"})
|
||||
).json()
|
||||
created = await client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "quinn@test.dev",
|
||||
"name": "Quinn Test",
|
||||
"role": "member",
|
||||
"department_id": department["id"],
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
user_id = created.json()["id"]
|
||||
|
||||
# Duplicate email → 409.
|
||||
duplicate = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": "quinn@test.dev", "name": "X", "password": "secret123"},
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
assert duplicate.json()["code"] == "email_taken"
|
||||
|
||||
# The new user can log in.
|
||||
await client.post("/api/auth/logout")
|
||||
await _login(client, "quinn@test.dev")
|
||||
me = (await client.get("/api/auth/me")).json()
|
||||
assert me["department_id"] == department["id"]
|
||||
|
||||
# Password reset by the admin invalidates the old password.
|
||||
await client.post("/api/auth/logout")
|
||||
await _login(client, "florian@test.dev")
|
||||
reset = await client.patch(
|
||||
f"/api/admin/users/{user_id}", json={"password": "new-secret-1"}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
await client.post("/api/auth/logout")
|
||||
old_login = await client.post(
|
||||
"/api/auth/login", json={"email": "quinn@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert old_login.status_code == 401
|
||||
new_login = await client.post(
|
||||
"/api/auth/login", json={"email": "quinn@test.dev", "password": "new-secret-1"}
|
||||
)
|
||||
assert new_login.status_code == 200
|
||||
|
||||
# Deleting the user keeps their documents, authorless.
|
||||
document = Document(
|
||||
title="Bleibt",
|
||||
status=DocumentStatus.published,
|
||||
visibility="public",
|
||||
content_md="# Bleibt",
|
||||
author_id=user_id,
|
||||
)
|
||||
db.add(document)
|
||||
await db.commit()
|
||||
|
||||
await client.post("/api/auth/logout")
|
||||
await _login(client, "florian@test.dev")
|
||||
assert (await client.delete(f"/api/admin/users/{user_id}")).status_code == 204
|
||||
db.expire_all()
|
||||
survivor = (
|
||||
await db.execute(select(Document).where(Document.title == "Bleibt"))
|
||||
).scalar_one()
|
||||
assert survivor.author_id is None
|
||||
|
||||
|
||||
async def test_admin_cannot_delete_or_demote_self(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
me = (await client.get("/api/auth/me")).json()
|
||||
delete = await client.delete(f"/api/admin/users/{me['id']}")
|
||||
assert delete.status_code == 409
|
||||
assert delete.json()["code"] == "self_modification"
|
||||
demote = await client.patch(f"/api/admin/users/{me['id']}", json={"role": "member"})
|
||||
assert demote.status_code == 409
|
||||
|
||||
|
||||
async def test_department_crud_and_conflicts(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
created = (
|
||||
await client.post("/api/admin/departments", json={"name": "Montage"})
|
||||
).json()
|
||||
|
||||
conflict = await client.post("/api/admin/departments", json={"name": "Montage"})
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["code"] == "name_taken"
|
||||
|
||||
renamed = await client.patch(
|
||||
f"/api/admin/departments/{created['id']}", json={"name": "Endmontage"}
|
||||
)
|
||||
assert renamed.json()["name"] == "Endmontage"
|
||||
|
||||
# Any authenticated user can list departments for pickers.
|
||||
listing = (await client.get("/api/departments")).json()
|
||||
assert "Endmontage" in [d["name"] for d in listing]
|
||||
|
||||
assert (
|
||||
await client.delete(f"/api/admin/departments/{created['id']}")
|
||||
).status_code == 204
|
||||
|
||||
|
||||
async def test_deleting_a_department_in_use_needs_confirmation(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
"""A department with members/documents/grants cannot be silently deleted:
|
||||
the CASCADE would drop its shared-access grants unseen, so it takes an
|
||||
explicit confirm."""
|
||||
await _login(client, "florian@test.dev")
|
||||
dept = (
|
||||
await client.post("/api/admin/departments", json={"name": "Vertrieb"})
|
||||
).json()
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "neu@test.dev",
|
||||
"name": "Neu",
|
||||
"role": "member",
|
||||
"department_id": dept["id"],
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
|
||||
blocked = await client.delete(f"/api/admin/departments/{dept['id']}")
|
||||
assert blocked.status_code == 409
|
||||
assert blocked.json()["code"] == "department_in_use"
|
||||
|
||||
confirmed = await client.delete(
|
||||
f"/api/admin/departments/{dept['id']}", params={"confirm": "true"}
|
||||
)
|
||||
assert confirmed.status_code == 204
|
||||
|
||||
|
||||
async def test_password_reset_revokes_all_sessions(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User
|
||||
) -> None:
|
||||
# Pablo logs in — her session row exists and her cookie is captured.
|
||||
await _login(client, "pablo@test.dev")
|
||||
anna_cookie = client.cookies.get("pablan_session")
|
||||
assert anna_cookie is not None
|
||||
|
||||
# Admin (same client, new cookie) resets Pablo's password.
|
||||
await _login(client, "florian@test.dev")
|
||||
reset = await client.patch(
|
||||
f"/api/admin/users/{seeded_user.id}", json={"password": "brand-new-pw1"}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
|
||||
# Pablo's old session is dead ...
|
||||
remaining = (
|
||||
await db.execute(
|
||||
select(func.count(AuthSession.id)).where(
|
||||
AuthSession.user_id == seeded_user.id
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert remaining == 0
|
||||
client.cookies.clear()
|
||||
client.cookies.set("pablan_session", anna_cookie)
|
||||
assert (await client.get("/api/auth/me")).status_code == 401
|
||||
|
||||
# ... while the acting admin's session survived.
|
||||
await _login(client, "florian@test.dev")
|
||||
assert (await client.get("/api/auth/me")).status_code == 200
|
||||
|
||||
|
||||
async def test_admin_changing_own_password_logs_themselves_out(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
me = (await client.get("/api/auth/me")).json()
|
||||
reset = await client.patch(
|
||||
f"/api/admin/users/{me['id']}", json={"password": "next-password-1"}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
assert (await client.get("/api/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_user_delete_cascades_conversations_but_not_documents(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User
|
||||
) -> None:
|
||||
"""GDPR offboarding: transcripts are personal data and go with the user;
|
||||
the approved document is the legitimate artifact and stays."""
|
||||
conversation = Conversation(mode=ConversationMode.query, user_id=seeded_user.id)
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
db.add(
|
||||
Message(
|
||||
conversation_id=conversation.id,
|
||||
role=MessageRole.user,
|
||||
content="personal transcript",
|
||||
)
|
||||
)
|
||||
document = Document(
|
||||
title="Artefakt",
|
||||
status=DocumentStatus.published,
|
||||
visibility="public",
|
||||
content_md="# Artefakt",
|
||||
author_id=seeded_user.id,
|
||||
)
|
||||
db.add(document)
|
||||
await db.commit()
|
||||
|
||||
await _login(client, "florian@test.dev")
|
||||
assert (
|
||||
await client.delete(f"/api/admin/users/{seeded_user.id}")
|
||||
).status_code == 204
|
||||
|
||||
db.expire_all()
|
||||
assert (await db.execute(select(func.count(Conversation.id)))).scalar_one() == 0
|
||||
assert (await db.execute(select(func.count(Message.id)))).scalar_one() == 0
|
||||
survivor = (
|
||||
await db.execute(select(Document).where(Document.title == "Artefakt"))
|
||||
).scalar_one()
|
||||
assert survivor.author_id is None
|
||||
|
||||
|
||||
async def test_admin_can_correct_a_users_email(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""A typo in an address used to mean deleting the person and losing
|
||||
everything hanging off their id."""
|
||||
await _login(client, "florian@test.dev")
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "tpyo@pablan.dev",
|
||||
"name": "Typo",
|
||||
"role": "member",
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
|
||||
patched = await client.patch(
|
||||
f"/api/admin/users/{created['id']}",
|
||||
json={"email": " Fixed@Pablan.DEV ", "name": "Fixed"},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
# Normalised exactly as creation does, or login would stop finding them.
|
||||
assert patched.json()["email"] == "fixed@pablan.dev"
|
||||
assert patched.json()["name"] == "Fixed"
|
||||
|
||||
login = await client.post(
|
||||
"/api/auth/login", json={"email": "fixed@pablan.dev", "password": "secret123"}
|
||||
)
|
||||
assert login.status_code == 200
|
||||
|
||||
|
||||
async def test_an_email_already_in_use_is_refused(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
users = (await client.get("/api/admin/users")).json()["items"]
|
||||
target = next(u for u in users if u["email"] == "pablo@test.dev")
|
||||
|
||||
clash = await client.patch(
|
||||
f"/api/admin/users/{target['id']}", json={"email": "florian@test.dev"}
|
||||
)
|
||||
assert clash.status_code == 409
|
||||
assert clash.json()["code"] == "email_taken"
|
||||
|
||||
|
||||
async def test_the_user_list_pages_and_searches(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User, seeded_user: User
|
||||
) -> None:
|
||||
"""The admin screen is the one place that scales with headcount."""
|
||||
await _login(client, "florian@test.dev")
|
||||
for index in range(6):
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": f"kolleg{index}@pablan.dev",
|
||||
"name": f"Kollege {index}",
|
||||
"role": "member",
|
||||
"password": "secret123",
|
||||
},
|
||||
)
|
||||
|
||||
first = (await client.get("/api/admin/users", params={"per_page": 3})).json()
|
||||
assert len(first["items"]) == 3
|
||||
assert first["total"] >= 8
|
||||
second = (
|
||||
await client.get("/api/admin/users", params={"per_page": 3, "page": 2})
|
||||
).json()
|
||||
assert {u["id"] for u in first["items"]}.isdisjoint(
|
||||
u["id"] for u in second["items"]
|
||||
)
|
||||
|
||||
# Search covers both columns, because an admin looking for someone knows
|
||||
# one of the two and rarely which.
|
||||
by_name = (
|
||||
await client.get("/api/admin/users", params={"search": "Kollege 4"})
|
||||
).json()
|
||||
assert [u["email"] for u in by_name["items"]] == ["kolleg4@pablan.dev"]
|
||||
by_email = (
|
||||
await client.get("/api/admin/users", params={"search": "kolleg4@"})
|
||||
).json()
|
||||
assert by_email["total"] == 1
|
||||
@@ -0,0 +1,125 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.passwords import hash_password, verify_password
|
||||
from app.auth.sessions import COOKIE_NAME
|
||||
from app.models import AuthSession, User
|
||||
|
||||
|
||||
def test_password_hash_roundtrip() -> None:
|
||||
hashed = hash_password("secret123")
|
||||
assert hashed != "secret123"
|
||||
assert verify_password(hashed, "secret123")
|
||||
assert not verify_password(hashed, "wrong")
|
||||
assert not verify_password("not-a-hash", "secret123")
|
||||
|
||||
|
||||
async def _session_count(db: AsyncSession) -> int:
|
||||
return (await db.execute(select(func.count(AuthSession.id)))).scalar_one()
|
||||
|
||||
|
||||
async def test_login_success_sets_cookie_and_session(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "pablo@test.dev", "password": "secret123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["email"] == "pablo@test.dev"
|
||||
assert body["role"] == "member"
|
||||
assert COOKIE_NAME in response.cookies
|
||||
assert await _session_count(db) == 1
|
||||
|
||||
|
||||
async def test_login_normalizes_email(client: AsyncClient, seeded_user: User) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": " PABLO@test.dev ", "password": "secret123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_login_wrong_password(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "pablo@test.dev", "password": "wrong"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Invalid email or password.",
|
||||
"code": "invalid_credentials",
|
||||
}
|
||||
assert await _session_count(db) == 0
|
||||
|
||||
|
||||
async def test_login_unknown_email_same_error(client: AsyncClient) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "ghost@test.dev", "password": "secret123"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "invalid_credentials"
|
||||
|
||||
|
||||
async def test_me_without_cookie(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "not_authenticated"
|
||||
|
||||
|
||||
async def test_me_with_garbage_cookie(client: AsyncClient) -> None:
|
||||
client.cookies.set(COOKIE_NAME, "not-a-uuid")
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_me_after_login(client: AsyncClient, seeded_user: User) -> None:
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "pablo@test.dev", "password": "secret123"},
|
||||
)
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == str(seeded_user.id)
|
||||
|
||||
|
||||
async def test_logout_deletes_session(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "pablo@test.dev", "password": "secret123"},
|
||||
)
|
||||
assert await _session_count(db) == 1
|
||||
|
||||
response = await client.post("/api/auth/logout")
|
||||
assert response.status_code == 204
|
||||
assert await _session_count(db) == 0
|
||||
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_expired_session_rejected(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
session = AuthSession(
|
||||
id=uuid.uuid4(),
|
||||
user_id=seeded_user.id,
|
||||
expires_at=datetime.now(UTC) - timedelta(minutes=1),
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
|
||||
client.cookies.set(COOKIE_NAME, str(session.id))
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == "not_authenticated"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for the active-section boundary — the authoritative computation
|
||||
the refinement endpoint uses (client mirrors it only for a visual hint)."""
|
||||
|
||||
from app.authoring.sections import active_section, slice_lines
|
||||
|
||||
DOC = """## Zweck
|
||||
Dieser Ablauf beschreibt die Rechnungsstellung.
|
||||
|
||||
## Ablauf
|
||||
Erst exportieren, dann versenden.
|
||||
|
||||
## Fallstricke
|
||||
"""
|
||||
|
||||
|
||||
def _span(md: str, cursor_line: int) -> tuple[int, int]:
|
||||
section = active_section(md, cursor_line)
|
||||
return section.start_line, section.end_line
|
||||
|
||||
|
||||
def test_cursor_in_a_section_selects_from_its_heading_to_the_next() -> None:
|
||||
# Line 5 is "Erst exportieren, dann versenden." under "## Ablauf" (line 4).
|
||||
assert _span(DOC, 5) == (4, 5)
|
||||
_prefix, section, _suffix = slice_lines(DOC, 4, 5)
|
||||
assert section == "## Ablauf\nErst exportieren, dann versenden."
|
||||
|
||||
|
||||
def test_cursor_on_the_heading_selects_that_section() -> None:
|
||||
assert _span(DOC, 1) == (1, 2)
|
||||
|
||||
|
||||
def test_trailing_blank_lines_are_excluded() -> None:
|
||||
# "## Ablauf" body is followed by a blank line before "## Fallstricke";
|
||||
# the blank must not be part of the section.
|
||||
start, end = _span(DOC, 4)
|
||||
assert (start, end) == (4, 5)
|
||||
|
||||
|
||||
def test_content_before_the_first_heading_is_its_own_section() -> None:
|
||||
md = "Eine Einleitung ohne Überschrift.\n\n## Danach\nText."
|
||||
assert _span(md, 1) == (1, 1)
|
||||
|
||||
|
||||
def test_a_document_without_headings_is_one_section() -> None:
|
||||
md = "Nur Fließtext.\nZweite Zeile."
|
||||
assert _span(md, 2) == (1, 2)
|
||||
|
||||
|
||||
def test_nested_headings_stop_at_a_same_or_higher_level() -> None:
|
||||
md = "## A\natext\n### A1\nsub\n## B\nbtext"
|
||||
# Cursor in "## A" (line 1) spans through its subsection "### A1" up to
|
||||
# the line before "## B".
|
||||
assert _span(md, 1) == (1, 4)
|
||||
# Cursor in the subsection spans only the subsection.
|
||||
assert _span(md, 4) == (3, 4)
|
||||
|
||||
|
||||
def test_a_heading_inside_a_code_fence_is_not_a_boundary() -> None:
|
||||
md = "## Code\n```\n## nicht echt\n```\nfertig"
|
||||
assert _span(md, 3) == (1, 5)
|
||||
|
||||
|
||||
def test_a_large_section_narrows_to_the_paragraph_at_the_cursor() -> None:
|
||||
big = "\n\n".join(f"Absatz {i} " + "x" * 400 for i in range(6))
|
||||
md = f"## Groß\n{big}"
|
||||
start, end = _span(md, 6) # somewhere deep in the section
|
||||
_prefix, section, _suffix = slice_lines(md, start, end)
|
||||
# Narrowed: a single paragraph, not the whole oversized section.
|
||||
assert "\n\n" not in section
|
||||
assert section.startswith("Absatz")
|
||||
@@ -0,0 +1,57 @@
|
||||
from app.rag.chunking import TARGET_CHUNK_CHARS, chunk_markdown
|
||||
|
||||
|
||||
def test_heading_paths_follow_hierarchy() -> None:
|
||||
md = (
|
||||
"Intro before any heading.\n\n"
|
||||
"## Wartung\n\nWöchentlich schmieren.\n\n"
|
||||
"### Schmierstoffe\n\nNur GX-220 verwenden.\n\n"
|
||||
"## Sicherheit\n\nLichtvorhang nie überbrücken.\n"
|
||||
)
|
||||
chunks = chunk_markdown(md, "Maschinenhandbuch")
|
||||
paths = [chunk.heading_path for chunk in chunks]
|
||||
assert paths == [
|
||||
"Maschinenhandbuch",
|
||||
"Maschinenhandbuch › Wartung",
|
||||
"Maschinenhandbuch › Wartung › Schmierstoffe",
|
||||
"Maschinenhandbuch › Sicherheit",
|
||||
]
|
||||
assert "GX-220" in chunks[2].content
|
||||
|
||||
|
||||
def test_leading_h1_equal_to_title_is_not_duplicated() -> None:
|
||||
md = "# Handbuch\n\nText direkt unter dem Titel.\n\n## Details\n\nMehr.\n"
|
||||
chunks = chunk_markdown(md, "Handbuch")
|
||||
assert chunks[0].heading_path == "Handbuch"
|
||||
assert chunks[1].heading_path == "Handbuch › Details"
|
||||
|
||||
|
||||
def test_oversized_section_is_split_at_paragraphs() -> None:
|
||||
paragraph = "Absatz mit ausreichend vielen Wörtern für den Test. " * 20
|
||||
md = "## Lang\n\n" + "\n\n".join([paragraph] * 5)
|
||||
chunks = chunk_markdown(md, "Doc")
|
||||
assert len(chunks) > 1
|
||||
assert all(len(chunk.content) <= TARGET_CHUNK_CHARS + 100 for chunk in chunks)
|
||||
assert all(chunk.heading_path == "Doc › Lang" for chunk in chunks)
|
||||
|
||||
|
||||
def test_code_fences_are_never_split() -> None:
|
||||
fence = "```\n" + "\n\n".join(["zeile eins", "zeile zwei", "zeile drei"]) + "\n```"
|
||||
filler = "Wort " * 500
|
||||
md = f"## Code\n\n{filler}\n\n{fence}\n\n{filler}"
|
||||
chunks = chunk_markdown(md, "Doc")
|
||||
fenced = [chunk for chunk in chunks if "```" in chunk.content]
|
||||
for chunk in fenced:
|
||||
assert chunk.content.count("```") % 2 == 0, "chunk split inside a fence"
|
||||
|
||||
|
||||
def test_heading_inside_fence_is_not_a_section() -> None:
|
||||
md = "## Skript\n\n```\n# kein heading, nur ein Kommentar\necho hi\n```\n"
|
||||
chunks = chunk_markdown(md, "Doc")
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].heading_path == "Doc › Skript"
|
||||
|
||||
|
||||
def test_empty_document_yields_no_chunks() -> None:
|
||||
assert chunk_markdown("", "Leer") == []
|
||||
assert chunk_markdown("\n\n \n", "Leer") == []
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Conversations API + query mode, end to end against fakes.
|
||||
|
||||
fake_llm scripts the chat completions, fake_embed the vectors — the SSE
|
||||
contract, persistence rules and permission scoping are what is under test.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.conversations import stream_turn
|
||||
from app.log import JsonFormatter, apply_content_log_guard
|
||||
from app.models import (
|
||||
Conversation,
|
||||
ConversationMode,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
Message,
|
||||
MessageRole,
|
||||
User,
|
||||
)
|
||||
from app.modes import get_mode
|
||||
from app.rag.indexing import reindex_document
|
||||
from tests.fake_openai import FakeOpenAI
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("fake_llm", "fake_embed")
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str = "pablo@test.dev") -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _indexed_public_doc(db: AsyncSession, author: User) -> Document:
|
||||
document = Document(
|
||||
title="Kaffeemaschine",
|
||||
status=DocumentStatus.published,
|
||||
visibility=DocumentVisibility.public,
|
||||
content_md="## Pflege\n\nDie Kaffeemaschine wird freitags entkalkt.",
|
||||
author_id=author.id,
|
||||
department_id=author.department_id,
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
await reindex_document(db, document)
|
||||
await db.commit()
|
||||
return document
|
||||
|
||||
|
||||
async def _create_conversation(client: AsyncClient) -> str:
|
||||
response = await client.post("/api/conversations", json={"mode": "query"})
|
||||
assert response.status_code == 200
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def _parse_sse(text: str) -> list[tuple[str, str]]:
|
||||
events = []
|
||||
for frame in text.split("\n\n"):
|
||||
if not frame.strip():
|
||||
continue
|
||||
lines = dict(line.split(": ", 1) for line in frame.splitlines() if ": " in line)
|
||||
events.append((lines["event"], lines["data"]))
|
||||
return events
|
||||
|
||||
|
||||
async def test_create_and_list(client: AsyncClient, seeded_user: User) -> None:
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
|
||||
listing = (await client.get("/api/conversations")).json()
|
||||
assert [c["id"] for c in listing] == [conversation_id]
|
||||
assert listing[0]["title"] is None # no messages yet
|
||||
|
||||
|
||||
async def test_unregistered_mode_rejected(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client)
|
||||
# insight stays unregistered until the EE module provides it.
|
||||
response = await client.post("/api/conversations", json={"mode": "insight"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["code"] == "unknown_mode"
|
||||
|
||||
|
||||
async def test_conversations_are_owner_scoped(
|
||||
client: AsyncClient, seeded_user: User, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
foreign_id = await _create_conversation(client)
|
||||
await client.post("/api/auth/logout")
|
||||
|
||||
await _login(client)
|
||||
assert (await client.get("/api/conversations")).json() == []
|
||||
assert (await client.get(f"/api/conversations/{foreign_id}")).status_code == 404
|
||||
assert (await client.delete(f"/api/conversations/{foreign_id}")).status_code == 404
|
||||
|
||||
|
||||
async def test_turn_streams_sources_tokens_done_and_persists(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
await _indexed_public_doc(db, seeded_user)
|
||||
fake_llm.chat_responses.append({"chunks": ["Frei", "tags."]})
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
|
||||
# All content words exist in the document — websearch_to_tsquery ANDs
|
||||
# terms, and fake embeddings carry no semantics (only FTS can match).
|
||||
response = await client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"content": "Kaffeemaschine entkalkt?"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
|
||||
events = _parse_sse(response.text)
|
||||
kinds = [kind for kind, _ in events]
|
||||
# searching → results → sources → answering → tokens → done
|
||||
assert kinds[:4] == ["state", "state", "sources", "state"]
|
||||
assert kinds.count("token") == 2
|
||||
assert kinds[-1] == "done"
|
||||
|
||||
states = [json.loads(data) for kind, data in events if kind == "state"]
|
||||
assert [s["phase"] for s in states] == ["searching", "results", "answering"]
|
||||
assert states[1]["count"] == 1
|
||||
# Progress events carry counts only — never the query or any content.
|
||||
for state in states:
|
||||
assert "Kaffeemaschine" not in json.dumps(state)
|
||||
|
||||
sources = json.loads(dict(events)["sources"])["chunks"]
|
||||
assert sources[0]["title"] == "Kaffeemaschine"
|
||||
assert "freitags entkalkt" in sources[0]["excerpt"]
|
||||
|
||||
messages = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert [m.role for m in messages] == [MessageRole.user, MessageRole.assistant]
|
||||
assert messages[1].content == "Freitags."
|
||||
assert str(messages[1].id) in events[-1][1]
|
||||
|
||||
# Cache-friendly structure: this turn's excerpt rides the final user turn,
|
||||
# while the static system prompt stays byte-identical for prompt caching.
|
||||
sent = fake_llm.requests[-1]["messages"]
|
||||
assert "freitags entkalkt" not in sent[0]["content"]
|
||||
assert "freitags entkalkt" in sent[-1]["content"]
|
||||
|
||||
listing = (await client.get("/api/conversations")).json()
|
||||
assert listing[0]["title"] == "Kaffeemaschine entkalkt?"
|
||||
|
||||
# Citations are snapshotted on the message, so they survive a reload.
|
||||
detail = (await client.get(f"/api/conversations/{conversation_id}")).json()
|
||||
persisted = detail["messages"][1]["sources"]
|
||||
assert [source["title"] for source in persisted] == ["Kaffeemaschine"]
|
||||
assert "freitags entkalkt" in persisted[0]["excerpt"]
|
||||
assert detail["messages"][0]["sources"] == [] # user turn
|
||||
|
||||
|
||||
async def test_low_confidence_marks_no_answer(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
"""The knowledge gap is explicit on the wire — the UI turns it into the
|
||||
'capture this now?' invitation."""
|
||||
await _indexed_public_doc(db, seeded_user)
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"content": "Xylophonstimmung Quartalsbericht?"},
|
||||
)
|
||||
events = _parse_sse(response.text)
|
||||
phases = [json.loads(data)["phase"] for kind, data in events if kind == "state"]
|
||||
assert phases == ["searching", "no_answer", "answering"]
|
||||
# No passage is USED to ground the answer, but the retrieved-yet-too-weak
|
||||
# passages are still reported (marked unused) so the "?" inspector can
|
||||
# explain why there was no answer.
|
||||
chunks = json.loads(dict(events)["sources"])["chunks"]
|
||||
assert chunks and all(chunk["used"] is False for chunk in chunks)
|
||||
context_turn = fake_llm.requests[-1]["messages"][-1]["content"]
|
||||
# The model still answers (a refusal on every unmatched question makes
|
||||
# the assistant feel broken) — it just may not invent company facts.
|
||||
assert "nothing relevant" in context_turn
|
||||
assert "Answer anyway" in context_turn
|
||||
|
||||
|
||||
async def test_turn_logs_contain_no_content(
|
||||
client: AsyncClient,
|
||||
db: AsyncSession,
|
||||
seeded_user: User,
|
||||
fake_llm: FakeOpenAI,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Rule 12 for the whole turn: neither the question, the retrieved
|
||||
document text, nor the reply may reach a log line."""
|
||||
await _indexed_public_doc(db, seeded_user)
|
||||
fake_llm.chat_responses.append({"chunks": ["GEHEIM-ANTWORT-88"]})
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
|
||||
apply_content_log_guard()
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
await client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"content": "Kaffeemaschine entkalkt GEHEIM-FRAGE-77?"},
|
||||
)
|
||||
|
||||
formatter = JsonFormatter()
|
||||
rendered = "\n".join(formatter.format(record) for record in caplog.records)
|
||||
assert "turn finished" in rendered # the turn really was logged
|
||||
assert "GEHEIM-FRAGE-77" not in rendered
|
||||
assert "GEHEIM-ANTWORT-88" not in rendered
|
||||
assert "freitags entkalkt" not in rendered # retrieved content / excerpt
|
||||
|
||||
|
||||
async def test_llm_failure_falls_back_to_a_plain_search(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
"""No model, but still a knowledge base: the turn ends as a full-text hit
|
||||
list the user opens themselves. It is persisted like any other reply, so a
|
||||
reload replays it instead of showing an empty assistant turn."""
|
||||
# Twice: the LLM client retries once on server errors.
|
||||
fake_llm.chat_responses.extend([{"status": 500}, {"status": 500}])
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/conversations/{conversation_id}/messages", json={"content": "Hallo?"}
|
||||
)
|
||||
frames = _parse_sse(response.text)
|
||||
kinds = [kind for kind, _ in frames]
|
||||
assert "fallback" in kinds
|
||||
assert "error" not in kinds
|
||||
assert kinds[-1] == "done"
|
||||
fallback = next(json.loads(data) for kind, data in frames if kind == "fallback")
|
||||
assert fallback["code"] == "llm_failed"
|
||||
|
||||
stored = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert [message.role for message in stored] == [
|
||||
MessageRole.user,
|
||||
MessageRole.assistant,
|
||||
]
|
||||
assert stored[-1].content == ""
|
||||
assert stored[-1].meta["fallback"] == "llm_failed"
|
||||
|
||||
# And it survives the round trip, so the frontend can phrase it on reload.
|
||||
detail = (await client.get(f"/api/conversations/{conversation_id}")).json()
|
||||
assert detail["messages"][-1]["fallback"] == "llm_failed"
|
||||
|
||||
|
||||
async def test_a_dead_embedding_endpoint_still_answers(
|
||||
client: AsyncClient,
|
||||
db: AsyncSession,
|
||||
seeded_user: User,
|
||||
fake_llm: FakeOpenAI,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The three roles are configured separately: with no embedding endpoint,
|
||||
retrieval drops to the full-text index and the chat model still answers."""
|
||||
from app.llm.errors import LLMError
|
||||
|
||||
async def _no_endpoint(texts: list[str], *, role: str = "embedding"):
|
||||
raise LLMError(
|
||||
"down",
|
||||
role="embedding",
|
||||
kind="embed",
|
||||
status="error",
|
||||
cause_type="APIConnectionError",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.rag.retrieval.embed", _no_endpoint)
|
||||
fake_llm.chat_responses.append({"chunks": ["Klar", "doch."]})
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/conversations/{conversation_id}/messages", json={"content": "Hallo?"}
|
||||
)
|
||||
kinds = [kind for kind, _ in _parse_sse(response.text)]
|
||||
assert "fallback" not in kinds
|
||||
assert "error" not in kinds
|
||||
assert kinds[-1] == "done"
|
||||
|
||||
|
||||
async def test_delete_conversation_cascades_messages(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
await client.post(
|
||||
f"/api/conversations/{conversation_id}/messages", json={"content": "Hi"}
|
||||
)
|
||||
|
||||
assert (
|
||||
await client.delete(f"/api/conversations/{conversation_id}")
|
||||
).status_code == 204
|
||||
remaining = (await db.execute(select(func.count(Message.id)))).scalar_one()
|
||||
assert remaining == 0
|
||||
|
||||
|
||||
async def test_empty_message_rejected(client: AsyncClient, seeded_user: User) -> None:
|
||||
await _login(client)
|
||||
conversation_id = await _create_conversation(client)
|
||||
response = await client.post(
|
||||
f"/api/conversations/{conversation_id}/messages", json={"content": ""}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_client_abort_persists_partial(
|
||||
db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
"""Stop button: closing the SSE generator mid-stream keeps the tokens
|
||||
that were already delivered."""
|
||||
conversation = Conversation(mode=ConversationMode.query, user_id=seeded_user.id)
|
||||
db.add(conversation)
|
||||
await db.commit()
|
||||
conversation = (
|
||||
await db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == conversation.id)
|
||||
.options(selectinload(Conversation.messages))
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
fake_llm.chat_responses.append({"chunks": ["Teil ", "eins ", "und zwei"]})
|
||||
mode = get_mode("query")
|
||||
assert mode is not None
|
||||
generator = stream_turn(conversation, "Frage?", mode, db)
|
||||
|
||||
token_frames = 0
|
||||
async for frame in generator:
|
||||
if frame.startswith("event: token"):
|
||||
token_frames += 1
|
||||
if token_frames == 2:
|
||||
break
|
||||
await generator.aclose()
|
||||
|
||||
partial = (
|
||||
await db.execute(
|
||||
select(Message.content).where(Message.role == MessageRole.assistant)
|
||||
)
|
||||
).scalar_one()
|
||||
assert partial == "Teil eins "
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
"""The citation popover shows prose, not Markdown source.
|
||||
|
||||
The popover is a small hover surface showing a fragment, so the excerpt is
|
||||
reduced to prose rather than rendered: a cited table would otherwise become
|
||||
a real table squeezed into ~320px, and a cited heading would render at h2
|
||||
size. Clicking the badge opens the document in the side panel, which does
|
||||
render Markdown properly.
|
||||
|
||||
These cases are all real shapes from the fixture corpus.
|
||||
"""
|
||||
|
||||
from app.modes.query import EXCERPT_CHARS
|
||||
from app.modes.query import excerpt as make_excerpt
|
||||
|
||||
|
||||
def test_a_cited_table_reads_as_prose() -> None:
|
||||
"""The worst case, and the one that sent this back for a second pass:
|
||||
the divider row is pure punctuation and survives naive stripping."""
|
||||
excerpt = make_excerpt(
|
||||
"| Code | Bedeutung | Sofortmaßnahme |\n"
|
||||
"|-------|------------|----------------|\n"
|
||||
"| E-101 | Not-Aus-Kreis unterbrochen | Alle Not-Aus-Taster prüfen |"
|
||||
)
|
||||
assert "|" not in excerpt
|
||||
assert "---" not in excerpt
|
||||
assert excerpt.startswith("Code · Bedeutung · Sofortmaßnahme · E-101")
|
||||
|
||||
|
||||
def test_inline_emphasis_loses_its_markers_not_its_words() -> None:
|
||||
for source, expected in [
|
||||
("**Eingang erfassen**: im ERP anlegen.", "Eingang erfassen: im ERP anlegen."),
|
||||
("die Presse muss *drucklos* sein", "die Presse muss drucklos sein"),
|
||||
("__Achtung__ beim Anfahren", "Achtung beim Anfahren"),
|
||||
("setze `max_turns` hoch", "setze max_turns hoch"),
|
||||
("**fett _und_ kursiv**", "fett und kursiv"),
|
||||
]:
|
||||
assert make_excerpt(source) == expected
|
||||
|
||||
|
||||
def test_links_keep_their_text_and_drop_their_target() -> None:
|
||||
excerpt = make_excerpt(
|
||||
"Siehe [das Handbuch](https://intranet.example/doc.pdf) dazu."
|
||||
)
|
||||
assert excerpt == "Siehe das Handbuch dazu."
|
||||
# A URL in a hover preview is noise, and it is also the part a reader
|
||||
# cannot click here anyway.
|
||||
assert "http" not in excerpt
|
||||
|
||||
|
||||
def test_block_markers_and_fences_are_dropped() -> None:
|
||||
excerpt = make_excerpt(
|
||||
"## Wartung\n\n"
|
||||
"- **Getriebeöl GX-220**: monatlich\n"
|
||||
"1. Bettbahnöl wöchentlich\n"
|
||||
"> Wichtig: erst entlüften\n"
|
||||
"```yaml\n"
|
||||
"interval: 30\n"
|
||||
"```"
|
||||
)
|
||||
assert excerpt.startswith("Wartung Getriebeöl GX-220: monatlich")
|
||||
for marker in ("##", "```", "> ", "**"):
|
||||
assert marker not in excerpt
|
||||
|
||||
|
||||
def test_plain_prose_is_left_alone() -> None:
|
||||
source = "Die Kaffeemaschine wird freitags entkalkt."
|
||||
assert make_excerpt(source) == source
|
||||
|
||||
|
||||
def test_underscores_inside_identifiers_survive() -> None:
|
||||
"""`result_document_id` is not italics. Emphasis needs a non-space
|
||||
character on both sides, which is what keeps snake_case intact."""
|
||||
assert make_excerpt("das Feld result_document_id bleibt leer") == (
|
||||
"das Feld result_document_id bleibt leer"
|
||||
)
|
||||
|
||||
|
||||
def test_long_content_is_cut_on_a_word_boundary() -> None:
|
||||
excerpt = make_excerpt("Wartung " * 200)
|
||||
assert len(excerpt) <= EXCERPT_CHARS + 1 # the ellipsis
|
||||
assert excerpt.endswith("…")
|
||||
assert not excerpt.rstrip("…").endswith(" ")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Built-in help documents: imported from files, never editable in the UI."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.help_import import (
|
||||
HelpImportError,
|
||||
import_help_documents,
|
||||
parse_help_document,
|
||||
)
|
||||
from app.models import Document, Job, User
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("fake_embed")
|
||||
|
||||
HELP_FILE = """---
|
||||
key: test-hilfe
|
||||
title: "Testhilfe"
|
||||
---
|
||||
|
||||
# Testhilfe
|
||||
|
||||
So funktioniert es.
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_splits_frontmatter_from_body() -> None:
|
||||
key, title, body = parse_help_document(HELP_FILE)
|
||||
assert (key, title) == ("test-hilfe", "Testhilfe")
|
||||
assert body.startswith("# Testhilfe")
|
||||
|
||||
|
||||
def test_parse_rejects_a_file_without_frontmatter() -> None:
|
||||
with pytest.raises(HelpImportError):
|
||||
parse_help_document("# Just markdown")
|
||||
|
||||
|
||||
async def _import(db: AsyncSession, tmp_path, monkeypatch, text: str) -> int:
|
||||
(tmp_path / "hilfe.md").write_text(text)
|
||||
monkeypatch.setenv("PABLAN_HELP_DIR", str(tmp_path))
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
return await import_help_documents(db)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
async def test_import_is_idempotent_and_reindexes_only_on_change(
|
||||
db: AsyncSession, tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
assert await _import(db, tmp_path, monkeypatch, HELP_FILE) == 1
|
||||
document = (
|
||||
await db.execute(select(Document).where(Document.is_builtin.is_(True)))
|
||||
).scalar_one()
|
||||
assert document.title == "Testhilfe"
|
||||
assert document.status.value == "published"
|
||||
assert document.visibility.value == "public"
|
||||
assert document.author_id is None
|
||||
|
||||
# Unchanged: no second document, no new index job.
|
||||
assert await _import(db, tmp_path, monkeypatch, HELP_FILE) == 0
|
||||
|
||||
# Changed: content is refreshed in place and re-indexed.
|
||||
assert (
|
||||
await _import(
|
||||
db, tmp_path, monkeypatch, HELP_FILE.replace("So funktioniert es.", "Neu.")
|
||||
)
|
||||
== 1
|
||||
)
|
||||
documents = (
|
||||
(await db.execute(select(Document).where(Document.is_builtin.is_(True))))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(documents) == 1
|
||||
assert "Neu." in documents[0].content_md
|
||||
jobs = (await db.execute(select(Job))).scalars().all()
|
||||
assert len(jobs) == 2 # one per actual change
|
||||
|
||||
|
||||
async def test_help_documents_cannot_be_edited_or_deleted(
|
||||
client: AsyncClient,
|
||||
db: AsyncSession,
|
||||
seeded_admin: User,
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Not even an admin may edit them — the next deploy would overwrite it."""
|
||||
await _import(db, tmp_path, monkeypatch, HELP_FILE)
|
||||
document = (
|
||||
await db.execute(select(Document).where(Document.is_builtin.is_(True)))
|
||||
).scalar_one()
|
||||
|
||||
login = await client.post(
|
||||
"/api/auth/login", json={"email": "florian@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert login.status_code == 200
|
||||
|
||||
listed = (await client.get("/api/documents")).json()["items"]
|
||||
entry = next(row for row in listed if row["id"] == str(document.id))
|
||||
assert entry["is_builtin"] is True
|
||||
assert entry["can_edit"] is False
|
||||
|
||||
patched = await client.patch(
|
||||
f"/api/documents/{document.id}", json={"title": "Gekapert"}
|
||||
)
|
||||
assert patched.status_code == 409
|
||||
assert patched.json()["code"] == "builtin_readonly"
|
||||
|
||||
deleted = await client.delete(f"/api/documents/{document.id}")
|
||||
assert deleted.status_code == 409
|
||||
@@ -0,0 +1,152 @@
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from app.ingestion.handlers import INDEX_DOCUMENT, REINDEX_ALL
|
||||
from app.ingestion.queue import enqueue, process_one
|
||||
from app.models import (
|
||||
Chunk,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
Job,
|
||||
JobStatus,
|
||||
User,
|
||||
)
|
||||
from app.rag.indexing import reindex_document
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("fake_embed")
|
||||
|
||||
CONTENT = (
|
||||
"## Wartung\n\nWöchentlich schmieren mit GX-220.\n\n"
|
||||
"## Sicherheit\n\nLichtvorhang niemals überbrücken.\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def _doc(
|
||||
db: AsyncSession,
|
||||
seeded_user: User,
|
||||
*,
|
||||
title: str = "Handbuch",
|
||||
status: DocumentStatus = DocumentStatus.published,
|
||||
) -> Document:
|
||||
document = Document(
|
||||
title=title,
|
||||
status=status,
|
||||
visibility=DocumentVisibility.department,
|
||||
content_md=CONTENT,
|
||||
author_id=seeded_user.id,
|
||||
department_id=seeded_user.department_id,
|
||||
meta={},
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
return document
|
||||
|
||||
|
||||
async def _chunk_count(db: AsyncSession, document_id) -> int:
|
||||
return (
|
||||
await db.execute(
|
||||
select(func.count(Chunk.id)).where(Chunk.document_id == document_id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def test_reindex_creates_chunks_with_meta(
|
||||
db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
document = await _doc(db, seeded_user)
|
||||
count = await reindex_document(db, document)
|
||||
await db.commit()
|
||||
assert count == 2
|
||||
|
||||
chunks = (
|
||||
(await db.execute(select(Chunk).where(Chunk.document_id == document.id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert {chunk.meta["heading_path"] for chunk in chunks} == {
|
||||
"Handbuch › Wartung",
|
||||
"Handbuch › Sicherheit",
|
||||
}
|
||||
for chunk in chunks:
|
||||
assert chunk.meta["visibility"] == "department"
|
||||
assert chunk.meta["department_id"] == str(seeded_user.department_id)
|
||||
|
||||
|
||||
async def test_reindex_is_idempotent(db: AsyncSession, seeded_user: User) -> None:
|
||||
document = await _doc(db, seeded_user)
|
||||
await reindex_document(db, document)
|
||||
await db.commit()
|
||||
first_ids = {
|
||||
chunk.id
|
||||
for chunk in (
|
||||
await db.execute(select(Chunk).where(Chunk.document_id == document.id))
|
||||
).scalars()
|
||||
}
|
||||
|
||||
await reindex_document(db, document)
|
||||
await db.commit()
|
||||
chunks = (
|
||||
(await db.execute(select(Chunk).where(Chunk.document_id == document.id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(chunks) == 2
|
||||
assert first_ids.isdisjoint({chunk.id for chunk in chunks})
|
||||
|
||||
|
||||
async def test_index_job_indexes_published_and_cleans_unpublished(
|
||||
db: AsyncSession,
|
||||
seeded_user: User,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
document = await _doc(db, seeded_user)
|
||||
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
|
||||
await db.commit()
|
||||
assert await process_one(session_factory) is True
|
||||
assert await _chunk_count(db, document.id) == 2
|
||||
|
||||
document.status = DocumentStatus.archived
|
||||
await enqueue(db, INDEX_DOCUMENT, {"document_id": str(document.id)})
|
||||
await db.commit()
|
||||
assert await process_one(session_factory) is True
|
||||
assert await _chunk_count(db, document.id) == 0
|
||||
|
||||
|
||||
async def test_reindex_all_fans_out_and_rebuilds_from_markdown(
|
||||
db: AsyncSession,
|
||||
seeded_user: User,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
published = [await _doc(db, seeded_user, title=f"Doc {i}") for i in range(3)]
|
||||
await _doc(db, seeded_user, title="Entwurf", status=DocumentStatus.draft)
|
||||
# Simulate an embedding-model swap: all derivatives are gone.
|
||||
await db.execute(delete(Chunk))
|
||||
await enqueue(db, REINDEX_ALL)
|
||||
await db.commit()
|
||||
|
||||
# Fan-out: the reindex_all job only enqueues per-document jobs.
|
||||
assert await process_one(session_factory) is True
|
||||
index_jobs = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Job).where(
|
||||
Job.type == INDEX_DOCUMENT, Job.status == JobStatus.pending
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(index_jobs) == 3 # the draft is not indexed
|
||||
|
||||
while await process_one(session_factory):
|
||||
pass
|
||||
for document in published:
|
||||
assert await _chunk_count(db, document.id) == 2
|
||||
@@ -0,0 +1,182 @@
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.llm.client import chat_json, chat_stream, embed
|
||||
from app.llm.errors import LLMError
|
||||
from app.metrics import metrics
|
||||
from tests.fake_openai import FakeOpenAI
|
||||
|
||||
PING = [{"role": "user", "content": "hi"}]
|
||||
|
||||
|
||||
class Verdict(BaseModel):
|
||||
covered: list[str]
|
||||
done: bool
|
||||
|
||||
|
||||
def _counter(name: str, **labels: str) -> float:
|
||||
for entry in metrics.snapshot()["counters"].get(name, []):
|
||||
if entry["labels"] == labels:
|
||||
return entry["value"]
|
||||
return 0.0
|
||||
|
||||
|
||||
async def test_chat_stream_yields_deltas_and_records_metrics(
|
||||
fake_llm: FakeOpenAI,
|
||||
) -> None:
|
||||
fake_llm.chat_responses.append({"chunks": ["Hel", "lo"]})
|
||||
out = [token async for token in chat_stream(PING)]
|
||||
assert out == ["Hel", "lo"]
|
||||
|
||||
body = fake_llm.requests[-1]
|
||||
assert body["stream"] is True
|
||||
assert body["stream_options"] == {"include_usage": True}
|
||||
assert (
|
||||
_counter("llm_calls_total", role="chat", kind="chat_stream", status="ok") == 1
|
||||
)
|
||||
assert _counter("llm_tokens_total", role="chat", direction="completion") == 3
|
||||
|
||||
|
||||
async def test_chat_stream_role_override(fake_llm: FakeOpenAI) -> None:
|
||||
[token async for token in chat_stream(PING, role="utility", max_tokens=1)]
|
||||
assert fake_llm.requests[-1]["max_tokens"] == 1
|
||||
assert (
|
||||
_counter("llm_calls_total", role="utility", kind="chat_stream", status="ok")
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_stream_error_is_sanitized(fake_llm: FakeOpenAI) -> None:
|
||||
# Twice: the client retries once on connection/server errors.
|
||||
fake_llm.chat_responses.extend([{"status": 500}, {"status": 500}])
|
||||
with pytest.raises(LLMError) as excinfo:
|
||||
[token async for token in chat_stream(PING)]
|
||||
error = excinfo.value
|
||||
assert "chat_stream failed" in str(error)
|
||||
# The server error body must not leak into the exception message.
|
||||
assert "induced failure" not in str(error)
|
||||
assert error.role == "chat"
|
||||
assert error.kind == "chat_stream"
|
||||
assert error.status == "error"
|
||||
assert error.status_code == 500
|
||||
assert error.cause_type # original exception class name only
|
||||
assert isinstance(error.duration_ms, int)
|
||||
assert (
|
||||
_counter("llm_calls_total", role="chat", kind="chat_stream", status="error")
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_json_happy_path(fake_llm: FakeOpenAI) -> None:
|
||||
fake_llm.chat_responses.append({"content": '{"covered": ["a"], "done": true}'})
|
||||
result = await chat_json(PING, Verdict)
|
||||
assert result == Verdict(covered=["a"], done=True)
|
||||
|
||||
body = fake_llm.requests[-1]
|
||||
assert body["response_format"]["type"] == "json_schema"
|
||||
assert body["response_format"]["json_schema"]["name"] == "Verdict"
|
||||
assert (
|
||||
_counter("llm_calls_total", role="utility", kind="chat_json", status="ok") == 1
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_json_retries_once_on_invalid_json(fake_llm: FakeOpenAI) -> None:
|
||||
fake_llm.chat_responses.extend(
|
||||
[
|
||||
{"content": "definitely not json"},
|
||||
{"content": '{"covered": [], "done": false}'},
|
||||
]
|
||||
)
|
||||
result = await chat_json(PING, Verdict)
|
||||
assert result.done is False
|
||||
assert len(fake_llm.requests) == 2
|
||||
retry_messages = fake_llm.requests[-1]["messages"]
|
||||
assert retry_messages[-1]["role"] == "user"
|
||||
assert "JSON" in retry_messages[-1]["content"]
|
||||
assert retry_messages[-2] == {"role": "assistant", "content": "definitely not json"}
|
||||
assert (
|
||||
_counter("llm_calls_total", role="utility", kind="chat_json", status="invalid")
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_json_gives_up_after_retry_without_leaking(
|
||||
fake_llm: FakeOpenAI,
|
||||
) -> None:
|
||||
fake_llm.chat_responses.extend(
|
||||
[{"content": "SECRET-A 123"}, {"content": "SECRET-B 456"}]
|
||||
)
|
||||
with pytest.raises(LLMError) as excinfo:
|
||||
await chat_json(PING, Verdict)
|
||||
error = excinfo.value
|
||||
assert "Verdict" in str(error)
|
||||
# Structured debugging metadata is present ...
|
||||
assert error.role == "utility"
|
||||
assert error.kind == "chat_json"
|
||||
assert error.status == "invalid"
|
||||
assert error.cause_type == "ValidationError"
|
||||
assert error.attempt == 2
|
||||
assert error.status_code is None
|
||||
assert isinstance(error.duration_ms, int)
|
||||
# ... and neither message nor ANY metadata field carries content.
|
||||
everything = str(error) + repr(vars(error))
|
||||
assert "SECRET-A" not in everything
|
||||
assert "SECRET-B" not in everything
|
||||
|
||||
|
||||
async def test_chat_json_http_error(fake_llm: FakeOpenAI) -> None:
|
||||
# Twice: the client retries once on connection/server errors.
|
||||
fake_llm.chat_responses.extend([{"status": 503}, {"status": 503}])
|
||||
with pytest.raises(LLMError) as excinfo:
|
||||
await chat_json(PING, Verdict)
|
||||
error = excinfo.value
|
||||
assert error.status == "error"
|
||||
assert error.status_code == 503
|
||||
assert error.attempt == 1
|
||||
assert error.cause_type
|
||||
assert "induced failure" not in str(error) + repr(vars(error))
|
||||
assert (
|
||||
_counter("llm_calls_total", role="utility", kind="chat_json", status="error")
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
async def test_embed_preserves_order(fake_llm: FakeOpenAI) -> None:
|
||||
vectors = await embed(["a", "b", "c"])
|
||||
assert len(vectors) == 3
|
||||
assert vectors[0][0] == 0.0
|
||||
assert vectors[2][0] == 2.0
|
||||
assert len(vectors[0]) == fake_llm.embedding_dim
|
||||
assert _counter("llm_calls_total", role="embedding", kind="embed", status="ok") == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cause_type", "status_code", "expected"),
|
||||
[
|
||||
("APIConnectionError", None, "llm_unreachable"),
|
||||
("ConnectError", None, "llm_unreachable"),
|
||||
# A timeout is the busy case, not the down case: the endpoint took the
|
||||
# request and never came back.
|
||||
("APITimeoutError", None, "llm_busy"),
|
||||
("RateLimitError", 429, "llm_busy"),
|
||||
("APIStatusError", 503, "llm_busy"),
|
||||
("AuthenticationError", 401, "llm_misconfigured"),
|
||||
("NotFoundError", 404, "llm_misconfigured"),
|
||||
("BadRequestError", 400, "llm_failed"),
|
||||
("ValueError", None, "llm_failed"),
|
||||
],
|
||||
)
|
||||
def test_error_code_separates_down_from_busy_from_misconfigured(
|
||||
cause_type: str, status_code: int | None, expected: str
|
||||
) -> None:
|
||||
"""One classification for every surface: retry-in-a-moment, start the
|
||||
endpoint, and fix the config must not read the same to the user."""
|
||||
error = LLMError(
|
||||
"boom",
|
||||
role="chat",
|
||||
kind="chat_stream",
|
||||
status="error",
|
||||
cause_type=cause_type,
|
||||
status_code=status_code,
|
||||
)
|
||||
assert error.code == expected
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Several people asking at once, against an endpoint with a few slots.
|
||||
|
||||
The gate is the only place that bounds how much work reaches an endpoint, so
|
||||
these tests are about the three answers it can give: go, wait, or busy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.llm import gate
|
||||
from app.llm.errors import LLMError
|
||||
|
||||
ENDPOINT = "http://endpoint.test/v1"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_gate() -> Iterator[None]:
|
||||
gate.reset()
|
||||
yield
|
||||
gate.reset()
|
||||
|
||||
|
||||
def _limits(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
parallel: int,
|
||||
wait: float = 20.0,
|
||||
queued: int = 24,
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "llm_max_parallel", parallel)
|
||||
monkeypatch.setattr(settings, "llm_queue_wait_seconds", wait)
|
||||
monkeypatch.setattr(settings, "llm_max_queued", queued)
|
||||
gate.reset()
|
||||
|
||||
|
||||
async def test_only_as_many_calls_reach_the_endpoint_as_it_has_slots(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_limits(monkeypatch, parallel=2)
|
||||
concurrent = 0
|
||||
peak = 0
|
||||
release = asyncio.Event()
|
||||
|
||||
async def call() -> None:
|
||||
nonlocal concurrent, peak
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
concurrent += 1
|
||||
peak = max(peak, concurrent)
|
||||
await release.wait()
|
||||
concurrent -= 1
|
||||
|
||||
tasks = [asyncio.create_task(call()) for _ in range(6)]
|
||||
await asyncio.sleep(0) # let everyone reach the gate
|
||||
assert peak == 2, "more calls were in flight than the endpoint has slots"
|
||||
release.set()
|
||||
await asyncio.gather(*tasks)
|
||||
assert peak == 2
|
||||
|
||||
|
||||
async def test_a_waiting_call_gets_the_slot_the_previous_one_frees(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_limits(monkeypatch, parallel=1)
|
||||
order: list[str] = []
|
||||
first_in = asyncio.Event()
|
||||
let_go = asyncio.Event()
|
||||
|
||||
async def first() -> None:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
order.append("first in")
|
||||
first_in.set()
|
||||
await let_go.wait()
|
||||
order.append("first out")
|
||||
|
||||
async def second() -> None:
|
||||
await first_in.wait()
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
order.append("second in")
|
||||
|
||||
task_one = asyncio.create_task(first())
|
||||
task_two = asyncio.create_task(second())
|
||||
await first_in.wait()
|
||||
await asyncio.sleep(0)
|
||||
assert order == ["first in"], "the second call did not wait"
|
||||
let_go.set()
|
||||
await asyncio.gather(task_one, task_two)
|
||||
assert order == ["first in", "first out", "second in"]
|
||||
|
||||
|
||||
async def test_waiting_longer_than_allowed_is_reported_as_busy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A caller that cannot be served soon is told so, rather than being held
|
||||
until the HTTP timeout makes it look like a broken endpoint."""
|
||||
_limits(monkeypatch, parallel=1, wait=0.05)
|
||||
let_go = asyncio.Event()
|
||||
|
||||
async def holder() -> None:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
await let_go.wait()
|
||||
|
||||
held = asyncio.create_task(holder())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
with pytest.raises(LLMError) as raised:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
pass
|
||||
assert raised.value.code == "llm_busy"
|
||||
|
||||
let_go.set()
|
||||
await held
|
||||
|
||||
|
||||
async def test_beyond_the_queue_limit_the_answer_is_immediate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Once far more work has arrived than the endpoint can absorb, the useful
|
||||
reply is "busy" now — not "busy" in twenty seconds."""
|
||||
_limits(monkeypatch, parallel=1, wait=5.0, queued=2)
|
||||
let_go = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
await let_go.wait()
|
||||
|
||||
async def queue_up() -> None:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
pass
|
||||
|
||||
holder = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0)
|
||||
waiters = [asyncio.create_task(queue_up()) for _ in range(2)]
|
||||
await asyncio.sleep(0)
|
||||
|
||||
started = asyncio.get_running_loop().time()
|
||||
with pytest.raises(LLMError) as raised:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
pass
|
||||
assert raised.value.code == "llm_busy"
|
||||
assert asyncio.get_running_loop().time() - started < 1.0
|
||||
|
||||
let_go.set()
|
||||
await asyncio.gather(holder, *waiters)
|
||||
|
||||
|
||||
async def test_endpoints_do_not_share_a_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The chat and embedding roles usually run on different servers; a busy
|
||||
chat endpoint must not stop retrieval from embedding a query."""
|
||||
_limits(monkeypatch, parallel=1)
|
||||
let_go = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
await let_go.wait()
|
||||
|
||||
holder = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0)
|
||||
async with gate.slot("http://other.test/v1", "embedding"):
|
||||
pass # reached its own slot while the first endpoint is full
|
||||
let_go.set()
|
||||
await holder
|
||||
|
||||
|
||||
async def test_a_failed_call_gives_its_slot_back(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_limits(monkeypatch, parallel=1, wait=0.05)
|
||||
with pytest.raises(RuntimeError):
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
raise RuntimeError("endpoint blew up")
|
||||
# The slot is free again, so the next caller is served rather than queued.
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
pass
|
||||
|
||||
|
||||
async def test_the_mode_can_tell_whether_a_turn_will_have_to_wait(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_limits(monkeypatch, parallel=1)
|
||||
assert gate.endpoint_busy(ENDPOINT) is False
|
||||
let_go = asyncio.Event()
|
||||
|
||||
async def occupy() -> None:
|
||||
async with gate.slot(ENDPOINT, "chat"):
|
||||
await let_go.wait()
|
||||
|
||||
holder = asyncio.create_task(occupy())
|
||||
await asyncio.sleep(0)
|
||||
assert gate.endpoint_busy(ENDPOINT) is True
|
||||
let_go.set()
|
||||
await holder
|
||||
assert gate.endpoint_busy(ENDPOINT) is False
|
||||
@@ -0,0 +1,286 @@
|
||||
"""LLM endpoint configuration: bootstrapped from `.env` once, then owned by
|
||||
the database, applied without a restart, and the api_key never leaves the
|
||||
server (rule 12)."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.llm import client as llm_client
|
||||
from app.llm import overrides
|
||||
from app.llm.overrides import bootstrap_llm_settings, env_defaults, load_config
|
||||
from app.log import JsonFormatter
|
||||
from app.models import LLMSetting, User
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("fake_llm")
|
||||
|
||||
SECRET = "sk-super-secret-key-9876"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_config():
|
||||
overrides.clear()
|
||||
llm_client.rebuild_clients()
|
||||
yield
|
||||
overrides.clear()
|
||||
llm_client.rebuild_clients()
|
||||
|
||||
|
||||
async def _login_admin(client: AsyncClient) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": "florian@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_bootstrap_copies_the_environment_once(db: AsyncSession) -> None:
|
||||
written = await bootstrap_llm_settings(db)
|
||||
assert written > 0
|
||||
|
||||
rows = {row.role: row for row in (await db.execute(select(LLMSetting))).scalars()}
|
||||
for role in ("chat", "utility", "embedding"):
|
||||
defaults = env_defaults(role)
|
||||
assert rows[role].base_url == defaults.base_url
|
||||
assert rows[role].base_url_from_env is True
|
||||
assert rows[role].model_from_env is True
|
||||
|
||||
# A second start changes nothing — the rows are the admin's now.
|
||||
assert await bootstrap_llm_settings(db) == 0
|
||||
|
||||
|
||||
async def test_bootstrap_fills_a_field_an_upgrade_left_deferring_to_env(
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""The upgrade path: before this milestone a NULL column meant "inherit
|
||||
from .env", so the migration flags those fields `*_from_env` and leaves
|
||||
them empty. Startup has to fill them in — otherwise a row whose
|
||||
overrides had been cleared comes out as an empty configuration and
|
||||
takes the endpoint down."""
|
||||
db.add(
|
||||
LLMSetting(
|
||||
role="chat",
|
||||
base_url=None,
|
||||
model="hand-picked",
|
||||
api_key=None,
|
||||
base_url_from_env=True,
|
||||
model_from_env=False,
|
||||
api_key_from_env=True,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await bootstrap_llm_settings(db)
|
||||
|
||||
row = (
|
||||
await db.execute(select(LLMSetting).where(LLMSetting.role == "chat"))
|
||||
).scalar_one()
|
||||
assert row.base_url == env_defaults("chat").base_url
|
||||
# The admin's own value is never overwritten.
|
||||
assert row.model == "hand-picked"
|
||||
assert row.model_from_env is False
|
||||
|
||||
|
||||
async def test_startup_logging_survives_the_reserved_name_trap(
|
||||
db: AsyncSession, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""`extra={"created": ...}` raises KeyError inside logging, because
|
||||
LogRecord already owns that attribute — and the process dies on startup.
|
||||
|
||||
This slipped through once: the log line only builds when the logger is
|
||||
enabled for INFO, and the suite otherwise runs above that level, so
|
||||
every existing test passed while the app refused to boot.
|
||||
"""
|
||||
with caplog.at_level(logging.INFO, logger="pablan.llm"):
|
||||
await bootstrap_llm_settings(db)
|
||||
await load_config(db)
|
||||
|
||||
rendered = "\n".join(JsonFormatter().format(record) for record in caplog.records)
|
||||
assert "llm_bootstrap" in rendered
|
||||
|
||||
|
||||
async def test_the_database_wins_over_a_later_env_change(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The point of bootstrap-then-DB: once a value is stored, the process
|
||||
uses it even though `.env` still says something else."""
|
||||
await _login_admin(client)
|
||||
env_url = env_defaults("chat").base_url
|
||||
|
||||
saved = await client.put(
|
||||
"/api/admin/llm/settings/chat", json={"base_url": "http://stored.invalid/v1"}
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["base_url"] == "http://stored.invalid/v1"
|
||||
assert saved.json()["base_url_from_env"] is False
|
||||
assert llm_client.role_config("chat")[0] == "http://stored.invalid/v1"
|
||||
|
||||
# Re-running bootstrap (i.e. a restart) must not undo it.
|
||||
await bootstrap_llm_settings(db)
|
||||
await load_config(db)
|
||||
assert llm_client.role_config("chat")[0] == "http://stored.invalid/v1"
|
||||
assert env_url != "http://stored.invalid/v1"
|
||||
|
||||
|
||||
async def test_resetting_a_field_restores_the_env_value(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
await _login_admin(client)
|
||||
env_model = env_defaults("chat").model
|
||||
|
||||
changed = await client.put(
|
||||
"/api/admin/llm/settings/chat", json={"model": "gemma-9000"}
|
||||
)
|
||||
assert changed.json()["model"] == "gemma-9000"
|
||||
assert changed.json()["model_from_env"] is False
|
||||
|
||||
reset = await client.put("/api/admin/llm/settings/chat", json={"reset_model": True})
|
||||
assert reset.json()["model_from_env"] is True
|
||||
assert reset.json()["model"] == (env_model or "")
|
||||
assert llm_client.role_config("chat")[2] == env_model
|
||||
|
||||
|
||||
async def test_provenance_is_tracked_per_field(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""Changing the model must not relabel the URL as hand-edited."""
|
||||
await _login_admin(client)
|
||||
body = (
|
||||
await client.put("/api/admin/llm/settings/chat", json={"model": "gemma-9000"})
|
||||
).json()
|
||||
assert body["model_from_env"] is False
|
||||
assert body["base_url_from_env"] is True
|
||||
assert body["api_key_from_env"] is True
|
||||
|
||||
|
||||
async def test_saving_rebuilds_the_client_so_no_restart_is_needed(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
await _login_admin(client)
|
||||
before = llm_client._client_for("chat")
|
||||
|
||||
await client.put(
|
||||
"/api/admin/llm/settings/chat",
|
||||
json={"base_url": "http://elsewhere.invalid/v1"},
|
||||
)
|
||||
|
||||
after = llm_client._client_for("chat")
|
||||
assert after is not before, "cached client kept the old base_url"
|
||||
assert str(after.base_url).startswith("http://elsewhere.invalid")
|
||||
|
||||
|
||||
async def test_the_api_key_is_write_only(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
await _login_admin(client)
|
||||
saved = await client.put("/api/admin/llm/settings/chat", json={"api_key": SECRET})
|
||||
assert saved.status_code == 200
|
||||
|
||||
# It is stored...
|
||||
row = (
|
||||
await db.execute(select(LLMSetting).where(LLMSetting.role == "chat"))
|
||||
).scalar_one()
|
||||
assert row.api_key == SECRET
|
||||
# ...and used...
|
||||
assert llm_client.role_config("chat")[1] == SECRET
|
||||
|
||||
# ...but no response body ever contains it.
|
||||
assert SECRET not in saved.text
|
||||
assert saved.json()["api_key_set"] is True
|
||||
assert saved.json()["api_key_from_env"] is False
|
||||
|
||||
listing = await client.get("/api/admin/llm/settings")
|
||||
assert SECRET not in listing.text
|
||||
assert "api_key" not in listing.json()[0]
|
||||
|
||||
|
||||
async def test_the_api_key_never_reaches_a_log_line(
|
||||
client: AsyncClient,
|
||||
db: AsyncSession,
|
||||
seeded_admin: User,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
await _login_admin(client)
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
await client.put("/api/admin/llm/settings/chat", json={"api_key": SECRET})
|
||||
await client.post(
|
||||
"/api/admin/llm/test",
|
||||
json={"role": "chat", "api_key": SECRET, "base_url": "http://x.invalid/v1"},
|
||||
)
|
||||
await client.post(
|
||||
"/api/admin/llm/models/chat",
|
||||
json={"api_key": SECRET, "base_url": "http://x.invalid/v1"},
|
||||
)
|
||||
|
||||
formatter = JsonFormatter()
|
||||
rendered = "\n".join(formatter.format(record) for record in caplog.records)
|
||||
assert SECRET not in rendered
|
||||
|
||||
|
||||
async def test_testing_a_candidate_does_not_persist_it(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The test button must not change the running configuration."""
|
||||
await _login_admin(client)
|
||||
before = llm_client.role_config("chat")
|
||||
|
||||
response = await client.post(
|
||||
"/api/admin/llm/test",
|
||||
json={"role": "chat", "base_url": "http://candidate.invalid/v1"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [role["role"] for role in response.json()["roles"]] == ["chat"]
|
||||
|
||||
assert llm_client.role_config("chat") == before
|
||||
assert (await db.execute(select(LLMSetting))).scalars().all() == []
|
||||
|
||||
|
||||
async def test_available_models_come_from_the_endpoint(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
await _login_admin(client)
|
||||
response = await client.post("/api/admin/llm/models/chat", json={})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["supported"] is True
|
||||
assert body["models"] == ["bge-m3", "gemma-3-27b"]
|
||||
|
||||
|
||||
async def test_an_endpoint_without_the_route_degrades_quietly(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User, fake_llm
|
||||
) -> None:
|
||||
"""Plenty of OpenAI-compatible servers do not implement /v1/models. That
|
||||
is a missing convenience, not an error worth showing."""
|
||||
fake_llm.served_models = None
|
||||
await _login_admin(client)
|
||||
|
||||
body = (await client.post("/api/admin/llm/models/chat", json={})).json()
|
||||
assert body["supported"] is False
|
||||
assert body["models"] == []
|
||||
assert body["error"] is None
|
||||
|
||||
|
||||
async def test_listing_models_does_not_persist_the_candidate(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
await _login_admin(client)
|
||||
await client.post(
|
||||
"/api/admin/llm/models/chat",
|
||||
json={"base_url": "http://candidate.invalid/v1", "api_key": SECRET},
|
||||
)
|
||||
assert (await db.execute(select(LLMSetting))).scalars().all() == []
|
||||
|
||||
|
||||
async def test_settings_require_an_admin(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
await client.post(
|
||||
"/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert (await client.get("/api/admin/llm/settings")).status_code == 403
|
||||
assert (
|
||||
await client.put("/api/admin/llm/settings/chat", json={"model": "x"})
|
||||
).status_code == 403
|
||||
assert (await client.post("/api/admin/llm/models/chat", json={})).status_code == 403
|
||||
@@ -0,0 +1,74 @@
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import (
|
||||
EMBEDDING_DIM,
|
||||
Chunk,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
)
|
||||
|
||||
|
||||
async def _make_document(db: AsyncSession, content: str) -> Document:
|
||||
document = Document(
|
||||
title="Server maintenance",
|
||||
status=DocumentStatus.published,
|
||||
content_md=content,
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
return document
|
||||
|
||||
|
||||
async def test_chunk_tsv_is_generated_with_german_config(db: AsyncSession) -> None:
|
||||
document = await _make_document(db, "# Maintenance")
|
||||
chunk = Chunk(
|
||||
document_id=document.id,
|
||||
chunk_index=0,
|
||||
content="The servers are maintained and checked regularly.",
|
||||
embedding=[0.1] * EMBEDDING_DIM,
|
||||
)
|
||||
db.add(chunk)
|
||||
await db.commit()
|
||||
|
||||
# Same word in content and query stems identically under any config;
|
||||
# real German retrieval assertions come with the M4 fixture corpus.
|
||||
matches = (
|
||||
await db.execute(
|
||||
select(Chunk.id).where(
|
||||
text("tsv @@ websearch_to_tsquery('german', 'maintained')")
|
||||
)
|
||||
)
|
||||
).all()
|
||||
assert len(matches) == 1
|
||||
|
||||
|
||||
async def test_chunk_index_unique_per_document(db: AsyncSession) -> None:
|
||||
document = await _make_document(db, "# Duplicate")
|
||||
for _ in range(2):
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=document.id,
|
||||
chunk_index=0,
|
||||
content="same index",
|
||||
embedding=[0.0] * EMBEDDING_DIM,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def test_embedding_dimension_enforced(db: AsyncSession) -> None:
|
||||
document = await _make_document(db, "# Dimension")
|
||||
db.add(
|
||||
Chunk(
|
||||
document_id=document.id,
|
||||
chunk_index=0,
|
||||
content="wrong dimension",
|
||||
embedding=[0.0] * (EMBEDDING_DIM - 1),
|
||||
)
|
||||
)
|
||||
with pytest.raises(Exception, match="expected 1024 dimensions"):
|
||||
await db.commit()
|
||||
@@ -0,0 +1,130 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.llm.client import chat_json
|
||||
from app.log import (
|
||||
JsonFormatter,
|
||||
apply_content_log_guard,
|
||||
correlation_id,
|
||||
safe_error,
|
||||
)
|
||||
from app.metrics import MetricsRegistry
|
||||
from tests.fake_openai import FakeOpenAI
|
||||
|
||||
|
||||
def test_metrics_registry_roundtrip() -> None:
|
||||
registry = MetricsRegistry()
|
||||
registry.inc("calls", {"role": "chat"})
|
||||
registry.inc("calls", {"role": "chat"}, value=2)
|
||||
registry.set_gauge("depth", 4.0)
|
||||
registry.observe("seconds", 1.0, {"kind": "x"})
|
||||
registry.observe("seconds", 3.0, {"kind": "x"})
|
||||
|
||||
snapshot = registry.snapshot()
|
||||
assert snapshot["counters"]["calls"] == [{"labels": {"role": "chat"}, "value": 3.0}]
|
||||
assert snapshot["gauges"]["depth"] == [{"labels": {}, "value": 4.0}]
|
||||
hist = snapshot["histograms"]["seconds"][0]
|
||||
assert hist == {
|
||||
"labels": {"kind": "x"},
|
||||
"count": 2,
|
||||
"sum": 4.0,
|
||||
"min": 1.0,
|
||||
"max": 3.0,
|
||||
"avg": 2.0,
|
||||
}
|
||||
|
||||
registry.reset()
|
||||
assert registry.snapshot() == {"counters": {}, "gauges": {}, "histograms": {}}
|
||||
|
||||
|
||||
def _format(record: logging.LogRecord) -> dict:
|
||||
return json.loads(JsonFormatter().format(record))
|
||||
|
||||
|
||||
def test_json_formatter_includes_extras_and_correlation_id() -> None:
|
||||
token = correlation_id.set("req-123")
|
||||
try:
|
||||
record = logging.LogRecord(
|
||||
name="pablan.test",
|
||||
level=logging.INFO,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg="llm call",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
record.role = "chat"
|
||||
record.duration_ms = 42
|
||||
payload = _format(record)
|
||||
finally:
|
||||
correlation_id.reset(token)
|
||||
|
||||
assert payload["message"] == "llm call"
|
||||
assert payload["level"] == "INFO"
|
||||
assert payload["correlation_id"] == "req-123"
|
||||
assert payload["role"] == "chat"
|
||||
assert payload["duration_ms"] == 42
|
||||
assert "ts" in payload
|
||||
|
||||
|
||||
def test_safe_error_strips_sql_parameters() -> None:
|
||||
error = ValueError(
|
||||
"insert failed [SQL: INSERT INTO messages ...] [parameters: ('secret content',)]"
|
||||
)
|
||||
sanitized = safe_error(error)
|
||||
assert sanitized == "ValueError: insert failed"
|
||||
assert "secret content" not in sanitized
|
||||
|
||||
|
||||
def test_safe_error_truncates() -> None:
|
||||
sanitized = safe_error(RuntimeError("x" * 1000), limit=50)
|
||||
assert len(sanitized) <= len("RuntimeError: ") + 50
|
||||
|
||||
|
||||
class Verdict(BaseModel):
|
||||
done: bool
|
||||
|
||||
|
||||
async def test_llm_logs_contain_no_content(
|
||||
fake_llm: FakeOpenAI, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""CLAUDE.md rule 12: with debug logging off (the default), neither the
|
||||
prompt nor the model response may appear in any rendered log line."""
|
||||
secret_prompt = "GEHEIM-PROMPT-77"
|
||||
secret_response = '{"done": true, "leak": "GEHEIM-ANTWORT-88"}'
|
||||
fake_llm.chat_responses.append({"content": secret_response})
|
||||
|
||||
# As in production: third-party SDK loggers are capped so they cannot
|
||||
# dump request bodies even at global DEBUG level.
|
||||
apply_content_log_guard()
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
await chat_json([{"role": "user", "content": secret_prompt}], Verdict)
|
||||
|
||||
formatter = JsonFormatter()
|
||||
rendered = "\n".join(formatter.format(record) for record in caplog.records)
|
||||
assert "llm call" in rendered
|
||||
assert "GEHEIM-PROMPT-77" not in rendered
|
||||
assert "GEHEIM-ANTWORT-88" not in rendered
|
||||
|
||||
|
||||
async def test_debug_flag_enables_content_logging(
|
||||
fake_llm: FakeOpenAI,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The documented never-in-production escape hatch actually works."""
|
||||
monkeypatch.setenv("PABLAN_DEBUG_LOG_PROMPTS", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
fake_llm.chat_responses.append({"content": '{"done": true}'})
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
await chat_json([{"role": "user", "content": "SICHTBAR-99"}], Verdict)
|
||||
formatter = JsonFormatter()
|
||||
rendered = "\n".join(formatter.format(record) for record in caplog.records)
|
||||
assert "SICHTBAR-99" in rendered
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""The colleague directory: member-visible, permission-safe, no credentials."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str = "pablo@test.dev") -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_directory_lists_colleagues_without_leaking_credentials(
|
||||
client: AsyncClient, seeded_user: User, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client)
|
||||
people = (await client.get("/api/people")).json()
|
||||
names = {person["name"] for person in people}
|
||||
assert {"Pablo Test", "Florian Test"} <= names
|
||||
# No email or password ever leaves the directory.
|
||||
assert all("email" not in p and "password_hash" not in p for p in people)
|
||||
|
||||
pablo = next(p for p in people if p["name"] == "Pablo Test")
|
||||
assert pablo["department"] == "Engineering"
|
||||
assert pablo["role"] == "member"
|
||||
|
||||
|
||||
async def test_directory_requires_a_session(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/people")).status_code == 401
|
||||
|
||||
|
||||
async def test_person_detail_and_unknown_is_404(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
await _login(client)
|
||||
ok = await client.get(f"/api/people/{seeded_user.id}")
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["name"] == "Pablo Test"
|
||||
|
||||
missing = await client.get("/api/people/00000000-0000-0000-0000-000000000000")
|
||||
assert missing.status_code == 404
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Admin-editable system prompts: override without a restart, reset to default."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.prompts.defaults import DEFAULTS
|
||||
from app.prompts.overrides import get_prompt
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_prompts_require_admin(client: AsyncClient, seeded_user) -> None:
|
||||
await _login(client, "pablo@test.dev")
|
||||
assert (await client.get("/api/admin/prompts")).status_code == 403
|
||||
|
||||
|
||||
async def test_prompt_override_applies_and_resets(
|
||||
client: AsyncClient, seeded_admin
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
|
||||
listed = (await client.get("/api/admin/prompts")).json()
|
||||
keys = {prompt["key"] for prompt in listed}
|
||||
assert {"query_system", "refine_rules", "title"} <= keys
|
||||
assert all(prompt["is_default"] for prompt in listed)
|
||||
|
||||
# Overriding applies immediately (get_prompt reads the refreshed cache).
|
||||
put = await client.put(
|
||||
"/api/admin/prompts/query_system",
|
||||
json={"content": "You are a test assistant."},
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["is_default"] is False
|
||||
assert get_prompt("query_system") == "You are a test assistant."
|
||||
|
||||
# An empty prompt is rejected; an unknown key is a 404.
|
||||
assert (
|
||||
await client.put("/api/admin/prompts/query_system", json={"content": " "})
|
||||
).status_code == 422
|
||||
assert (
|
||||
await client.put("/api/admin/prompts/nope", json={"content": "x"})
|
||||
).status_code == 404
|
||||
|
||||
# Resetting restores the shipped default.
|
||||
reset = await client.put("/api/admin/prompts/query_system", json={"reset": True})
|
||||
assert reset.status_code == 200
|
||||
assert reset.json()["is_default"] is True
|
||||
assert get_prompt("query_system") == DEFAULTS["query_system"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Query mode helpers (the SSE contract itself lives in
|
||||
test_conversations_api.py)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.models import MessageRole
|
||||
from app.modes.query import _topic_transcript
|
||||
|
||||
|
||||
def _msg(role: MessageRole, content: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(role=role, content=content)
|
||||
|
||||
|
||||
def test_topic_transcript_needs_prior_context() -> None:
|
||||
# A first message alone has no earlier context: the topic fallback is a
|
||||
# no-op, so a first-message miss stays a genuine no-answer.
|
||||
conversation = SimpleNamespace(messages=[])
|
||||
assert _topic_transcript(conversation, "Wie beantrage ich Urlaub?") == ""
|
||||
|
||||
|
||||
def test_topic_transcript_includes_history_and_current() -> None:
|
||||
conversation = SimpleNamespace(
|
||||
messages=[
|
||||
_msg(MessageRole.user, "Wie beantrage ich Urlaub?"),
|
||||
_msg(MessageRole.assistant, "Über das Personalportal."),
|
||||
]
|
||||
)
|
||||
transcript = _topic_transcript(conversation, "Und was war meine erste Frage?")
|
||||
assert "Urlaub" in transcript
|
||||
assert "erste Frage" in transcript
|
||||
assert transcript.count("User:") == 2
|
||||
|
||||
|
||||
def test_topic_transcript_does_not_duplicate_the_current_message() -> None:
|
||||
# The current message may already be persisted as the last stored turn.
|
||||
conversation = SimpleNamespace(
|
||||
messages=[
|
||||
_msg(MessageRole.user, "Erste Frage."),
|
||||
_msg(MessageRole.assistant, "Antwort."),
|
||||
_msg(MessageRole.user, "Zweite Frage."),
|
||||
]
|
||||
)
|
||||
transcript = _topic_transcript(conversation, "Zweite Frage.")
|
||||
assert transcript.count("Zweite Frage.") == 1
|
||||
@@ -0,0 +1,266 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from app.ingestion.handlers import RETENTION_CLEANUP, ensure_retention_scheduled
|
||||
from app.ingestion.queue import (
|
||||
MAX_ATTEMPTS,
|
||||
enqueue,
|
||||
job_handler,
|
||||
process_one,
|
||||
)
|
||||
from app.models import (
|
||||
AuthSession,
|
||||
Conversation,
|
||||
ConversationMode,
|
||||
Department,
|
||||
Job,
|
||||
JobStatus,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(db_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def _get_job(db: AsyncSession, job_id: uuid.UUID) -> Job:
|
||||
db.expire_all()
|
||||
job = await db.get(Job, job_id)
|
||||
assert job is not None
|
||||
return job
|
||||
|
||||
|
||||
async def test_successful_job_is_marked_done(
|
||||
db: AsyncSession, session_factory: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
seen: list[dict] = []
|
||||
|
||||
@job_handler("t_ok")
|
||||
async def handle(handler_db: AsyncSession, job: Job) -> None:
|
||||
seen.append(job.payload)
|
||||
|
||||
job = await enqueue(db, "t_ok", {"n": 1})
|
||||
await db.commit()
|
||||
|
||||
assert await process_one(session_factory) is True
|
||||
assert seen == [{"n": 1}]
|
||||
refreshed = await _get_job(db, job.id)
|
||||
assert refreshed.status == JobStatus.done
|
||||
assert refreshed.attempts == 1
|
||||
|
||||
# Nothing left to do.
|
||||
assert await process_one(session_factory) is False
|
||||
|
||||
|
||||
async def test_failing_job_retries_with_backoff(
|
||||
db: AsyncSession, session_factory: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
@job_handler("t_fail")
|
||||
async def handle(handler_db: AsyncSession, job: Job) -> None:
|
||||
raise ValueError("boom")
|
||||
|
||||
job = await enqueue(db, "t_fail")
|
||||
await db.commit()
|
||||
|
||||
assert await process_one(session_factory) is True
|
||||
refreshed = await _get_job(db, job.id)
|
||||
assert refreshed.status == JobStatus.pending
|
||||
assert refreshed.attempts == 1
|
||||
assert refreshed.last_error is not None
|
||||
assert "ValueError: boom" in refreshed.last_error
|
||||
assert refreshed.run_after > datetime.now(UTC) + timedelta(seconds=10)
|
||||
|
||||
# Backed off into the future: not claimable right now.
|
||||
assert await process_one(session_factory) is False
|
||||
|
||||
|
||||
async def test_job_fails_permanently_after_max_attempts(
|
||||
db: AsyncSession, session_factory: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
@job_handler("t_exhaust")
|
||||
async def handle(handler_db: AsyncSession, job: Job) -> None:
|
||||
raise RuntimeError("always broken")
|
||||
|
||||
job = await enqueue(db, "t_exhaust")
|
||||
await db.commit()
|
||||
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
refreshed = await _get_job(db, job.id)
|
||||
refreshed.run_after = datetime.now(UTC) - timedelta(seconds=1)
|
||||
await db.commit()
|
||||
assert await process_one(session_factory) is True
|
||||
|
||||
refreshed = await _get_job(db, job.id)
|
||||
assert refreshed.status == JobStatus.failed
|
||||
assert refreshed.attempts == MAX_ATTEMPTS
|
||||
|
||||
|
||||
async def test_unknown_job_type_records_error(
|
||||
db: AsyncSession, session_factory: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
job = await enqueue(db, "t_nobody_home")
|
||||
await db.commit()
|
||||
|
||||
assert await process_one(session_factory) is True
|
||||
refreshed = await _get_job(db, job.id)
|
||||
assert refreshed.status == JobStatus.pending
|
||||
assert refreshed.last_error is not None
|
||||
assert "LookupError" in refreshed.last_error
|
||||
|
||||
|
||||
async def test_locked_job_is_skipped_and_claimable_after_rollback(
|
||||
db: AsyncSession, session_factory: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
"""SKIP LOCKED + crash-safety: a claim held by a dying worker (open tx)
|
||||
is invisible to others and becomes claimable again on rollback."""
|
||||
|
||||
@job_handler("t_locked")
|
||||
async def handle(handler_db: AsyncSession, job: Job) -> None:
|
||||
pass
|
||||
|
||||
job = await enqueue(db, "t_locked")
|
||||
await db.commit()
|
||||
|
||||
async with session_factory() as other:
|
||||
claimed = (
|
||||
await other.execute(
|
||||
select(Job).where(Job.id == job.id).with_for_update(skip_locked=True)
|
||||
)
|
||||
).scalar_one()
|
||||
assert claimed.id == job.id
|
||||
# Row is locked by "another worker": nothing to process.
|
||||
assert await process_one(session_factory) is False
|
||||
await other.rollback() # the worker "crashes"
|
||||
|
||||
# After the rollback the job is claimable again.
|
||||
assert await process_one(session_factory) is True
|
||||
refreshed = await _get_job(db, job.id)
|
||||
assert refreshed.status == JobStatus.done
|
||||
|
||||
|
||||
async def test_handler_writes_roll_back_atomically_on_failure(
|
||||
db: AsyncSession, session_factory: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
@job_handler("t_atomic")
|
||||
async def handle(handler_db: AsyncSession, job: Job) -> None:
|
||||
handler_db.add(Department(name="Ghost Department"))
|
||||
await handler_db.flush()
|
||||
raise RuntimeError("after write")
|
||||
|
||||
await enqueue(db, "t_atomic")
|
||||
await db.commit()
|
||||
assert await process_one(session_factory) is True
|
||||
|
||||
ghost = (
|
||||
await db.execute(
|
||||
select(Department).where(Department.name == "Ghost Department")
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
assert ghost is None
|
||||
|
||||
|
||||
async def test_retention_cleanup(
|
||||
db: AsyncSession,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
seeded_user: User,
|
||||
) -> None:
|
||||
now = datetime.now(UTC)
|
||||
old = now - timedelta(days=120)
|
||||
|
||||
old_query = Conversation(
|
||||
mode=ConversationMode.query, user_id=seeded_user.id, updated_at=old
|
||||
)
|
||||
fresh_query = Conversation(mode=ConversationMode.query, user_id=seeded_user.id)
|
||||
# A non-query mode (EE insight) must survive retention: only ephemeral
|
||||
# query threads are cleaned up.
|
||||
old_insight = Conversation(
|
||||
mode=ConversationMode.insight, user_id=seeded_user.id, updated_at=old
|
||||
)
|
||||
expired_session = AuthSession(
|
||||
user_id=seeded_user.id, expires_at=now - timedelta(days=1)
|
||||
)
|
||||
valid_session = AuthSession(
|
||||
user_id=seeded_user.id, expires_at=now + timedelta(days=1)
|
||||
)
|
||||
db.add_all([old_query, fresh_query, old_insight, expired_session, valid_session])
|
||||
await enqueue(db, RETENTION_CLEANUP)
|
||||
await db.commit()
|
||||
|
||||
assert await process_one(session_factory) is True
|
||||
|
||||
db.expire_all()
|
||||
remaining_conversations = {
|
||||
c.id for c in (await db.execute(select(Conversation))).scalars()
|
||||
}
|
||||
assert remaining_conversations == {fresh_query.id, old_insight.id}
|
||||
remaining_sessions = {
|
||||
s.id for s in (await db.execute(select(AuthSession))).scalars()
|
||||
}
|
||||
assert remaining_sessions == {valid_session.id}
|
||||
|
||||
# Rescheduled itself for tomorrow.
|
||||
next_job = (
|
||||
await db.execute(
|
||||
select(Job).where(
|
||||
Job.type == RETENTION_CLEANUP, Job.status == JobStatus.pending
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert next_job.run_after > now + timedelta(hours=23)
|
||||
|
||||
|
||||
async def test_ensure_retention_scheduled_is_idempotent(
|
||||
db: AsyncSession, session_factory: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
async with session_factory() as first:
|
||||
await ensure_retention_scheduled(first)
|
||||
async with session_factory() as second:
|
||||
await ensure_retention_scheduled(second)
|
||||
|
||||
jobs = (
|
||||
(await db.execute(select(Job).where(Job.type == RETENTION_CLEANUP)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(jobs) == 1
|
||||
|
||||
|
||||
async def test_retention_respects_configured_days(
|
||||
db: AsyncSession,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
seeded_user: User,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end with a short retention window: 1 day keeps yesterday's
|
||||
conversation out of scope for deletion at 20h but purges a 30h one."""
|
||||
from app.config import get_settings
|
||||
|
||||
monkeypatch.setenv("PABLAN_QUERY_RETENTION_DAYS", "1")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
too_old = Conversation(
|
||||
mode=ConversationMode.query,
|
||||
user_id=seeded_user.id,
|
||||
updated_at=now - timedelta(hours=30),
|
||||
)
|
||||
still_fresh = Conversation(
|
||||
mode=ConversationMode.query,
|
||||
user_id=seeded_user.id,
|
||||
updated_at=now - timedelta(hours=20),
|
||||
)
|
||||
db.add_all([too_old, still_fresh])
|
||||
await enqueue(db, RETENTION_CLEANUP)
|
||||
await db.commit()
|
||||
|
||||
assert await process_one(session_factory) is True
|
||||
db.expire_all()
|
||||
remaining = {c.id for c in (await db.execute(select(Conversation))).scalars()}
|
||||
assert remaining == {still_fresh.id}
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Retrieval-aware section refinement: a refined section may draw on what the
|
||||
company has already documented, and only on documents the author is allowed to
|
||||
read.
|
||||
|
||||
Uses deterministic fake embeddings (fake_embed): identical text lands at
|
||||
distance 0, unrelated text near-orthogonal. That is enough to prove the wiring,
|
||||
the permission boundary and the gates; whether grounding helps the writing is
|
||||
measured against the real model in tests/evals.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.authoring.grounding import for_section as grounding_for
|
||||
from app.auth.passwords import hash_password
|
||||
from app.authoring.prompts import render_refine_prompt
|
||||
from app.models import (
|
||||
Chunk,
|
||||
Department,
|
||||
Document,
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from app.rag.indexing import embedding_text, reindex_document
|
||||
from tests.fake_openai import FakeOpenAI
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("fake_embed")
|
||||
|
||||
# A sentence long enough to clear the grounding minimum, reused as both the
|
||||
# stored document and the query so the fake embedding matches exactly.
|
||||
COFFEE = "Die Kaffeemaschine wird jeden Freitag gründlich entkalkt und gereinigt."
|
||||
|
||||
|
||||
async def _user(db: AsyncSession, email: str, department_id: uuid.UUID) -> 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 _published(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
content: str,
|
||||
author: User,
|
||||
visibility: DocumentVisibility = DocumentVisibility.public,
|
||||
) -> Document:
|
||||
document = Document(
|
||||
title=title,
|
||||
status=DocumentStatus.published,
|
||||
visibility=visibility,
|
||||
content_md=content,
|
||||
author_id=author.id,
|
||||
department_id=author.department_id,
|
||||
)
|
||||
db.add(document)
|
||||
await db.flush()
|
||||
await reindex_document(db, document)
|
||||
return document
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# --- The prompt only offers grounding as a reference, never as a fact source.
|
||||
|
||||
|
||||
def test_prompt_omits_grounding_when_there_is_none() -> None:
|
||||
messages = render_refine_prompt(
|
||||
"## Pflege\n\nNotizen", prefix="", suffix="", persona=None, hint=None
|
||||
)
|
||||
assert "Related knowledge" not in messages[-1]["content"]
|
||||
|
||||
|
||||
def test_prompt_appends_grounding_after_the_section() -> None:
|
||||
messages = render_refine_prompt(
|
||||
"## Pflege\n\nNotizen",
|
||||
prefix="",
|
||||
suffix="",
|
||||
persona=None,
|
||||
hint=None,
|
||||
knowledge=['From "Kaffeemaschine": entkalken.'],
|
||||
)
|
||||
turn = messages[-1]["content"]
|
||||
assert "Related knowledge" in turn
|
||||
assert 'From "Kaffeemaschine"' in turn
|
||||
# The section to refine still leads; grounding trails it.
|
||||
assert turn.index("Refine only this section") < turn.index("Related knowledge")
|
||||
|
||||
|
||||
# --- grounding.for_section: finds related knowledge, excludes self, respects the gates.
|
||||
|
||||
|
||||
async def test_grounding_surfaces_a_related_document(db: AsyncSession) -> None:
|
||||
engineering = Department(name="Engineering")
|
||||
db.add(engineering)
|
||||
await db.flush()
|
||||
author = await _user(db, "pablo@test.dev", engineering.id)
|
||||
document = await _published(
|
||||
db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
query = await _chunk_text(db, document)
|
||||
references = await grounding_for(db, query, author, document_id=uuid.uuid4())
|
||||
assert references, "an exact-text match should be grounded"
|
||||
assert references[0].title == "Kaffeemaschine"
|
||||
|
||||
|
||||
async def test_grounding_never_includes_the_document_being_edited(
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
engineering = Department(name="Engineering")
|
||||
db.add(engineering)
|
||||
await db.flush()
|
||||
author = await _user(db, "pablo@test.dev", engineering.id)
|
||||
document = await _published(
|
||||
db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
query = await _chunk_text(db, document)
|
||||
references = await grounding_for(db, query, author, document_id=document.id)
|
||||
assert references == []
|
||||
|
||||
|
||||
async def test_grounding_skips_a_section_that_is_still_just_a_heading(
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
engineering = Department(name="Engineering")
|
||||
db.add(engineering)
|
||||
await db.flush()
|
||||
author = await _user(db, "pablo@test.dev", engineering.id)
|
||||
await _published(
|
||||
db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Heading plus a few words is below the minimum, so nothing is searched.
|
||||
references = await grounding_for(
|
||||
db, "## Pflege\n\nnoch nichts", author, uuid.uuid4()
|
||||
)
|
||||
assert references == []
|
||||
|
||||
|
||||
async def test_grounding_cannot_reach_a_document_the_author_may_not_read(
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
engineering = Department(name="Engineering")
|
||||
sales = Department(name="Sales")
|
||||
db.add_all([engineering, sales])
|
||||
await db.flush()
|
||||
pablo = await _user(db, "pablo@test.dev", engineering.id)
|
||||
max_user = await _user(db, "max@test.dev", sales.id)
|
||||
secret = await _published(
|
||||
db,
|
||||
title="Preisliste",
|
||||
content=f"## Preise\n\n{COFFEE}",
|
||||
author=max_user,
|
||||
visibility=DocumentVisibility.restricted,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
query = await _chunk_text(db, secret)
|
||||
# Max authored it, so he is grounded on it; Pablo has no access, so he is not.
|
||||
assert await grounding_for(db, query, max_user, uuid.uuid4())
|
||||
assert await grounding_for(db, query, pablo, uuid.uuid4()) == []
|
||||
|
||||
|
||||
# --- The endpoint wires grounding into the streamed prompt.
|
||||
|
||||
|
||||
async def test_refine_endpoint_passes_grounding_to_the_model(
|
||||
client: AsyncClient, db: AsyncSession, fake_llm: FakeOpenAI
|
||||
) -> None:
|
||||
engineering = Department(name="Engineering")
|
||||
db.add(engineering)
|
||||
await db.flush()
|
||||
author = await _user(db, "pablo@test.dev", engineering.id)
|
||||
reference = await _published(
|
||||
db, title="Kaffeemaschine", content=f"## Pflege\n\n{COFFEE}", author=author
|
||||
)
|
||||
draft = Document(
|
||||
title="Entwurf",
|
||||
status=DocumentStatus.draft,
|
||||
visibility=DocumentVisibility.public,
|
||||
content_md="## Pflege\n\nStichpunkte",
|
||||
author_id=author.id,
|
||||
department_id=engineering.id,
|
||||
)
|
||||
db.add(draft)
|
||||
await db.commit()
|
||||
|
||||
query = await _chunk_text(db, reference)
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
refined = await client.post(
|
||||
f"/api/documents/{draft.id}/refine",
|
||||
json={"content_md": query, "cursor_line": 1},
|
||||
)
|
||||
assert refined.status_code == 200
|
||||
prompt = fake_llm.requests[-1]["messages"][-1]["content"]
|
||||
assert "Kaffeemaschine" in prompt
|
||||
@@ -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) == []
|
||||
@@ -0,0 +1,320 @@
|
||||
"""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
|
||||
@@ -0,0 +1,128 @@
|
||||
"""The catalog must never write over a customer's templates.
|
||||
|
||||
These tests exist because the opposite behaviour shipped first: the catalog
|
||||
used to be re-imported on every start, which would silently discard an
|
||||
admin's edits the next time we improved a shipped blueprint.
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Template
|
||||
from app.template_catalog import (
|
||||
STARTER_TEMPLATE_IDS,
|
||||
catalog_for_locale,
|
||||
load_catalog,
|
||||
seed_starter_templates,
|
||||
)
|
||||
from app.template_import import parse_template, upsert_template
|
||||
|
||||
|
||||
async def test_starter_set_seeds_only_an_empty_table(db: AsyncSession) -> None:
|
||||
seeded = await seed_starter_templates(db)
|
||||
assert seeded == len(STARTER_TEMPLATE_IDS)
|
||||
|
||||
ids = set((await db.execute(select(Template.config["id"].astext))).scalars().all())
|
||||
assert ids == set(STARTER_TEMPLATE_IDS)
|
||||
|
||||
# A second start adds nothing — the admin's curation is the truth.
|
||||
assert await seed_starter_templates(db) == 0
|
||||
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
||||
assert count == len(STARTER_TEMPLATE_IDS)
|
||||
|
||||
|
||||
async def test_seeding_never_overwrites_an_edited_template(db: AsyncSession) -> None:
|
||||
"""The regression this module is named for: an admin edits a starter
|
||||
template, the server restarts, and the edit survives."""
|
||||
await seed_starter_templates(db)
|
||||
row = (
|
||||
await db.execute(
|
||||
select(Template).where(Template.config["id"].astext == "prozess")
|
||||
)
|
||||
).scalar_one()
|
||||
row.name = "Unser Ablauf"
|
||||
row.config = {**row.config, "persona": "Komplett umgeschrieben."}
|
||||
await db.commit()
|
||||
|
||||
await seed_starter_templates(db)
|
||||
|
||||
await db.refresh(row)
|
||||
assert row.name == "Unser Ablauf"
|
||||
assert row.config["persona"] == "Komplett umgeschrieben."
|
||||
|
||||
|
||||
async def test_deleting_a_starter_template_keeps_it_deleted(db: AsyncSession) -> None:
|
||||
"""Deleting all but one must not resurrect the rest on restart — the
|
||||
table is non-empty, so seeding stays out."""
|
||||
await seed_starter_templates(db)
|
||||
rows = (await db.execute(select(Template))).scalars().all()
|
||||
for row in rows[1:]:
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
|
||||
await seed_starter_templates(db)
|
||||
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
||||
assert count == 1
|
||||
|
||||
|
||||
async def test_an_empty_table_after_deleting_everything_reseeds(
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""The flip side, and the honest consequence of the empty-table rule: an
|
||||
admin who removes every template gets the starter set back on restart
|
||||
rather than an instance nobody can capture with."""
|
||||
await seed_starter_templates(db)
|
||||
for row in (await db.execute(select(Template))).scalars().all():
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
|
||||
assert await seed_starter_templates(db) == len(STARTER_TEMPLATE_IDS)
|
||||
|
||||
|
||||
async def test_importing_a_customer_template_is_untouched_by_seeding(
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
own = parse_template(
|
||||
"\n".join(
|
||||
[
|
||||
"id: unser-eigenes",
|
||||
'name: "Unser eigenes"',
|
||||
'version: "1.0"',
|
||||
"kind: authoring",
|
||||
"persona: |",
|
||||
" Du bist ein Fachredakteur.",
|
||||
'title_template: "X: {{user.name}} ({{date}})"',
|
||||
"skeleton: |",
|
||||
" ## Thema",
|
||||
"sections:",
|
||||
' - heading: "Thema"',
|
||||
' hint: "Worum es geht."',
|
||||
]
|
||||
)
|
||||
)
|
||||
await upsert_template(db, own)
|
||||
await db.commit()
|
||||
|
||||
# The table is not empty, so nothing is seeded over it.
|
||||
assert await seed_starter_templates(db) == 0
|
||||
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_every_shipped_blueprint_parses_and_declares_its_language() -> None:
|
||||
"""A broken blueprint is skipped at load time, so a silent typo would
|
||||
quietly shrink the catalog instead of failing loudly."""
|
||||
entries = load_catalog()
|
||||
assert len(entries) >= len(STARTER_TEMPLATE_IDS)
|
||||
assert all(entry.locale in ("de", "en") for entry in entries)
|
||||
assert all(entry.description for entry in entries)
|
||||
# A blueprint may have no sections at all (`notiz` opens an empty
|
||||
# document on purpose), but one that HAS a skeleton must hint at it.
|
||||
assert all(entry.sections >= 1 for entry in entries if entry.id != "notiz")
|
||||
|
||||
|
||||
def test_catalog_collapses_language_variants_to_one_entry_per_id() -> None:
|
||||
per_locale = load_catalog()
|
||||
collapsed = catalog_for_locale("de")
|
||||
assert len(collapsed) == len({entry.id for entry in per_locale})
|
||||
assert all(entry.locale == "de" for entry in collapsed)
|
||||
@@ -0,0 +1,274 @@
|
||||
import re
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Template, User
|
||||
from app.template_import import parse_template, upsert_template
|
||||
|
||||
VALID_YAML = """\
|
||||
id: import-test
|
||||
name: "Import-Test"
|
||||
version: "1.0"
|
||||
kind: authoring
|
||||
persona: |
|
||||
Du bist ein Fachredakteur.
|
||||
title_template: "Import: {{user.name}} ({{date}})"
|
||||
skeleton: |
|
||||
## Thema
|
||||
sections:
|
||||
- heading: "Thema"
|
||||
hint: "Worum es geht."
|
||||
metadata:
|
||||
visibility: department
|
||||
"""
|
||||
|
||||
# The structured config the form builder posts — the same shape parse_template
|
||||
# produces, so /build and /import share one validation guarantee.
|
||||
VALID_CONFIG = {
|
||||
"id": "gebaut",
|
||||
"name": "Gebaute Vorlage",
|
||||
"version": "1.0",
|
||||
"kind": "authoring",
|
||||
"locale": "de",
|
||||
"description": "Aus dem Formular gebaut.",
|
||||
"model": {"temperature": 0.4, "min_class_hint": None},
|
||||
"persona": "Du bist ein Fachredakteur.",
|
||||
"skeleton": "## Thema\n",
|
||||
"sections": [{"heading": "Thema", "hint": "Worum es geht."}],
|
||||
"title_template": "Gebaut: {{user.name}}",
|
||||
"metadata": {"visibility": "department"},
|
||||
}
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _seed(db: AsyncSession, yaml: str = VALID_YAML) -> Template:
|
||||
"""Put a template in the table directly, for tests that need one to exist
|
||||
rather than to exercise a create endpoint."""
|
||||
row, _ = await upsert_template(db, parse_template(yaml))
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def test_endpoints_require_auth(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/templates")).status_code == 401
|
||||
assert (
|
||||
await client.post("/api/templates/build", json={"config": VALID_CONFIG})
|
||||
).status_code == 401
|
||||
|
||||
|
||||
async def test_list_get_roundtrip(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
row = await _seed(db)
|
||||
|
||||
# Members can browse templates (they pick one to write a document from).
|
||||
await _login(client, "pablo@test.dev")
|
||||
listing = (await client.get("/api/templates")).json()
|
||||
assert [t["name"] for t in listing] == ["Import-Test"]
|
||||
detail = (await client.get(f"/api/templates/{row.id}")).json()
|
||||
assert detail["config"]["sections"][0]["heading"] == "Thema"
|
||||
assert detail["config"]["persona"].startswith("Du bist")
|
||||
|
||||
|
||||
async def test_every_template_is_editable_and_deletable(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""There is no read-only template: a template describes how a company
|
||||
documents its own knowledge, so the company owns it."""
|
||||
row, _ = await upsert_template(db, parse_template(VALID_YAML))
|
||||
await db.commit()
|
||||
await _login(client, "florian@test.dev")
|
||||
|
||||
edited = VALID_YAML.replace('version: "1.0"', 'version: "1.1"')
|
||||
saved = await client.put(f"/api/templates/{row.id}", json={"yaml": edited})
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["version"] == "1.1"
|
||||
|
||||
# A fork gets its own config id, so the two never collide.
|
||||
forked = await client.post(f"/api/templates/{row.id}/duplicate")
|
||||
assert forked.status_code == 200
|
||||
fork = forked.json()
|
||||
assert fork["config"]["id"].endswith("-copy")
|
||||
# Numbered, not worded: the name is shown in the UI, and the backend
|
||||
# renders no UI-language strings.
|
||||
assert fork["name"].endswith(" (2)")
|
||||
|
||||
assert (await client.delete(f"/api/templates/{fork['id']}")).status_code == 204
|
||||
assert (await client.delete(f"/api/templates/{row.id}")).status_code == 204
|
||||
|
||||
|
||||
async def test_editing_returns_the_yaml_and_rejects_invalid_input(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
row = await _seed(db)
|
||||
await _login(client, "florian@test.dev")
|
||||
|
||||
# The detail carries editable YAML, not just the parsed config.
|
||||
detail = (await client.get(f"/api/templates/{row.id}")).json()
|
||||
assert "skeleton:" in detail["yaml"]
|
||||
|
||||
# Saving invalid YAML is rejected: a missing required field, or malformed
|
||||
# syntax, both surface as 422 invalid_template rather than corrupting the row.
|
||||
incomplete = await client.put(
|
||||
f"/api/templates/{row.id}", json={"yaml": "id: x\nname: y"}
|
||||
)
|
||||
assert incomplete.status_code == 422
|
||||
assert incomplete.json()["code"] == "invalid_template"
|
||||
|
||||
malformed = await client.put(f"/api/templates/{row.id}", json={"yaml": ":\n - ]["})
|
||||
assert malformed.status_code == 422
|
||||
|
||||
|
||||
async def test_template_management_requires_an_admin(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
row, _ = await upsert_template(db, parse_template(VALID_YAML))
|
||||
await db.commit()
|
||||
await client.post(
|
||||
"/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert (
|
||||
await client.put(f"/api/templates/{row.id}", json={"yaml": VALID_YAML})
|
||||
).status_code == 403
|
||||
assert (await client.post(f"/api/templates/{row.id}/duplicate")).status_code == 403
|
||||
assert (await client.delete(f"/api/templates/{row.id}")).status_code == 403
|
||||
|
||||
|
||||
async def test_catalog_lists_blueprints_and_marks_what_is_added(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The catalog is what ships in templates/ — inert until someone adds
|
||||
an entry."""
|
||||
await _login(client, "florian@test.dev")
|
||||
catalog = (await client.get("/api/templates/catalog")).json()
|
||||
by_id = {entry["id"]: entry for entry in catalog}
|
||||
|
||||
assert "notiz" in by_id
|
||||
assert "anlage" in by_id
|
||||
assert by_id["anlage"]["sections"] >= 1
|
||||
assert by_id["anlage"]["description"]
|
||||
# Nothing is in the instance yet.
|
||||
assert all(entry["added"] is False for entry in catalog)
|
||||
|
||||
added = await client.post("/api/templates/catalog/anlage")
|
||||
assert added.status_code == 200
|
||||
assert added.json()["config"]["id"] == "anlage"
|
||||
|
||||
catalog = (await client.get("/api/templates/catalog")).json()
|
||||
assert {e["id"]: e["added"] for e in catalog}["anlage"] is True
|
||||
|
||||
# Adding twice would silently overwrite an edited template.
|
||||
again = await client.post("/api/templates/catalog/anlage")
|
||||
assert again.status_code == 409
|
||||
assert again.json()["code"] == "already_added"
|
||||
|
||||
|
||||
async def test_a_template_added_from_the_catalog_is_fully_editable(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The whole point of the catalog: what you add becomes yours."""
|
||||
await _login(client, "florian@test.dev")
|
||||
added = (await client.post("/api/templates/catalog/person")).json()
|
||||
|
||||
# The detail serializes the config back to YAML, so quoting is safe_dump's.
|
||||
changed = re.sub(r"^version:.*$", 'version: "0.9"', added["yaml"], flags=re.M)
|
||||
saved = await client.put(f"/api/templates/{added['id']}", json={"yaml": changed})
|
||||
assert saved.status_code == 200, saved.json()
|
||||
assert saved.json()["version"] == "0.9"
|
||||
|
||||
assert (await client.delete(f"/api/templates/{added['id']}")).status_code == 204
|
||||
|
||||
|
||||
async def test_unknown_blueprint_is_404(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
assert (await client.get("/api/templates/catalog/nope")).status_code == 404
|
||||
assert (await client.post("/api/templates/catalog/nope")).status_code == 404
|
||||
|
||||
|
||||
async def test_catalog_requires_an_admin(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
"""Blueprints are an administration concern — a member picks from what
|
||||
the admin enabled, not from the whole catalogue."""
|
||||
await _login(client, "pablo@test.dev")
|
||||
assert (await client.get("/api/templates/catalog")).status_code == 403
|
||||
assert (await client.post("/api/templates/catalog/freies-thema")).status_code == 403
|
||||
|
||||
|
||||
async def test_build_creates_from_config_then_updates_in_place(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The form builder posts structured config instead of YAML. Creating with
|
||||
template_id null makes a row; posting the row id updates it in place."""
|
||||
await _login(client, "florian@test.dev")
|
||||
created = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
assert created.status_code == 200, created.json()
|
||||
body = created.json()
|
||||
assert body["config"]["id"] == "gebaut"
|
||||
assert body["config"]["skeleton"].startswith("## Thema")
|
||||
row_id = body["id"]
|
||||
|
||||
reordered = {
|
||||
**VALID_CONFIG,
|
||||
"sections": [{"heading": "Ablauf", "hint": "Die Schritte."}],
|
||||
"skeleton": "## Ablauf\n",
|
||||
}
|
||||
saved = await client.post(
|
||||
"/api/templates/build", json={"template_id": row_id, "config": reordered}
|
||||
)
|
||||
assert saved.status_code == 200, saved.json()
|
||||
assert saved.json()["id"] == row_id
|
||||
assert saved.json()["config"]["sections"][0]["heading"] == "Ablauf"
|
||||
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
||||
assert count == 1
|
||||
|
||||
|
||||
async def test_build_uniquifies_config_id_for_new_templates(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""A second new template with the same name must not silently overwrite
|
||||
the first: the server suffixes the derived config id instead."""
|
||||
await _login(client, "florian@test.dev")
|
||||
first = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
second = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
assert first.json()["config"]["id"] == "gebaut"
|
||||
assert second.json()["config"]["id"] == "gebaut-2"
|
||||
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
||||
assert count == 2
|
||||
|
||||
|
||||
async def test_build_requires_admin(client: AsyncClient, seeded_user: User) -> None:
|
||||
await _login(client, "pablo@test.dev")
|
||||
response = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_build_rejects_invalid_config(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
"""The nested config is a Pydantic model, so a missing required field is
|
||||
rejected before it can reach the table."""
|
||||
await _login(client, "florian@test.dev")
|
||||
without_persona = {k: v for k, v in VALID_CONFIG.items() if k != "persona"}
|
||||
response = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": without_persona}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
Reference in New Issue
Block a user