From 77371fafc9b152400123e2a0f6610905f189248b Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 1 Apr 2026 13:52:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Typeahead-Vorschl=C3=A4ge=20bei=20Vollt?= =?UTF-8?q?extsuche=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/vorlagen/suggest?q= mit FTS5 Prefix-Matching - Debounce 300ms, Top 7 Vorschläge mit Snippet - Keyboard-Navigation (↑↓ Enter Escape) - Klick/Enter → direkt zur Vorlage - Enter ohne Auswahl → normale Filtersuche --- backend/src/tracker/api/routes/vorlagen.py | 59 +++++++++++++++ frontend/src/lib/api.ts | 11 +++ frontend/src/routes/vorlagen/+page.svelte | 86 +++++++++++++++++++++- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/backend/src/tracker/api/routes/vorlagen.py b/backend/src/tracker/api/routes/vorlagen.py index 82ae58c..6a5feaf 100644 --- a/backend/src/tracker/api/routes/vorlagen.py +++ b/backend/src/tracker/api/routes/vorlagen.py @@ -29,6 +29,65 @@ def _db(): conn.close() +@router.get("/suggest") +def suggest(q: str = Query("", min_length=2, max_length=200), conn=Depends(_db)): + """Typeahead suggestions using FTS5.""" + has_fts = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='vorlagen_fts'" + ).fetchone() + if not has_fts or not q.strip(): + return {"items": []} + + # Build prefix query for typeahead (last word gets *) + words = q.strip().split() + parts = [f'"{w}"' for w in words[:-1]] + parts.append(f'"{words[-1]}"*') # prefix match on last word + fts_query = " ".join(parts) + + try: + rows = conn.execute( + """SELECT rowid, + snippet(vorlagen_fts, 1, '', '', '…', 10) as snip + FROM vorlagen_fts + WHERE vorlagen_fts MATCH ? + ORDER BY rank + LIMIT 7""", + (fts_query,), + ).fetchall() + except Exception: + return {"items": []} + + if not rows: + return {"items": []} + + ids = [r["rowid"] for r in rows] + placeholders = ",".join("?" * len(ids)) + vorlagen = conn.execute( + f"""SELECT id, aktenzeichen, betreff, typ + FROM vorlagen WHERE id IN ({placeholders})""", + ids, + ).fetchall() + v_map = {v["id"]: v for v in vorlagen} + snip_map = {r["rowid"]: r["snip"] for r in rows} + + items = [] + for rid in ids: + v = v_map.get(rid) + if v: + items.append({ + "id": v["id"], + "aktenzeichen": v["aktenzeichen"], + "betreff": v["betreff"], + "typ": v["typ"], + "snippet": snip_map.get(rid, ""), + }) + + return {"items": items} + + + + + @router.get("") def list_vorlagen( page: int = Query(1, ge=1), diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dc372f8..b604544 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -204,6 +204,17 @@ export const reevalKette = (id: number, anmerkung: string) => export const fetchJobStatus = (jobId: string) => get<{ status: string; result?: object; error?: string }>(`/bewertung/status/${jobId}`); +export interface SuchVorschlag { + id: number; + aktenzeichen: string | null; + betreff: string | null; + typ: string | null; + snippet?: string; +} + +export const fetchSuchvorschlaege = (q: string) => + get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`); + export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen'); export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => { const params = jahr ? `?jahr=${jahr}` : ''; diff --git a/frontend/src/routes/vorlagen/+page.svelte b/frontend/src/routes/vorlagen/+page.svelte index c90850b..d14495e 100644 --- a/frontend/src/routes/vorlagen/+page.svelte +++ b/frontend/src/routes/vorlagen/+page.svelte @@ -1,7 +1,7 @@