feat(search): SEARCH-002 Suchindex auch auf Materialnamen erweitert
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:
@@ -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")
|
||||
@@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,21 +20,58 @@ async def suche(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
) -> list[SuchTreffer]:
|
||||
"""Globale Suche (Command Palette) über Objektname/-code, per pg_trgm-
|
||||
Index statt vollständig geladener Objektliste + Client-Filter (SEARCH-001).
|
||||
Kurze Eingaben (<2 Zeichen) liefern bewusst nichts - sonst zu viele
|
||||
Zufallstreffer über den Ähnlichkeits-Schwellenwert von pg_trgm."""
|
||||
"""Globale Suche (Command Palette) über Objektname/-code UND Material-
|
||||
Namen (SEARCH-002: wer "Kompressen" sucht, will Objekte finden, die
|
||||
welche führen) - per pg_trgm-Index statt vollständig geladener
|
||||
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()
|
||||
if len(q) < 2:
|
||||
return []
|
||||
|
||||
muster = f"%{q}%"
|
||||
aehnlichkeit = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q))
|
||||
stmt = (
|
||||
select(Objekt.id, Objekt.code, Objekt.name)
|
||||
.where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit > 0.2))
|
||||
.order_by(aehnlichkeit.desc())
|
||||
|
||||
aehnlichkeit_objekt = func.greatest(func.similarity(Objekt.name, q), func.similarity(Objekt.code, q))
|
||||
direkt_stmt = (
|
||||
select(Objekt.id, Objekt.code, Objekt.name, aehnlichkeit_objekt.label("score"))
|
||||
.where(or_(Objekt.name.ilike(muster), Objekt.code.ilike(muster), aehnlichkeit_objekt > 0.2))
|
||||
.order_by(aehnlichkeit_objekt.desc())
|
||||
.limit(_TREFFER_LIMIT)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [SuchTreffer(id=r.id, code=r.code, name=r.name) for r in result.all()]
|
||||
direkt_ergebnis = (await db.execute(direkt_stmt)).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]
|
||||
|
||||
@@ -5,3 +5,6 @@ class SuchTreffer(BaseModel):
|
||||
id: int
|
||||
code: 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
|
||||
|
||||
@@ -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):
|
||||
antwort = await client.get("/api/v1/suche?q=Kontroll")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user