llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
This commit is contained in:
parent
123df8a48a
commit
c0de60e4a5
|
|
@ -1,11 +1,12 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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.
|
Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint.
|
||||||
NEU in v0.12.0:
|
NEU in v0.12.1:
|
||||||
- List-/Filter-Endpoints: GET /plan_templates, GET /plans
|
- Fix: Filter `section` bei `/plans` nutzt jetzt **materialisierte** `plan_section_names` (robust gg. Qdrant-Versionen).
|
||||||
- Umfangreiche Swagger-Doku (summary/description, Query-Parameter mit Beschreibungen & Beispielen)
|
- 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 fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -244,196 +245,4 @@ def create_plan(p: Plan):
|
||||||
missing: List[str] = []
|
missing: List[str] = []
|
||||||
for sec in p.sections or []:
|
for sec in p.sections or []:
|
||||||
for it in sec.items or []:
|
for it in sec.items or []:
|
||||||
exid = (it.exercise_external_id or "").strip()
|
ex
|
||||||
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))
|
|
||||||
Loading…
Reference in New Issue
Block a user