Pablan, as it stands

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

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

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