diff --git a/llm-api/plan_router.py b/llm-api/plan_router.py index 86f7151..359a9aa 100644 --- a/llm-api/plan_router.py +++ b/llm-api/plan_router.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- """ -plan_router.py – v0.12.1 (WP-15) +plan_router.py – v0.12.2 (WP-15) Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint. -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. +NEU/Änderungen ggü. v0.12.0: +- GET /plan_templates (Liste/Filter) +- GET /plans (Liste/Filter) +- Fix: Filter `section` bei `/plans` nutzt materialisierte `plan_section_names` +- POST /plan materialisiert `plan_section_names` automatisch +- Ausführlichere Swagger-Doku """ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field @@ -97,7 +99,6 @@ class PlanList(BaseModel): # ----------------- # Helpers # ----------------- - def _ensure_collection(name: str): if not qdrant.collection_exists(name): qdrant.recreate_collection( @@ -105,7 +106,6 @@ def _ensure_collection(name: str): vectors_config=VectorParams(size=model.get_sentence_embedding_dimension(), distance=Distance.COSINE), ) - def _norm_list(xs: List[str]) -> List[str]: seen, out = set(), [] for x in xs or []: @@ -116,133 +116,5 @@ def _norm_list(xs: List[str]) -> List[str]: out.append(s) return sorted(out, key=str.casefold) - def _template_embed_text(tpl: PlanTemplate) -> str: - parts = [tpl.name, tpl.discipline, tpl.age_group, tpl.target_group] - parts += tpl.goals - parts += [s.name for s in tpl.sections] - return ". ".join([p for p in parts if p]) - - -def _plan_embed_text(p: Plan) -> str: - parts = [p.title, p.discipline, p.age_group, p.target_group] - parts += p.goals - parts += [s.name for s in p.sections] - return ". ".join([p for p in parts if p]) - - -def _embed(text: str): - return model.encode(text or "").tolist() - - -def _fingerprint_for_plan(p: Plan) -> str: - core = { - "title": p.title, - "total_minutes": int(p.total_minutes), - "items": [ - {"exercise_external_id": it.exercise_external_id, "duration": int(it.duration)} - for sec in p.sections - for it in (sec.items or []) - ], - } - raw = json.dumps(core, sort_keys=True, ensure_ascii=False) - return hashlib.sha256(raw.encode("utf-8")).hexdigest() - - -def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, Any]]: - flt = Filter(must=[FieldCondition(key=key, match=MatchValue(value=value))]) - pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=True) - if not pts: - return None - payload = dict(pts[0].payload or {}) - payload.setdefault("id", str(pts[0].id)) - return payload - - -def _as_model(model_cls, payload: Dict[str, Any]): - fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {}) - allowed = set(fields.keys()) - data = {k: payload[k] for k in payload.keys() if k in allowed} - return model_cls(**data) - - -def _truthy(val: Optional[str]) -> bool: - return str(val or "").strip().lower() in {"1", "true", "yes", "on"} - - -def _exists_in_collection(collection: str, key: str, value: Any) -> bool: - flt = Filter(must=[FieldCondition(key=key, match=MatchValue(value=value))]) - pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=False) - return bool(pts) - -# ----------------- -# CRUD (bestehend) -# ----------------- -@router.post( - "/plan_templates", - response_model=PlanTemplate, - summary="Create a plan template", - description=( - "Erstellt ein Plan-Template (Strukturplanung).\n\n" - "• Mehrere Sections erlaubt.\n" - "• Section-Felder: must/ideal/supplement/forbid keywords + capability_targets.\n" - "• Materialisierte Facettenfelder (section_*) werden intern geschrieben, um Qdrant-Filter zu beschleunigen." - ), -) -def create_plan_template(t: PlanTemplate): - _ensure_collection(PLAN_TEMPLATE_COLLECTION) - payload = t.model_dump() - payload["goals"] = _norm_list(payload.get("goals")) - sections = payload.get("sections", []) or [] - for s in sections: - s["must_keywords"] = _norm_list(s.get("must_keywords") or []) - s["ideal_keywords"] = _norm_list(s.get("ideal_keywords") or []) - s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or []) - s["forbid_keywords"] = _norm_list(s.get("forbid_keywords") or []) - - payload["section_names"] = _norm_list([s.get("name", "") for s in sections]) - payload["section_must_keywords"] = _norm_list([kw for s in sections for kw in (s.get("must_keywords") or [])]) - payload["section_ideal_keywords"] = _norm_list([kw for s in sections for kw in (s.get("ideal_keywords") or [])]) - payload["section_supplement_keywords"] = _norm_list([kw for s in sections for kw in (s.get("supplement_keywords") or [])]) - payload["section_forbid_keywords"] = _norm_list([kw for s in sections for kw in (s.get("forbid_keywords") or [])]) - - vec = _embed(_template_embed_text(t)) - qdrant.upsert(collection_name=PLAN_TEMPLATE_COLLECTION, points=[PointStruct(id=str(t.id), vector=vec, payload=payload)]) - return t - - -@router.get( - "/plan_templates/{tpl_id}", - response_model=PlanTemplate, - summary="Read a plan template by id", - description="Liest ein Template anhand seiner ID und gibt nur die Schemafelder zurück (zusätzliche Payload wird herausgefiltert).", -) -def get_plan_template(tpl_id: str): - _ensure_collection(PLAN_TEMPLATE_COLLECTION) - found = _get_by_field(PLAN_TEMPLATE_COLLECTION, "id", tpl_id) - if not found: - raise HTTPException(status_code=404, detail="not found") - return _as_model(PlanTemplate, found) - - -@router.post( - "/plan", - response_model=Plan, - summary="Create a concrete training plan", - description=( - "Erstellt einen konkreten Trainingsplan.\n\n" - "Idempotenz: gleicher Fingerprint (title + items) → gleicher Plan (kein Duplikat).\n" - "Optional: Validierung von template_id und Exercises (Strict-Mode)." - ), -) -def create_plan(p: Plan): - _ensure_collection(PLAN_COLLECTION) - if p.template_id: - if not _exists_in_collection(PLAN_TEMPLATE_COLLECTION, "id", p.template_id): - raise HTTPException(status_code=422, detail=f"Unknown template_id: {p.template_id}") - - if _truthy(os.getenv("PLAN_STRICT_EXERCISES")): - missing: List[str] = [] - for sec in p.sections or []: - for it in sec.items or []: - ex \ No newline at end of file + par