From 58d2260d89b449c695b57beb2fd0193c46de277a Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 Aug 2025 08:12:10 +0200 Subject: [PATCH] llm-api/plan_router.py aktualisiert --- llm-api/plan_router.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/llm-api/plan_router.py b/llm-api/plan_router.py index e250ca0..339da1d 100644 --- a/llm-api/plan_router.py +++ b/llm-api/plan_router.py @@ -1,16 +1,18 @@ # -*- coding: utf-8 -*- """ -plan_router.py – v0.12.4 (WP-15) +plan_router.py – v0.12.6 (WP-15) -Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint. +Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint ++ List-/Filter-Endpoints – robust & dokumentiert. -Änderungen ggü. v0.12.1/0.12.3: -- FIX: POST /plan materialisiert `plan_section_names` im Payload (robuste Section-Filter). -- GET /plans filtert auf `plan_section_names` (statt verschachteltem `sections.name`). -- GET /plan_templates & GET /plans: Filter + Paging + Swagger-Beschreibungen. -- Optional: Strict-Checks (template_id/exercises) via ENV. +Ä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 -Hinweis: KEYWORD-Index für `plans.plan_section_names` per bootstrap sicherstellen. +Voraussetzung: KEYWORD-Index für `plans.plan_section_names` durch bootstrap-script (v1.2.x) """ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field @@ -41,8 +43,8 @@ class TemplateSection(BaseModel): name: str target_minutes: int must_keywords: List[str] = [] - ideal_keywords: List[str] = [] # optional (wünschenswert) - supplement_keywords: List[str] = [] # optional (ergänzend) + ideal_keywords: List[str] = [] # wünschenswert + supplement_keywords: List[str] = [] # ergänzend forbid_keywords: List[str] = [] capability_targets: Dict[str, int] = {} @@ -101,6 +103,7 @@ class PlanList(BaseModel): # ----------------- # Helpers # ----------------- + def _ensure_collection(name: str): """Legt Collection an, wenn sie fehlt (analog exercise_router).""" if not qdrant.collection_exists(name): @@ -109,6 +112,7 @@ 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]: """Trimmen, casefolded deduplizieren, stabil sortieren.""" seen, out = set(), [] @@ -120,21 +124,25 @@ 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: """sha256(title, total_minutes, sections.items.exercise_external_id, sections.items.duration)""" core = { @@ -149,6 +157,7 @@ def _fingerprint_for_plan(p: Plan) -> str: 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) @@ -158,6 +167,7 @@ def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, A payload.setdefault("id", str(pts[0].id)) return payload + 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__", {}) @@ -165,9 +175,11 @@ def _as_model(model_cls, payload: Dict[str, Any]): 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) @@ -209,6 +221,7 @@ def create_plan_template(t: PlanTemplate): 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, @@ -222,6 +235,7 @@ def get_plan_template(tpl_id: str): raise HTTPException(status_code=404, detail="not found") return _as_model(PlanTemplate, found) + @router.get( "/plan_templates", response_model=PlanTemplateList, @@ -339,6 +353,7 @@ def create_plan(p: Plan): 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, @@ -357,6 +372,7 @@ def get_plan(plan_id: str): pass return _as_model(Plan, found) + @router.get( "/plans", response_model=PlanList,