diff --git a/llm-api/plan_router.py b/llm-api/plan_router.py index 339da1d..24dc743 100644 --- a/llm-api/plan_router.py +++ b/llm-api/plan_router.py @@ -1,18 +1,9 @@ # -*- coding: utf-8 -*- """ -plan_router.py – v0.12.6 (WP-15) +plan_router.py – v0.13.0 (WP-15) -Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint -+ List-/Filter-Endpoints – robust & dokumentiert. - -Änderungen ggü. v0.12.4: -- FIX: POST /plan materialisiert jetzt `plan_section_names` (dedupliziert, getrimmt, sortiert) -- FIX: GET /plans filtert ausschließlich über `plan_section_names` (kein verschachteltes `sections.name` mehr) -- Doku: Swagger-Descriptions/Beispiele für alle neuen Query-Parameter -- Beibehalt: optionale Strict-Validierung von Exercises via ENV `PLAN_STRICT_EXERCISES` -- Beibehalt: Referenz-Validierung `template_id` → 422, wenn unbekannt - -Voraussetzung: KEYWORD-Index für `plans.plan_section_names` durch bootstrap-script (v1.2.x) +Minimal-CRUD + List/Filter für Templates & Pläne. +Fix: Zeitfenster-Filter per Qdrant-Range über `created_at_ts` (FLOAT). """ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field @@ -24,7 +15,10 @@ import json import os from clients import model, qdrant -from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue, VectorParams, Distance +from qdrant_client.models import ( + PointStruct, Filter, FieldCondition, MatchValue, + VectorParams, Distance, Range +) router = APIRouter(tags=["plans"]) @@ -105,7 +99,6 @@ class PlanList(BaseModel): # ----------------- def _ensure_collection(name: str): - """Legt Collection an, wenn sie fehlt (analog exercise_router).""" if not qdrant.collection_exists(name): qdrant.recreate_collection( collection_name=name, @@ -114,7 +107,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() @@ -144,7 +136,6 @@ def _embed(text: str): def _fingerprint_for_plan(p: Plan) -> str: - """sha256(title, total_minutes, sections.items.exercise_external_id, sections.items.duration)""" core = { "title": p.title, "total_minutes": int(p.total_minutes), @@ -169,7 +160,6 @@ def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, A def _as_model(model_cls, payload: Dict[str, Any]): - """Filtert unbekannte Payload-Felder heraus (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} @@ -337,8 +327,26 @@ def create_plan(p: Plan): # Normalisieren + Materialisierung p.goals = _norm_list(p.goals) payload = p.model_dump() - if isinstance(payload.get("created_at"), datetime): - payload["created_at"] = payload["created_at"].astimezone(timezone.utc).isoformat() + + # created_at → ISO + numerischer Zeitstempel (FLOAT) + dt = payload.get("created_at") + if isinstance(dt, datetime): + dt = dt.astimezone(timezone.utc).isoformat() + elif isinstance(dt, str): + # sicherheitshalber nach UTC normalisieren + try: + _ = datetime.fromisoformat(dt.replace("Z", "+00:00")) + except Exception: + dt = datetime.now(timezone.utc).isoformat() + else: + dt = datetime.now(timezone.utc).isoformat() + payload["created_at"] = dt + try: + ts = datetime.fromisoformat(dt.replace("Z", "+00:00")).timestamp() + except Exception: + ts = datetime.now(timezone.utc).timestamp() + payload["created_at_ts"] = float(ts) + # Materialisierte Section-Namen für robuste Filter/Indizes try: payload["plan_section_names"] = _norm_list([ @@ -382,7 +390,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 (lokal ausgewertet).\n\n" + "- created_from / created_to: ISO-8601 Zeitfenster → serverseitiger Range-Filter über `created_at_ts` (FLOAT).\n\n" "**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)." ), ) @@ -413,12 +421,24 @@ def list_plans( if section: must.append(FieldCondition(key="plan_section_names", match=MatchValue(value=section))) + # Range-Filter über numerisches Feld (FLOAT) + range_args: Dict[str, float] = {} + try: + if created_from: + range_args["gte"] = float(datetime.fromisoformat(created_from.replace("Z", "+00:00")).timestamp()) + if created_to: + range_args["lte"] = float(datetime.fromisoformat(created_to.replace("Z", "+00:00")).timestamp()) + except Exception: + range_args = {} + if range_args: + must.append(FieldCondition(key="created_at_ts", range=Range(**range_args))) + flt = Filter(must=must or None) if must else None fetch_n = max(offset + limit, 1) pts, _ = qdrant.scroll(collection_name=PLAN_COLLECTION, scroll_filter=flt, limit=fetch_n, with_payload=True) - # optionales Zeitfenster lokal anwenden + # Fallback: lokaler Zeitfilter (für Alt-Daten ohne created_at_ts) def _in_window(py: Dict[str, Any]) -> bool: if not (created_from or created_to): return True @@ -456,4 +476,4 @@ def list_plans( sliced = payloads[offset:offset+limit] items = [_as_model(Plan, x) for x in sliced] - return PlanList(items=items, limit=limit, offset=offset, count=len(items)) + return PlanList(items=items, limit=limit, offset=offset, count=len(items)) \ No newline at end of file