diff --git a/llm-api/plan_router.py b/llm-api/plan_router.py index ae16a3d..7fffe06 100644 --- a/llm-api/plan_router.py +++ b/llm-api/plan_router.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- """ -plan_router.py – v0.13.1 (WP-15) +plan_router.py – v0.13.2 (WP-15) Minimal-CRUD + List/Filter für Templates & Pläne. -Änderungen ggü. v0.13.0 -- Serverseitiger Zeitfenster-Filter über `created_at_ts` (FLOAT) bleibt erhalten. -- Lokaler Fallback-Zeitfilter wird DEAKTIVIERT, sobald ein serverseitiger Range aktiv ist - (verhindert false negatives). -- `plan_section_names` wird beim POST /plan materialisiert und für Filters genutzt. +Ä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. """ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field @@ -102,9 +101,7 @@ class PlanList(BaseModel): # ----------------- # Helpers # ----------------- - def _ensure_collection(name: str): - """Falls Collection fehlt, analog exercise_router anlegen.""" if not qdrant.collection_exists(name): qdrant.recreate_collection( collection_name=name, @@ -112,7 +109,6 @@ def _ensure_collection(name: str): ) def _norm_list(xs: List[str]) -> List[str]: - """Trimmen, casefolded deduplizieren, stabil sortieren.""" seen, out = set(), [] for x in xs or []: s = str(x).strip() @@ -161,7 +157,6 @@ def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, A return payload def _as_model(model_cls, payload: Dict[str, Any]): - """Unbekannte Payload-Felder herausfiltern (Pydantic v1/v2 kompatibel).""" fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {}) allowed = set(fields.keys()) data = {k: payload[k] for k in payload.keys() if k in allowed} @@ -344,7 +339,7 @@ def create_plan(p: Plan): ts = datetime.now(timezone.utc).timestamp() payload["created_at_ts"] = float(ts) - # Materialisierte Section-Namen für robuste Filter/Indizes + # Materialisierte Section-Namen (robuste Filter/Indizes) try: payload["plan_section_names"] = _norm_list([ (s.get("name") or "").strip() for s in (payload.get("sections") or []) if isinstance(s, dict) @@ -385,7 +380,7 @@ 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).\n\n" + "- created_from / created_to: ISO-8601 Zeitfenster → serverseitiger Range-Filter über `created_at_ts` (FLOAT). Bei 0 Treffern wird lokal gefiltert.\n\n" "**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)." ), ) @@ -402,21 +397,23 @@ def list_plans( offset: int = Query(0, ge=0, description="Start-Offset für Paging"), ): _ensure_collection(PLAN_COLLECTION) - must: List[Any] = [] - if created_by: - must.append(FieldCondition(key="created_by", match=MatchValue(value=created_by))) - if discipline: - must.append(FieldCondition(key="discipline", match=MatchValue(value=discipline))) - if age_group: - must.append(FieldCondition(key="age_group", match=MatchValue(value=age_group))) - if target_group: - must.append(FieldCondition(key="target_group", match=MatchValue(value=target_group))) - if goal: - must.append(FieldCondition(key="goals", match=MatchValue(value=goal))) - if section: - must.append(FieldCondition(key="plan_section_names", match=MatchValue(value=section))) - # Range-Filter über numerisches Feld (FLOAT) + # 1) Grundfilter (ohne Zeit) + base_must: List[Any] = [] + if created_by: + base_must.append(FieldCondition(key="created_by", match=MatchValue(value=created_by))) + if discipline: + base_must.append(FieldCondition(key="discipline", match=MatchValue(value=discipline))) + if age_group: + base_must.append(FieldCondition(key="age_group", match=MatchValue(value=age_group))) + if target_group: + base_must.append(FieldCondition(key="target_group", match=MatchValue(value=target_group))) + if goal: + base_must.append(FieldCondition(key="goals", match=MatchValue(value=goal))) + if section: + base_must.append(FieldCondition(key="plan_section_names", match=MatchValue(value=section))) + + # 2) Serverseitiger Zeitbereich range_args: Dict[str, float] = {} try: if created_from: @@ -427,24 +424,30 @@ def list_plans( range_args = {} applied_server_range = bool(range_args) + must_with_time = list(base_must) if applied_server_range: - must.append(FieldCondition(key="created_at_ts", range=Range(**range_args))) + must_with_time.append(FieldCondition(key="created_at_ts", range=Range(**range_args))) - flt = Filter(must=must or None) if must else None + # 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) - fetch_n = max(offset + limit, 1) - pts, _ = qdrant.scroll(collection_name=PLAN_COLLECTION, scroll_filter=flt, limit=fetch_n, with_payload=True) + # 4) Fallback: wenn serverseitig gefiltert wurde und 0 Treffer → zweite Scroll ohne Zeit-Range, lokal filtern + 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) - # Fallback: nur wenn KEIN serverseitiger Range aktiv war (Alt-Daten ohne created_at_ts) + # 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 applied_server_range: - return True if not (created_from or created_to): return True ts = py.get("created_at") if isinstance(ts, dict) and ts.get("$date"): ts = ts["$date"] - if isinstance(ts, str): + if isinstance(ts, (int, float)) and py.get("created_at_ts") is not None: + dt = datetime.fromtimestamp(float(py["created_at_ts"]), tz=timezone.utc) + elif isinstance(ts, str): try: dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) except Exception: @@ -470,8 +473,19 @@ def list_plans( for p in pts: py = dict(p.payload or {}) py.setdefault("id", str(p.id)) - if _in_window(py): - payloads.append(py) + # 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) sliced = payloads[offset:offset+limit] items = [_as_model(Plan, x) for x in sliced]