feat(search): SEARCH-002 Suchindex auch auf Materialnamen erweitert
CI / backend-tests (push) Failing after 2m11s
CI / frontend-build (push) Successful in 22s

Wer nach einem Material sucht (z.B. "Kompresse"), will die Objekte finden,
die es aktiv führen - nicht nur Objekte, deren eigener Name/Code passt.
GIN-Trigram-Index auf material.name (Migration 0025), /suche joint jetzt
zusätzlich über objektposition (nur status=aktiv) auf material.name.
Direkter Objekt-Treffer hat Vorrang vor Material-Treffer für dasselbe
Objekt. Command Palette zeigt bei Material-Treffern einen Hinweis
("führt „Kompresse“") statt nur dem Code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 01:16:45 +02:00
co-authored by Claude Sonnet 5
parent 174cefa595
commit 2475c6a68d
5 changed files with 105 additions and 12 deletions
@@ -0,0 +1,23 @@
"""SEARCH-002: GIN-Trigram-Index auf Material.name für Suche über Materialnamen.
Revision ID: 0025_material_suchindex
Revises: 0024_dokument_hash
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0025_material_suchindex"
down_revision: Union[str, None] = "0024_dokument_hash"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# pg_trgm-Extension existiert bereits seit Migration 0023.
op.execute("CREATE INDEX ix_material_name_trgm ON material USING GIN (name gin_trgm_ops)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_material_name_trgm")
+50 -11
View File
@@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.session import get_db from app.db.session import get_db
from app.models.objekt import Objekt from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.models.stammdaten import Material
from app.schemas.suche import SuchTreffer from app.schemas.suche import SuchTreffer
router = APIRouter() router = APIRouter()
@@ -18,21 +20,58 @@ async def suche(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_=Depends(get_current_user), _=Depends(get_current_user),
) -> list[SuchTreffer]: ) -> list[SuchTreffer]:
"""Globale Suche (Command Palette) über Objektname/-code, per pg_trgm- """Globale Suche (Command Palette) über Objektname/-code UND Material-
Index statt vollständig geladener Objektliste + Client-Filter (SEARCH-001). Namen (SEARCH-002: wer "Kompressen" sucht, will Objekte finden, die
Kurze Eingaben (<2 Zeichen) liefern bewusst nichts - sonst zu viele welche führen) - per pg_trgm-Index statt vollständig geladener
Zufallstreffer über den Ähnlichkeits-Schwellenwert von pg_trgm.""" Objektliste + Client-Filter (SEARCH-001). Kurze Eingaben (<2 Zeichen)
liefern bewusst nichts - sonst zu viele Zufallstreffer über den
Ähnlichkeits-Schwellenwert von pg_trgm."""
q = q.strip() q = q.strip()
if len(q) < 2: if len(q) < 2:
return [] return []
muster = f"%{q}%" muster = f"%{q}%"
aehnlichkeit = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q))
stmt = ( aehnlichkeit_objekt = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q))
select(Objekt.id, Objekt.code, Objekt.name) direkt_stmt = (
.where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit > 0.2)) select(Objekt.id, Objekt.code, Objekt.name, aehnlichkeit_objekt.label("score"))
.order_by(aehnlichkeit.desc()) .where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit_objekt > 0.2))
.order_by(aehnlichkeit_objekt.desc())
.limit(_TREFFER_LIMIT) .limit(_TREFFER_LIMIT)
) )
result = await db.execute(stmt) direkt_ergebnis = (await db.execute(direkt_stmt)).all()
return [SuchTreffer(id=r.id, code=r.code, name=r.name) for r in result.all()]
aehnlichkeit_material = func.similarity(Material.name, q)
material_stmt = (
select(Objekt.id, Objekt.code, Objekt.name, Material.name.label("material_name"), aehnlichkeit_material.label("score"))
.join(Objektposition, Objektposition.objekt_id == Objekt.id)
.join(Material, Material.id == Objektposition.material_id)
.where(
Objektposition.status == ObjektpositionStatus.aktiv,
or_(Material.name.ilike(muster), aehnlichkeit_material > 0.2),
)
.order_by(aehnlichkeit_material.desc())
.limit(_TREFFER_LIMIT)
)
material_ergebnis = (await db.execute(material_stmt)).all()
# Zusammenführen: direkter Objekt-Treffer schlägt Material-Treffer für
# dasselbe Objekt (kein "über Material XY" anzeigen, wenn der Objektname
# selbst schon passt), sonst höherer Score gewinnt bei Mehrfachtreffer.
kandidaten: dict[int, SuchTreffer] = {}
scores: dict[int, float] = {}
for row in direkt_ergebnis:
kandidaten[row.id] = SuchTreffer(id=row.id, code=row.code, name=row.name)
scores[row.id] = float(row.score or 0)
for row in material_ergebnis:
score = float(row.score or 0)
if row.id in kandidaten:
continue
if row.id not in scores or score > scores[row.id]:
kandidaten[row.id] = SuchTreffer(
id=row.id, code=row.code, name=row.name, ueber_material=row.material_name
)
scores[row.id] = score
treffer = sorted(kandidaten.values(), key=lambda t: scores[t.id], reverse=True)
return treffer[:_TREFFER_LIMIT]
+3
View File
@@ -5,3 +5,6 @@ class SuchTreffer(BaseModel):
id: int id: int
code: str code: str
name: str name: str
# Gesetzt, wenn das Objekt NICHT über eigenen Name/Code getroffen wurde,
# sondern weil es (aktiv) ein Material mit passendem Namen führt.
ueber_material: str | None = None
+27
View File
@@ -30,3 +30,30 @@ async def test_suche_zu_kurze_eingabe_liefert_leer(client, objekt_mit_position,
async def test_suche_ohne_login_abgelehnt(client, objekt_mit_position): async def test_suche_ohne_login_abgelehnt(client, objekt_mit_position):
antwort = await client.get("/api/v1/suche?q=Kontroll") antwort = await client.get("/api/v1/suche?q=Kontroll")
assert antwort.status_code == 401 assert antwort.status_code == 401
@pytest.mark.asyncio
async def test_suche_findet_objekt_ueber_materialname(client, objekt_mit_position, mitarbeiter_user):
"""SEARCH-002: Objekt hat keine "Kompresse" im eigenen Namen/Code, führt
das Material aber - muss trotzdem auffindbar sein, mit ueber_material-Hinweis."""
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
antwort = await client.get("/api/v1/suche?q=Kompresse", headers=auth_header(token))
assert antwort.status_code == 200
treffer = [t for t in antwort.json() if t["id"] == objekt.id]
assert len(treffer) == 1
assert treffer[0]["ueber_material"] == "Kompresse"
@pytest.mark.asyncio
async def test_suche_direkter_objekt_treffer_hat_vorrang_vor_material(client, objekt_mit_position, mitarbeiter_user):
"""Wenn Objektname/-code selbst passt, kein ueber_material-Hinweis, auch
wenn zufällig auch ein Material ähnlich heißen würde."""
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
antwort = await client.get("/api/v1/suche?q=Kontroll-Test", headers=auth_header(token))
treffer = [t for t in antwort.json() if t["id"] == objekt.id]
assert len(treffer) == 1
assert treffer[0]["ueber_material"] is None
+2 -1
View File
@@ -8,6 +8,7 @@ interface SuchTreffer {
id: number; id: number;
code: string; code: string;
name: string; name: string;
ueber_material?: string | null;
} }
interface Eintrag { interface Eintrag {
@@ -114,7 +115,7 @@ export function CommandPalette() {
objektTreffer.map((o) => ({ objektTreffer.map((o) => ({
id: `objekt-${o.id}`, id: `objekt-${o.id}`,
label: o.name, label: o.name,
hinweis: o.code, hinweis: o.ueber_material ? `${o.code} · führt „${o.ueber_material}` : o.code,
gehe: () => navigate(`/akte/objekt/${o.id}`), gehe: () => navigate(`/akte/objekt/${o.id}`),
})), })),
[objektTreffer, navigate] [objektTreffer, navigate]