From c0de60e4a550c674cff2d4ac47beff8c29a6461f Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 Aug 2025 07:17:59 +0200 Subject: [PATCH] llm-api/plan_router.py aktualisiert --- llm-api/plan_router.py | 203 ++--------------------------------------- 1 file changed, 6 insertions(+), 197 deletions(-) diff --git a/llm-api/plan_router.py b/llm-api/plan_router.py index 46ab476..86f7151 100644 --- a/llm-api/plan_router.py +++ b/llm-api/plan_router.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- """ -plan_router.py – v0.12.0 (WP-15) +plan_router.py – v0.12.1 (WP-15) Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint. -NEU in v0.12.0: -- List-/Filter-Endpoints: GET /plan_templates, GET /plans -- Umfangreiche Swagger-Doku (summary/description, Query-Parameter mit Beschreibungen & Beispielen) +NEU in v0.12.1: +- Fix: Filter `section` bei `/plans` nutzt jetzt **materialisierte** `plan_section_names` (robust gg. Qdrant-Versionen). +- Neues Payload-Feld beim POST `/plan`: `plan_section_names` (Liste) wird automatisch gesetzt und indexiert. +- Swagger-Doku aktualisiert. """ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field @@ -244,196 +245,4 @@ def create_plan(p: Plan): missing: List[str] = [] for sec in p.sections or []: for it in sec.items or []: - exid = (it.exercise_external_id or "").strip() - if exid and not _exists_in_collection(EXERCISE_COLLECTION, "external_id", exid): - missing.append(exid) - if missing: - raise HTTPException(status_code=422, detail={"error": "unknown exercise_external_id", "missing": sorted(set(missing))}) - - fp = _fingerprint_for_plan(p) - p.fingerprint = p.fingerprint or fp - - existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint) - if existing: - return _as_model(Plan, existing) - - 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() - - vec = _embed(_plan_embed_text(p)) - qdrant.upsert(collection_name=PLAN_COLLECTION, points=[PointStruct(id=str(p.id), vector=vec, payload=payload)]) - return p - - -@router.get( - "/plan/{plan_id}", - response_model=Plan, - summary="Read a training plan by id", - description="Liest einen Plan anhand seiner ID. `created_at` wird (falls ISO-String) zu `datetime` geparst.", -) -def get_plan(plan_id: str): - _ensure_collection(PLAN_COLLECTION) - found = _get_by_field(PLAN_COLLECTION, "id", plan_id) - if not found: - raise HTTPException(status_code=404, detail="not found") - if isinstance(found.get("created_at"), str): - try: - found["created_at"] = datetime.fromisoformat(found["created_at"]) - except Exception: - pass - return _as_model(Plan, found) - -# ----------------- -# NEW: List-/Filter-Endpoints -# ----------------- -@router.get( - "/plan_templates", - response_model=PlanTemplateList, - summary="List plan templates (filterable)", - description=( - "Listet Plan-Templates mit Filtern.\n\n" - "**Filter** (exakte Matches, KEYWORD-Felder):\n" - "- discipline, age_group, target_group\n" - "- section: Section-Name (nutzt materialisierte `section_names`)\n" - "- goal: Ziel (nutzt `goals`)\n" - "- keyword: trifft auf beliebige Section-Keyword-Felder (must/ideal/supplement/forbid).\n\n" - "**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)." - ), -) -def list_plan_templates( - discipline: Optional[str] = Query(None, description="Filter: Disziplin (exaktes KEYWORD-Match)", example="Karate"), - age_group: Optional[str] = Query(None, description="Filter: Altersgruppe", example="Teenager"), - target_group: Optional[str] = Query(None, description="Filter: Zielgruppe", example="Breitensport"), - section: Optional[str] = Query(None, description="Filter: Section-Name (materialisiert)", example="Warmup"), - goal: Optional[str] = Query(None, description="Filter: Trainingsziel", example="Technik"), - keyword: Optional[str] = Query(None, description="Filter: Keyword in must/ideal/supplement/forbid", example="Koordination"), - limit: int = Query(20, ge=1, le=200, description="Max. Anzahl Items"), - offset: int = Query(0, ge=0, description="Start-Offset für Paging"), -): - _ensure_collection(PLAN_TEMPLATE_COLLECTION) - must: List[Any] = [] - should: List[Any] = [] - 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 section: - must.append(FieldCondition(key="section_names", match=MatchValue(value=section))) - if goal: - must.append(FieldCondition(key="goals", match=MatchValue(value=goal))) - if keyword: - for k in [ - "section_must_keywords", - "section_ideal_keywords", - "section_supplement_keywords", - "section_forbid_keywords", - ]: - should.append(FieldCondition(key=k, match=MatchValue(value=keyword))) - - flt = None - if must or should: - flt = Filter(must=must or None, should=should or None) - - # einfache Offset-Implementierung: wir holen (offset+limit) und slicen lokal - fetch_n = offset + limit - fetch_n = max(fetch_n, 1) - pts, _ = qdrant.scroll(collection_name=PLAN_TEMPLATE_COLLECTION, scroll_filter=flt, limit=fetch_n, with_payload=True) - items = [] - for p in pts[offset:offset+limit]: - payload = dict(p.payload or {}) - payload.setdefault("id", str(p.id)) - items.append(_as_model(PlanTemplate, payload)) - - return PlanTemplateList(items=items, limit=limit, offset=offset, count=len(items)) - - -@router.get( - "/plans", - response_model=PlanList, - summary="List training plans (filterable)", - description=( - "Listet Trainingspläne mit Filtern.\n\n" - "**Filter** (exakte Matches, KEYWORD-Felder):\n" - "- created_by, discipline, age_group, target_group, goal\n" - "- section: Section-Name (aktuell verschachteltes `sections.name`; optional später materialisieren als `plan_section_names`)\n" - "- created_from / created_to: ISO‑8601 Zeitfenster (lokal ausgewertet).\n\n" - "**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)." - ), -) -def list_plans( - created_by: Optional[str] = Query(None, description="Filter: Ersteller", example="tester"), - discipline: Optional[str] = Query(None, description="Filter: Disziplin", example="Karate"), - age_group: Optional[str] = Query(None, description="Filter: Altersgruppe", example="Teenager"), - target_group: Optional[str] = Query(None, description="Filter: Zielgruppe", example="Breitensport"), - goal: Optional[str] = Query(None, description="Filter: Trainingsziel", example="Technik"), - section: Optional[str] = Query(None, description="Filter: Section-Name", example="Warmup"), - created_from: Optional[str] = Query(None, description="Ab-Zeitpunkt (ISO 8601, z. B. 2025-08-12T00:00:00Z)", example="2025-08-12T00:00:00Z"), - created_to: Optional[str] = Query(None, description="Bis-Zeitpunkt (ISO 8601)", example="2025-08-13T00:00:00Z"), - limit: int = Query(20, ge=1, le=200, description="Max. Anzahl Items"), - offset: int = Query(0, ge=0, description="Start-Offset für Paging"), -): - _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: - # Achtung: verschachtelt – funktioniert in eurer Qdrant-Version; sonst Schritt 3 (Materialisierung) - must.append(FieldCondition(key="sections.name", match=MatchValue(value=section))) - - flt = Filter(must=must or None) if must else None - - fetch_n = offset + limit - fetch_n = max(fetch_n, 1) - pts, _ = qdrant.scroll(collection_name=PLAN_COLLECTION, scroll_filter=flt, limit=fetch_n, with_payload=True) - - # optionales Zeitfenster lokal anwenden (created_at ist ISO‑String im Payload) - def _in_window(py): - if not (created_from or created_to): - return True - ts = py.get("created_at") - if isinstance(ts, dict) and ts.get("$date"): - ts = ts["$date"] - if isinstance(ts, str): - try: - dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) - except Exception: - return False - elif isinstance(ts, datetime): - dt = ts - else: - return False - ok = True - if created_from: - try: - ok = ok and dt >= datetime.fromisoformat(created_from.replace("Z", "+00:00")) - except Exception: - pass - if created_to: - try: - ok = ok and dt <= datetime.fromisoformat(created_to.replace("Z", "+00:00")) - except Exception: - pass - return ok - - payloads = [] - for p in pts: - py = dict(p.payload or {}) - py.setdefault("id", str(p.id)) - if _in_window(py): - payloads.append(py) - - 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)) \ No newline at end of file + ex \ No newline at end of file