llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
This commit is contained in:
parent
58d2260d89
commit
0c143124b3
|
|
@ -1,18 +1,9 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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
|
Minimal-CRUD + List/Filter für Templates & Pläne.
|
||||||
+ List-/Filter-Endpoints – robust & dokumentiert.
|
Fix: Zeitfenster-Filter per Qdrant-Range über `created_at_ts` (FLOAT).
|
||||||
|
|
||||||
Ä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)
|
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -24,7 +15,10 @@ import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from clients import model, qdrant
|
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"])
|
router = APIRouter(tags=["plans"])
|
||||||
|
|
||||||
|
|
@ -105,7 +99,6 @@ class PlanList(BaseModel):
|
||||||
# -----------------
|
# -----------------
|
||||||
|
|
||||||
def _ensure_collection(name: str):
|
def _ensure_collection(name: str):
|
||||||
"""Legt Collection an, wenn sie fehlt (analog exercise_router)."""
|
|
||||||
if not qdrant.collection_exists(name):
|
if not qdrant.collection_exists(name):
|
||||||
qdrant.recreate_collection(
|
qdrant.recreate_collection(
|
||||||
collection_name=name,
|
collection_name=name,
|
||||||
|
|
@ -114,7 +107,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()
|
||||||
|
|
@ -144,7 +136,6 @@ def _embed(text: str):
|
||||||
|
|
||||||
|
|
||||||
def _fingerprint_for_plan(p: Plan) -> str:
|
def _fingerprint_for_plan(p: Plan) -> str:
|
||||||
"""sha256(title, total_minutes, sections.items.exercise_external_id, sections.items.duration)"""
|
|
||||||
core = {
|
core = {
|
||||||
"title": p.title,
|
"title": p.title,
|
||||||
"total_minutes": int(p.total_minutes),
|
"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]):
|
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__", {})
|
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}
|
||||||
|
|
@ -337,8 +327,26 @@ def create_plan(p: Plan):
|
||||||
# Normalisieren + Materialisierung
|
# Normalisieren + Materialisierung
|
||||||
p.goals = _norm_list(p.goals)
|
p.goals = _norm_list(p.goals)
|
||||||
payload = p.model_dump()
|
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
|
# Materialisierte Section-Namen für robuste Filter/Indizes
|
||||||
try:
|
try:
|
||||||
payload["plan_section_names"] = _norm_list([
|
payload["plan_section_names"] = _norm_list([
|
||||||
|
|
@ -382,7 +390,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 (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)."
|
"**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -413,12 +421,24 @@ def list_plans(
|
||||||
if section:
|
if section:
|
||||||
must.append(FieldCondition(key="plan_section_names", match=MatchValue(value=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
|
flt = Filter(must=must or None) if must else None
|
||||||
|
|
||||||
fetch_n = max(offset + limit, 1)
|
fetch_n = max(offset + limit, 1)
|
||||||
pts, _ = qdrant.scroll(collection_name=PLAN_COLLECTION, scroll_filter=flt, limit=fetch_n, with_payload=True)
|
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:
|
def _in_window(py: Dict[str, Any]) -> bool:
|
||||||
if not (created_from or created_to):
|
if not (created_from or created_to):
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user