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:47:18 +02:00
parent 0b34b85a5a
commit 249f1aeea0

View File

@ -1,13 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
plan_router.py v0.13.3 (WP-15) plan_router.py v0.13.4 (WP-15)
Minimal-CRUD + List/Filter für Templates & Pläne. Änderungen ggü. v0.13.3
- Idempotenter POST /plan: Wenn ein Plan mit gleichem Fingerprint existiert und die neue
Änderungen ggü. v0.13.2 Anfrage ein späteres `created_at` trägt, wird der gespeicherte Plan mit dem neueren
- /plans: Mehrseitiges Scrollen, bis mindestens offset+limit Treffer eingesammelt sind. `created_at` und `created_at_ts` aktualisiert (kein Duplikat, aber zeitlich frisch).
- Stabilisiert Zeitfenster-Filter in großen Collections; verhindert leere Resultate, - /plans: Mehrseitiges Scrollen bleibt aktiv; Zeitfenster-Filter robust (serverseitig + Fallback).
wenn gesuchte Items nicht auf der ersten Scroll-Seite liegen.
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -41,8 +40,8 @@ class TemplateSection(BaseModel):
name: str name: str
target_minutes: int target_minutes: int
must_keywords: List[str] = [] must_keywords: List[str] = []
ideal_keywords: List[str] = [] # wünschenswert ideal_keywords: List[str] = []
supplement_keywords: List[str] = [] # ergänzend supplement_keywords: List[str] = []
forbid_keywords: List[str] = [] forbid_keywords: List[str] = []
capability_targets: Dict[str, int] = {} capability_targets: Dict[str, int] = {}
@ -147,14 +146,15 @@ def _fingerprint_for_plan(p: Plan) -> str:
raw = json.dumps(core, sort_keys=True, ensure_ascii=False) raw = json.dumps(core, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest() return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, Any]]: def _get_by_field(collection: str, key: str, value: Any):
flt = Filter(must=[FieldCondition(key=key, match=MatchValue(value=value))]) flt = Filter(must=[FieldCondition(key=key, match=MatchValue(value=value))])
pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=True) pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=True)
if not pts: if not pts:
return None return None
payload = dict(pts[0].payload or {}) point = pts[0]
payload.setdefault("id", str(pts[0].id)) payload = dict(point.payload or {})
return payload payload.setdefault("id", str(point.id))
return {"id": point.id, "payload": payload}
def _as_model(model_cls, payload: Dict[str, Any]): def _as_model(model_cls, payload: Dict[str, Any]):
fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {}) fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {})
@ -170,8 +170,13 @@ 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) pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=False)
return bool(pts) return bool(pts)
def _parse_iso_to_ts(iso_str: str) -> float:
try:
return float(datetime.fromisoformat(iso_str.replace("Z", "+00:00")).timestamp())
except Exception:
return float(datetime.now(timezone.utc).timestamp())
def _scroll_collect(collection: str, flt: Optional[Filter], need: int, page: int = 256): 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 = [] out = []
offset = None offset = None
page = max(1, min(page, 1024)) page = max(1, min(page, 1024))
@ -231,7 +236,7 @@ def get_plan_template(tpl_id: str):
found = _get_by_field(PLAN_TEMPLATE_COLLECTION, "id", tpl_id) found = _get_by_field(PLAN_TEMPLATE_COLLECTION, "id", tpl_id)
if not found: if not found:
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
return _as_model(PlanTemplate, found) return _as_model(PlanTemplate, found["payload"])
@router.get( @router.get(
"/plan_templates", "/plan_templates",
@ -271,18 +276,10 @@ def list_plan_templates(
if goal: if goal:
must.append(FieldCondition(key="goals", match=MatchValue(value=goal))) must.append(FieldCondition(key="goals", match=MatchValue(value=goal)))
if keyword: if keyword:
for k in ( for k in ("section_must_keywords","section_ideal_keywords","section_supplement_keywords","section_forbid_keywords"):
"section_must_keywords",
"section_ideal_keywords",
"section_supplement_keywords",
"section_forbid_keywords",
):
should.append(FieldCondition(key=k, match=MatchValue(value=keyword))) should.append(FieldCondition(key=k, match=MatchValue(value=keyword)))
flt = None flt = Filter(must=must or None, should=should or None) if (must or should) else None
if must or should:
flt = Filter(must=must or None, should=should or None)
need = max(offset + limit, 1) need = max(offset + limit, 1)
pts = _scroll_collect(PLAN_TEMPLATE_COLLECTION, flt, need) pts = _scroll_collect(PLAN_TEMPLATE_COLLECTION, flt, need)
items: List[PlanTemplate] = [] items: List[PlanTemplate] = []
@ -302,7 +299,7 @@ def list_plan_templates(
description=( description=(
"Erstellt einen konkreten Trainingsplan.\n\n" "Erstellt einen konkreten Trainingsplan.\n\n"
"Idempotenz: gleicher Fingerprint (title + items) → gleicher Plan (kein Duplikat).\n" "Idempotenz: gleicher Fingerprint (title + items) → gleicher Plan (kein Duplikat).\n"
"Optional: Validierung von template_id und Exercises (Strict-Mode)." "Bei erneutem POST mit späterem `created_at` wird `created_at`/`created_at_ts` des bestehenden Plans aktualisiert."
), ),
) )
def create_plan(p: Plan): def create_plan(p: Plan):
@ -324,19 +321,13 @@ def create_plan(p: Plan):
if missing: if missing:
raise HTTPException(status_code=422, detail={"error": "unknown exercise_external_id", "missing": sorted(set(missing))}) raise HTTPException(status_code=422, detail={"error": "unknown exercise_external_id", "missing": sorted(set(missing))})
# Fingerprint + Idempotenz # Fingerprint
fp = _fingerprint_for_plan(p) fp = _fingerprint_for_plan(p)
p.fingerprint = p.fingerprint or fp p.fingerprint = p.fingerprint or fp
existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint)
if existing:
return _as_model(Plan, existing)
# Normalisieren + Materialisierung # Ziel-ISO + TS aus Request berechnen (auch wenn Duplikat)
p.goals = _norm_list(p.goals) req_payload = p.model_dump()
payload = p.model_dump() dt = req_payload.get("created_at")
# created_at → ISO + numerischer Zeitstempel (FLOAT)
dt = payload.get("created_at")
if isinstance(dt, datetime): if isinstance(dt, datetime):
dt = dt.astimezone(timezone.utc).isoformat() dt = dt.astimezone(timezone.utc).isoformat()
elif isinstance(dt, str): elif isinstance(dt, str):
@ -346,22 +337,53 @@ def create_plan(p: Plan):
dt = datetime.now(timezone.utc).isoformat() dt = datetime.now(timezone.utc).isoformat()
else: else:
dt = datetime.now(timezone.utc).isoformat() dt = datetime.now(timezone.utc).isoformat()
payload["created_at"] = dt req_payload["created_at"] = dt
try: req_ts = _parse_iso_to_ts(dt)
ts = datetime.fromisoformat(dt.replace("Z", "+00:00")).timestamp() req_payload["created_at_ts"] = float(req_ts)
except Exception:
ts = datetime.now(timezone.utc).timestamp()
payload["created_at_ts"] = float(ts)
# Materialisierte Section-Namen (robuste Filter/Indizes) # Dup-Check
try: existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint)
payload["plan_section_names"] = _norm_list([ if existing:
(s.get("name") or "").strip() for s in (payload.get("sections") or []) if isinstance(s, dict) # Falls neues created_at später ist → gespeicherten Plan aktualisieren
]) cur = existing["payload"]
except Exception: cur_ts = cur.get("created_at_ts")
payload["plan_section_names"] = _norm_list([ if cur_ts is None:
(getattr(s, "name", "") or "").strip() for s in (p.sections or []) cur_ts = _parse_iso_to_ts(str(cur.get("created_at", dt)))
]) if req_ts > float(cur_ts):
try:
qdrant.set_payload(
collection_name=PLAN_COLLECTION,
payload={"created_at": req_payload["created_at"], "created_at_ts": req_payload["created_at_ts"]},
points=[existing["id"]],
)
# Antwort-Objekt aktualisieren
cur["created_at"] = req_payload["created_at"]
cur["created_at_ts"] = req_payload["created_at_ts"]
except Exception:
pass
return _as_model(Plan, cur)
# Neu anlegen
p.goals = _norm_list(p.goals)
payload = req_payload # enthält bereits korrektes created_at + created_at_ts
payload.update({
"id": p.id,
"template_id": p.template_id,
"title": p.title,
"discipline": p.discipline,
"age_group": p.age_group,
"target_group": p.target_group,
"total_minutes": p.total_minutes,
"sections": [s.model_dump() for s in p.sections],
"goals": _norm_list(p.goals),
"capability_summary": p.capability_summary,
"novelty_against_last_n": p.novelty_against_last_n,
"fingerprint": p.fingerprint,
"created_by": p.created_by,
"source": p.source,
})
# Section-Namen materialisieren
payload["plan_section_names"] = _norm_list([ (s.get("name") or "").strip() for s in (payload.get("sections") or []) if isinstance(s, dict) ])
vec = _embed(_plan_embed_text(p)) vec = _embed(_plan_embed_text(p))
qdrant.upsert(collection_name=PLAN_COLLECTION, points=[PointStruct(id=str(p.id), vector=vec, payload=payload)]) qdrant.upsert(collection_name=PLAN_COLLECTION, points=[PointStruct(id=str(p.id), vector=vec, payload=payload)])
@ -378,12 +400,13 @@ def get_plan(plan_id: str):
found = _get_by_field(PLAN_COLLECTION, "id", plan_id) found = _get_by_field(PLAN_COLLECTION, "id", plan_id)
if not found: if not found:
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
if isinstance(found.get("created_at"), str): payload = found["payload"]
if isinstance(payload.get("created_at"), str):
try: try:
found["created_at"] = datetime.fromisoformat(found["created_at"]) payload["created_at"] = datetime.fromisoformat(payload["created_at"])
except Exception: except Exception:
pass pass
return _as_model(Plan, found) return _as_model(Plan, payload)
@router.get( @router.get(
"/plans", "/plans",
@ -457,13 +480,12 @@ def list_plans(
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
# Wenn serverseitig Range aktiv war und Treffer kamen, brauchen wir keinen lokalen Check
if applied_server_range and not fallback_local_time_check: if applied_server_range and not fallback_local_time_check:
return True return True # serverseitig bereits gefiltert
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, (int, float)) and py.get("created_at_ts") is not None: if isinstance(py.get("created_at_ts"), (int, float)):
dt = datetime.fromtimestamp(float(py["created_at_ts"]), tz=timezone.utc) dt = datetime.fromtimestamp(float(py["created_at_ts"]), tz=timezone.utc)
elif isinstance(ts, str): elif isinstance(ts, str):
try: try: