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.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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user