llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
This commit is contained in:
parent
5e2591fb56
commit
00a8837aa1
|
|
@ -1,14 +1,13 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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.
|
Minimal-CRUD + List/Filter für Templates & Pläne.
|
||||||
|
|
||||||
Änderungen ggü. v0.13.0
|
Änderungen ggü. v0.13.1
|
||||||
- Serverseitiger Zeitfenster-Filter über `created_at_ts` (FLOAT) bleibt erhalten.
|
- Robuster Zeitfenster-Fallback: Wenn der serverseitige Range-Filter (created_at_ts) 0 Treffer liefert,
|
||||||
- Lokaler Fallback-Zeitfilter wird DEAKTIVIERT, sobald ein serverseitiger Range aktiv ist
|
wird eine zweite Scroll-Abfrage ohne Zeit-Range gefahren und lokal nach created_from/created_to gefiltert.
|
||||||
(verhindert false negatives).
|
- plan_section_names bleibt Materialisierung & Filter-Basis.
|
||||||
- `plan_section_names` wird beim POST /plan materialisiert und für Filters genutzt.
|
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -102,9 +101,7 @@ class PlanList(BaseModel):
|
||||||
# -----------------
|
# -----------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# -----------------
|
# -----------------
|
||||||
|
|
||||||
def _ensure_collection(name: str):
|
def _ensure_collection(name: str):
|
||||||
"""Falls Collection fehlt, analog exercise_router anlegen."""
|
|
||||||
if not qdrant.collection_exists(name):
|
if not qdrant.collection_exists(name):
|
||||||
qdrant.recreate_collection(
|
qdrant.recreate_collection(
|
||||||
collection_name=name,
|
collection_name=name,
|
||||||
|
|
@ -112,7 +109,6 @@ def _ensure_collection(name: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
def _norm_list(xs: List[str]) -> List[str]:
|
def _norm_list(xs: List[str]) -> List[str]:
|
||||||
"""Trimmen, casefolded deduplizieren, stabil sortieren."""
|
|
||||||
seen, out = set(), []
|
seen, out = set(), []
|
||||||
for x in xs or []:
|
for x in xs or []:
|
||||||
s = str(x).strip()
|
s = str(x).strip()
|
||||||
|
|
@ -161,7 +157,6 @@ def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, A
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _as_model(model_cls, payload: Dict[str, Any]):
|
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__", {})
|
fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {})
|
||||||
allowed = set(fields.keys())
|
allowed = set(fields.keys())
|
||||||
data = {k: payload[k] for k in payload.keys() if k in allowed}
|
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()
|
ts = datetime.now(timezone.utc).timestamp()
|
||||||
payload["created_at_ts"] = float(ts)
|
payload["created_at_ts"] = float(ts)
|
||||||
|
|
||||||
# Materialisierte Section-Namen für robuste Filter/Indizes
|
# Materialisierte Section-Namen (robuste Filter/Indizes)
|
||||||
try:
|
try:
|
||||||
payload["plan_section_names"] = _norm_list([
|
payload["plan_section_names"] = _norm_list([
|
||||||
(s.get("name") or "").strip() for s in (payload.get("sections") or []) if isinstance(s, dict)
|
(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"
|
"**Filter** (exakte Matches, KEYWORD-Felder):\n"
|
||||||
"- created_by, discipline, age_group, target_group, goal\n"
|
"- created_by, discipline, age_group, target_group, goal\n"
|
||||||
"- section: Section-Name (nutzt materialisiertes `plan_section_names`)\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)."
|
"**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"),
|
offset: int = Query(0, ge=0, description="Start-Offset für Paging"),
|
||||||
):
|
):
|
||||||
_ensure_collection(PLAN_COLLECTION)
|
_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] = {}
|
range_args: Dict[str, float] = {}
|
||||||
try:
|
try:
|
||||||
if created_from:
|
if created_from:
|
||||||
|
|
@ -427,24 +424,30 @@ def list_plans(
|
||||||
range_args = {}
|
range_args = {}
|
||||||
|
|
||||||
applied_server_range = bool(range_args)
|
applied_server_range = bool(range_args)
|
||||||
|
must_with_time = list(base_must)
|
||||||
if applied_server_range:
|
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)
|
# 4) Fallback: wenn serverseitig gefiltert wurde und 0 Treffer → zweite Scroll ohne Zeit-Range, lokal filtern
|
||||||
pts, _ = qdrant.scroll(collection_name=PLAN_COLLECTION, scroll_filter=flt, limit=fetch_n, with_payload=True)
|
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:
|
def _in_window(py: Dict[str, Any]) -> bool:
|
||||||
if applied_server_range:
|
|
||||||
return True
|
|
||||||
if not (created_from or created_to):
|
if not (created_from or created_to):
|
||||||
return True
|
return True
|
||||||
ts = py.get("created_at")
|
ts = py.get("created_at")
|
||||||
if isinstance(ts, dict) and ts.get("$date"):
|
if isinstance(ts, dict) and ts.get("$date"):
|
||||||
ts = ts["$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:
|
try:
|
||||||
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -470,8 +473,19 @@ def list_plans(
|
||||||
for p in pts:
|
for p in pts:
|
||||||
py = dict(p.payload or {})
|
py = dict(p.payload or {})
|
||||||
py.setdefault("id", str(p.id))
|
py.setdefault("id", str(p.id))
|
||||||
if _in_window(py):
|
# Wenn keine Zeit-Filter angegeben sind, oder wir im Fallback sind → prüfe lokal.
|
||||||
payloads.append(py)
|
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]
|
sliced = payloads[offset:offset+limit]
|
||||||
items = [_as_model(Plan, x) for x in sliced]
|
items = [_as_model(Plan, x) for x in sliced]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user