From 0b34b85a5ab9792215762dbb8a327b0509e60b07 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 Aug 2025 11:37:10 +0200 Subject: [PATCH] llm-api/plan_router.py aktualisiert --- llm-api/plan_router.py | 69 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/llm-api/plan_router.py b/llm-api/plan_router.py index 7fffe06..bb6f101 100644 --- a/llm-api/plan_router.py +++ b/llm-api/plan_router.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- """ -plan_router.py – v0.13.2 (WP-15) +plan_router.py – v0.13.3 (WP-15) Minimal-CRUD + List/Filter für Templates & Pläne. -Änderungen ggü. v0.13.1 -- Robuster Zeitfenster-Fallback: Wenn der serverseitige Range-Filter (created_at_ts) 0 Treffer liefert, - wird eine zweite Scroll-Abfrage ohne Zeit-Range gefahren und lokal nach created_from/created_to gefiltert. -- plan_section_names bleibt Materialisierung & Filter-Basis. +Änderungen ggü. v0.13.2 +- /plans: Mehrseitiges Scrollen, bis mindestens offset+limit Treffer eingesammelt sind. +- Stabilisiert Zeitfenster-Filter in großen Collections; verhindert leere Resultate, + wenn gesuchte Items nicht auf der ersten Scroll-Seite liegen. """ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field @@ -170,6 +170,20 @@ def _exists_in_collection(collection: str, key: str, value: Any) -> bool: pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=False) return bool(pts) +def _scroll_collect(collection: str, flt: Optional[Filter], need: int, page: int = 256): + """Scrollt mehrere Seiten und sammelt mind. `need` Punkte ein (oder bis keine mehr kommen).""" + out = [] + offset = None + page = max(1, min(page, 1024)) + while len(out) < need: + pts, offset = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=min(page, need - len(out)), with_payload=True, offset=offset) + if not pts: + break + out.extend(pts) + if offset is None: + break + return out + # ----------------- # Endpoints: Templates # ----------------- @@ -269,8 +283,8 @@ def list_plan_templates( if must or should: flt = Filter(must=must or None, should=should or None) - fetch_n = max(offset + limit, 1) - pts, _ = qdrant.scroll(collection_name=PLAN_TEMPLATE_COLLECTION, scroll_filter=flt, limit=fetch_n, with_payload=True) + need = max(offset + limit, 1) + pts = _scroll_collect(PLAN_TEMPLATE_COLLECTION, flt, need) items: List[PlanTemplate] = [] for p in pts[offset:offset+limit]: payload = dict(p.payload or {}) @@ -380,7 +394,8 @@ def get_plan(plan_id: str): "**Filter** (exakte Matches, KEYWORD-Felder):\n" "- created_by, discipline, age_group, target_group, goal\n" "- section: Section-Name (nutzt materialisiertes `plan_section_names`)\n" - "- created_from / created_to: ISO-8601 Zeitfenster → serverseitiger Range-Filter über `created_at_ts` (FLOAT). Bei 0 Treffern wird lokal gefiltert.\n\n" + "- created_from / created_to: ISO-8601 Zeitfenster → serverseitiger Range-Filter über `created_at_ts` (FLOAT). " + "Falls 0 Treffer: zweiter Durchlauf ohne Zeit-Range + lokale Zeitprüfung.\n\n" "**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)." ), ) @@ -398,7 +413,7 @@ def list_plans( ): _ensure_collection(PLAN_COLLECTION) - # 1) Grundfilter (ohne Zeit) + # Grundfilter (ohne Zeit) base_must: List[Any] = [] if created_by: base_must.append(FieldCondition(key="created_by", match=MatchValue(value=created_by))) @@ -413,7 +428,7 @@ def list_plans( if section: base_must.append(FieldCondition(key="plan_section_names", match=MatchValue(value=section))) - # 2) Serverseitiger Zeitbereich + # Serverseitiger Zeitbereich range_args: Dict[str, float] = {} try: if created_from: @@ -428,20 +443,23 @@ def list_plans( if applied_server_range: must_with_time.append(FieldCondition(key="created_at_ts", range=Range(**range_args))) - # 3) Erste Scroll: mit Zeit-Range (falls gesetzt) - fetch_n = max(offset + limit, 100) # etwas großzügiger, um Tests sicher zu treffen - flt_time = Filter(must=must_with_time or None) if must_with_time else None - pts, _ = qdrant.scroll(collection_name=PLAN_COLLECTION, scroll_filter=flt_time, limit=fetch_n, with_payload=True) + need = max(offset + limit, 1) - # 4) Fallback: wenn serverseitig gefiltert wurde und 0 Treffer → zweite Scroll ohne Zeit-Range, lokal filtern + # 1) Scroll mit Zeit-Range (falls vorhanden) + pts = _scroll_collect(PLAN_COLLECTION, Filter(must=must_with_time or None) if must_with_time else None, need) + + # 2) Fallback: 0 Treffer → ohne Zeit-Range scrollen und lokal filtern + fallback_local_time_check = False if applied_server_range and not pts: - flt_no_time = Filter(must=base_must or None) if base_must else None - pts, _ = qdrant.scroll(collection_name=PLAN_COLLECTION, scroll_filter=flt_no_time, limit=fetch_n, with_payload=True) + pts = _scroll_collect(PLAN_COLLECTION, Filter(must=base_must or None) if base_must else None, need) + fallback_local_time_check = True - # 5) Lokaler Zeitfenster-Check (nur wenn keine serverseitige Zeit-Range gegriffen hat ODER Fallback aktiv war) def _in_window(py: Dict[str, Any]) -> bool: if not (created_from or created_to): return True + # Wenn serverseitig Range aktiv war und Treffer kamen, brauchen wir keinen lokalen Check + if applied_server_range and not fallback_local_time_check: + return True ts = py.get("created_at") if isinstance(ts, dict) and ts.get("$date"): ts = ts["$date"] @@ -473,19 +491,8 @@ def list_plans( for p in pts: py = dict(p.payload or {}) py.setdefault("id", str(p.id)) - # Wenn keine Zeit-Filter angegeben sind, oder wir im Fallback sind → prüfe lokal. - if not (created_from or created_to) or (applied_server_range and not payloads and not pts): - # (Die Bedingung oben wird praktisch nie gebraucht; belassen aus Klarheit.) - pass - if not applied_server_range: - # Kein serverseitiger Range → lokal prüfen - if not _in_window(py): - continue - # Wenn serverseitig aktiv war und wir trotzdem hier sind (Fallback-Pfad), lokal prüfen: - if applied_server_range and range_args and len(pts) >= 0: - if not _in_window(py): - continue - payloads.append(py) + if _in_window(py): + payloads.append(py) sliced = payloads[offset:offset+limit] items = [_as_model(Plan, x) for x in sliced]