diff --git a/llm-api/plan_router.py b/llm-api/plan_router.py index 884b02e..ad30a0d 100644 --- a/llm-api/plan_router.py +++ b/llm-api/plan_router.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- """ -plan_router.py – v0.10.0 (WP-15) +plan_router.py – v0.11.0 (WP-15) Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint. -Erweiterung ggü. v0.9.0: optionale Section-Felder ideal/supplement + materialisierte -Facettenfelder für Qdrant-Indizes; robustere Payload→Model-Konvertierung. +Erweiterungen: +- optionale Section-Felder ideal/supplement + materialisierte Facettenfelder +- Referenz-Validierung: template_id (pflicht, wenn gesetzt) +- Optionaler Strict-Mode: PLAN_STRICT_EXERCISES prüft exercise_external_id """ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field @@ -26,6 +28,7 @@ router = APIRouter(tags=["plans"]) PLAN_COLLECTION = os.getenv("PLAN_COLLECTION") or os.getenv("QDRANT_COLLECTION_PLANS", "plans") PLAN_TEMPLATE_COLLECTION = os.getenv("PLAN_TEMPLATE_COLLECTION", "plan_templates") PLAN_SESSION_COLLECTION = os.getenv("PLAN_SESSION_COLLECTION", "plan_sessions") +EXERCISE_COLLECTION = os.getenv("EXERCISE_COLLECTION", "exercises") # ----------------- # Modelle @@ -34,8 +37,8 @@ class TemplateSection(BaseModel): name: str target_minutes: int must_keywords: List[str] = [] - ideal_keywords: List[str] = [] # NEU - supplement_keywords: List[str] = [] # NEU + ideal_keywords: List[str] = [] # optional + supplement_keywords: List[str] = [] # optional forbid_keywords: List[str] = [] capability_targets: Dict[str, int] = {} @@ -156,6 +159,16 @@ 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) + return bool(pts) + # ----------------- # Endpoints # ----------------- @@ -168,8 +181,8 @@ def create_plan_template(t: PlanTemplate): 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 []) # NEU - s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or []) # NEU + 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 []) # Materialisierte Facettenfelder (stabile KEYWORD-Indizes in Qdrant) @@ -196,19 +209,38 @@ def get_plan_template(tpl_id: str): @router.post("/plan", response_model=Plan) def create_plan(p: Plan): _ensure_collection(PLAN_COLLECTION) - # Fingerprint + + # 1) Template-Referenz prüfen (falls gesetzt) + 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}") + + # 2) Optional: Strict-Mode – Exercises prüfen + if _truthy(os.getenv("PLAN_STRICT_EXERCISES")): + 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)) + }) + + # 3) Fingerprint fp = _fingerprint_for_plan(p) p.fingerprint = p.fingerprint or fp - # Idempotenz + # 4) Idempotenz existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint) if existing: return _as_model(Plan, existing) - # Normalisieren + # 5) Normalisieren & upsert p.goals = _norm_list(p.goals) payload = p.model_dump() - # ISO8601 sicherstellen if isinstance(payload.get("created_at"), datetime): payload["created_at"] = payload["created_at"].astimezone(timezone.utc).isoformat() @@ -223,7 +255,6 @@ def get_plan(plan_id: str): found = _get_by_field(PLAN_COLLECTION, "id", plan_id) if not found: raise HTTPException(status_code=404, detail="not found") - # created_at zurück in datetime (ISO) if isinstance(found.get("created_at"), str): try: found["created_at"] = datetime.fromisoformat(found["created_at"])