llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s

This commit is contained in:
Lars 2025-08-13 11:37:10 +02:00
parent 00a8837aa1
commit 0b34b85a5a

View File

@ -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,18 +491,7 @@ 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
if _in_window(py):
payloads.append(py)
sliced = payloads[offset:offset+limit]