2025-08-12 12:46:48 +02:00
|
|
|
|
# -*- coding: utf-8 -*-
|
2025-08-12 10:24:36 +02:00
|
|
|
|
"""
|
2025-08-13 07:17:59 +02:00
|
|
|
|
plan_router.py – v0.12.1 (WP-15)
|
2025-08-12 10:24:36 +02:00
|
|
|
|
|
|
|
|
|
|
Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint.
|
2025-08-13 07:17:59 +02:00
|
|
|
|
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.
|
2025-08-12 10:24:36 +02:00
|
|
|
|
"""
|
2025-08-13 07:06:08 +02:00
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
2025-08-12 10:24:36 +02:00
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
from clients import model, qdrant
|
|
|
|
|
|
from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue, VectorParams, Distance
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(tags=["plans"])
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------
|
|
|
|
|
|
# Konfiguration
|
|
|
|
|
|
# -----------------
|
|
|
|
|
|
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")
|
2025-08-12 16:25:32 +02:00
|
|
|
|
EXERCISE_COLLECTION = os.getenv("EXERCISE_COLLECTION", "exercises")
|
2025-08-12 10:24:36 +02:00
|
|
|
|
|
|
|
|
|
|
# -----------------
|
|
|
|
|
|
# Modelle
|
|
|
|
|
|
# -----------------
|
|
|
|
|
|
class TemplateSection(BaseModel):
|
|
|
|
|
|
name: str
|
|
|
|
|
|
target_minutes: int
|
|
|
|
|
|
must_keywords: List[str] = []
|
2025-08-13 07:06:08 +02:00
|
|
|
|
ideal_keywords: List[str] = []
|
|
|
|
|
|
supplement_keywords: List[str] = []
|
2025-08-12 10:24:36 +02:00
|
|
|
|
forbid_keywords: List[str] = []
|
|
|
|
|
|
capability_targets: Dict[str, int] = {}
|
|
|
|
|
|
|
|
|
|
|
|
class PlanTemplate(BaseModel):
|
|
|
|
|
|
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
|
|
|
|
name: str
|
|
|
|
|
|
discipline: str
|
|
|
|
|
|
age_group: str
|
|
|
|
|
|
target_group: str
|
|
|
|
|
|
total_minutes: int
|
|
|
|
|
|
sections: List[TemplateSection] = []
|
|
|
|
|
|
goals: List[str] = []
|
|
|
|
|
|
equipment_allowed: List[str] = []
|
|
|
|
|
|
created_by: str
|
|
|
|
|
|
version: str
|
|
|
|
|
|
|
|
|
|
|
|
class PlanItem(BaseModel):
|
|
|
|
|
|
exercise_external_id: str
|
|
|
|
|
|
duration: int
|
|
|
|
|
|
why: str
|
|
|
|
|
|
|
|
|
|
|
|
class PlanSection(BaseModel):
|
|
|
|
|
|
name: str
|
|
|
|
|
|
items: List[PlanItem] = []
|
|
|
|
|
|
minutes: int
|
|
|
|
|
|
|
|
|
|
|
|
class Plan(BaseModel):
|
|
|
|
|
|
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
|
|
|
|
template_id: Optional[str] = None
|
|
|
|
|
|
title: str
|
|
|
|
|
|
discipline: str
|
|
|
|
|
|
age_group: str
|
|
|
|
|
|
target_group: str
|
|
|
|
|
|
total_minutes: int
|
|
|
|
|
|
sections: List[PlanSection] = []
|
|
|
|
|
|
goals: List[str] = []
|
|
|
|
|
|
capability_summary: Dict[str, int] = {}
|
|
|
|
|
|
novelty_against_last_n: Optional[float] = None
|
|
|
|
|
|
fingerprint: Optional[str] = None
|
|
|
|
|
|
created_by: str
|
|
|
|
|
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
|
|
|
|
source: str = "API"
|
|
|
|
|
|
|
2025-08-13 07:06:08 +02:00
|
|
|
|
class PlanTemplateList(BaseModel):
|
|
|
|
|
|
items: List[PlanTemplate]
|
|
|
|
|
|
limit: int
|
|
|
|
|
|
offset: int
|
|
|
|
|
|
count: int
|
|
|
|
|
|
|
|
|
|
|
|
class PlanList(BaseModel):
|
|
|
|
|
|
items: List[Plan]
|
|
|
|
|
|
limit: int
|
|
|
|
|
|
offset: int
|
|
|
|
|
|
count: int
|
|
|
|
|
|
|
2025-08-12 10:24:36 +02:00
|
|
|
|
# -----------------
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
# -----------------
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_collection(name: str):
|
|
|
|
|
|
if not qdrant.collection_exists(name):
|
|
|
|
|
|
qdrant.recreate_collection(
|
|
|
|
|
|
collection_name=name,
|
|
|
|
|
|
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 []:
|
|
|
|
|
|
s = str(x).strip()
|
|
|
|
|
|
k = s.casefold()
|
|
|
|
|
|
if s and k not in seen:
|
|
|
|
|
|
seen.add(k)
|
|
|
|
|
|
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": [
|
2025-08-13 07:06:08 +02:00
|
|
|
|
{"exercise_external_id": it.exercise_external_id, "duration": int(it.duration)}
|
2025-08-12 10:24:36 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-08-12 12:46:48 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2025-08-12 16:25:32 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2025-08-12 10:24:36 +02:00
|
|
|
|
# -----------------
|
2025-08-13 07:06:08 +02:00
|
|
|
|
# CRUD (bestehend)
|
2025-08-12 10:24:36 +02:00
|
|
|
|
# -----------------
|
2025-08-13 07:06:08 +02:00
|
|
|
|
@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."
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2025-08-12 10:24:36 +02:00
|
|
|
|
def create_plan_template(t: PlanTemplate):
|
|
|
|
|
|
_ensure_collection(PLAN_TEMPLATE_COLLECTION)
|
|
|
|
|
|
payload = t.model_dump()
|
|
|
|
|
|
payload["goals"] = _norm_list(payload.get("goals"))
|
2025-08-12 12:46:48 +02:00
|
|
|
|
sections = payload.get("sections", []) or []
|
|
|
|
|
|
for s in sections:
|
2025-08-12 10:24:36 +02:00
|
|
|
|
s["must_keywords"] = _norm_list(s.get("must_keywords") or [])
|
2025-08-12 16:25:32 +02:00
|
|
|
|
s["ideal_keywords"] = _norm_list(s.get("ideal_keywords") or [])
|
|
|
|
|
|
s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or [])
|
2025-08-12 10:24:36 +02:00
|
|
|
|
s["forbid_keywords"] = _norm_list(s.get("forbid_keywords") or [])
|
2025-08-12 12:46:48 +02:00
|
|
|
|
|
|
|
|
|
|
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 [])])
|
|
|
|
|
|
|
2025-08-12 10:24:36 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-08-13 07:06:08 +02:00
|
|
|
|
@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).",
|
|
|
|
|
|
)
|
2025-08-12 10:24:36 +02:00
|
|
|
|
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")
|
2025-08-12 12:46:48 +02:00
|
|
|
|
return _as_model(PlanTemplate, found)
|
2025-08-12 10:24:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
2025-08-13 07:06:08 +02:00
|
|
|
|
@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)."
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2025-08-12 10:24:36 +02:00
|
|
|
|
def create_plan(p: Plan):
|
|
|
|
|
|
_ensure_collection(PLAN_COLLECTION)
|
2025-08-12 16:25:32 +02:00
|
|
|
|
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 []:
|
2025-08-13 07:17:59 +02:00
|
|
|
|
ex
|