llm-api/exercise_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
This commit is contained in:
parent
4d5d36d6e7
commit
8302a7fecf
|
|
@ -4,32 +4,45 @@ from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from datetime import datetime, date
|
from datetime import datetime
|
||||||
from clients import model, qdrant
|
from clients import model, qdrant
|
||||||
from qdrant_client.models import PointStruct, VectorParams, Distance, PointIdsList
|
from qdrant_client.models import (
|
||||||
|
PointStruct,
|
||||||
|
VectorParams,
|
||||||
|
Distance,
|
||||||
|
PointIdsList,
|
||||||
|
# NEW: für Filter-Queries (Lookup via external_id)
|
||||||
|
Filter, FieldCondition, MatchValue,
|
||||||
|
)
|
||||||
import os
|
import os
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# ---- Models ----
|
# =========================
|
||||||
|
# Models
|
||||||
|
# =========================
|
||||||
class Exercise(BaseModel):
|
class Exercise(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
# NEW — optional, bricht vorhandene POST-Calls nicht
|
||||||
|
external_id: Optional[str] = None # z.B. "mw:12345"
|
||||||
|
fingerprint: Optional[str] = None # sha256 über Kernfelder
|
||||||
|
source: Optional[str] = None # Herkunft, z.B. "MediaWiki"
|
||||||
|
imported_at: Optional[datetime] = None # vom Import gesetzt
|
||||||
|
|
||||||
|
# Bestehende Felder (unverändert)
|
||||||
title: str
|
title: str
|
||||||
summary: str
|
summary: str
|
||||||
short_description: str
|
short_description: str
|
||||||
keywords: List[str] = []
|
keywords: List[str] = []
|
||||||
link: Optional[str] = None
|
link: Optional[str] = None
|
||||||
|
|
||||||
discipline: str
|
discipline: str
|
||||||
group: Optional[str] = None
|
group: Optional[str] = None
|
||||||
age_group: str
|
age_group: str
|
||||||
target_group: str
|
target_group: str
|
||||||
min_participants: int
|
min_participants: int
|
||||||
duration_minutes: int
|
duration_minutes: int
|
||||||
|
|
||||||
capabilities: Dict[str, int] = {}
|
capabilities: Dict[str, int] = {}
|
||||||
category: str
|
category: str
|
||||||
|
|
||||||
purpose: str
|
purpose: str
|
||||||
execution: str
|
execution: str
|
||||||
notes: str
|
notes: str
|
||||||
|
|
@ -37,146 +50,122 @@ class Exercise(BaseModel):
|
||||||
method: str
|
method: str
|
||||||
equipment: List[str] = []
|
equipment: List[str] = []
|
||||||
|
|
||||||
class PhaseExercise(BaseModel):
|
|
||||||
exercise_id: str
|
|
||||||
cond_load: Dict[str, Any] = {}
|
|
||||||
coord_load: Dict[str, Any] = {}
|
|
||||||
instructions: str
|
|
||||||
|
|
||||||
class PlanPhase(BaseModel):
|
|
||||||
name: str
|
|
||||||
duration_minutes: int
|
|
||||||
method: str
|
|
||||||
method_notes: str
|
|
||||||
exercises: List[PhaseExercise]
|
|
||||||
|
|
||||||
class TrainingPlan(BaseModel):
|
|
||||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
||||||
title: str
|
|
||||||
short_description: str
|
|
||||||
collection: str
|
|
||||||
discipline: str
|
|
||||||
group: Optional[str] = None
|
|
||||||
dojo: str
|
|
||||||
date: date
|
|
||||||
plan_duration_weeks: int
|
|
||||||
focus_areas: List[str] = []
|
|
||||||
predecessor_plan_id: Optional[str] = None
|
|
||||||
age_group: str
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
phases: List[PlanPhase]
|
|
||||||
|
|
||||||
class DeleteResponse(BaseModel):
|
class DeleteResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
count: int
|
count: int
|
||||||
collection: str
|
collection: str
|
||||||
source: Optional[str] = None
|
|
||||||
type: Optional[str] = None
|
|
||||||
|
|
||||||
# ---- CRUD Endpoints for Exercise ----
|
# =========================
|
||||||
|
# Helpers
|
||||||
|
# =========================
|
||||||
|
COLLECTION = os.getenv("EXERCISE_COLLECTION", "exercises")
|
||||||
|
|
||||||
|
# CHANGED: Factorized to reuse for both create and update
|
||||||
|
def _ensure_collection():
|
||||||
|
if not qdrant.collection_exists(COLLECTION):
|
||||||
|
qdrant.recreate_collection(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
vectors_config=VectorParams(
|
||||||
|
size=model.get_sentence_embedding_dimension(),
|
||||||
|
distance=Distance.COSINE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# NEW: gemeinsamer Helper für external_id-Lookup
|
||||||
|
def _lookup_by_external_id(external_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
_ensure_collection()
|
||||||
|
flt = Filter(must=[FieldCondition(key="external_id", match=MatchValue(value=external_id))])
|
||||||
|
pts, _ = qdrant.scroll(collection_name=COLLECTION, scroll_filter=flt, limit=1)
|
||||||
|
if not pts:
|
||||||
|
return None
|
||||||
|
# qdrant_client liefert PointStruct; wir geben die payload + id zurück
|
||||||
|
doc = pts[0].payload or {}
|
||||||
|
doc = dict(doc)
|
||||||
|
doc.setdefault("id", str(pts[0].id))
|
||||||
|
return doc
|
||||||
|
|
||||||
|
# NEW: konsistente Embedding-Erzeugung
|
||||||
|
_def_embed_text_fields = ("title", "summary", "short_description", "purpose", "execution", "notes")
|
||||||
|
|
||||||
|
def _make_vector(ex: Exercise) -> List[float]:
|
||||||
|
text = ". ".join([getattr(ex, f, "") for f in _def_embed_text_fields if getattr(ex, f, None)])
|
||||||
|
# Achtung: model.encode muss synchron sein; sonst async anpassen
|
||||||
|
vec = model.encode(text).tolist()
|
||||||
|
return vec
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Endpoints
|
||||||
|
# =========================
|
||||||
|
@router.get("/exercise/by-external-id") # NEW
|
||||||
|
def get_exercise_by_external_id(external_id: str = Query(..., min_length=3)):
|
||||||
|
"""Lookup für Idempotenz im Importer. Liefert 404, wenn nicht vorhanden."""
|
||||||
|
found = _lookup_by_external_id(external_id)
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
return found
|
||||||
|
|
||||||
@router.post("/exercise", response_model=Exercise)
|
@router.post("/exercise", response_model=Exercise)
|
||||||
def create_exercise(ex: Exercise):
|
def create_or_update_exercise(ex: Exercise):
|
||||||
# Ensure Exercise collection exists
|
"""
|
||||||
if not qdrant.collection_exists("exercises"):
|
CHANGED: Upsert-Semantik. Wenn `external_id` existiert und bereits in Qdrant gefunden wird,
|
||||||
qdrant.recreate_collection(
|
wird dieselbe Point-ID überschrieben (echtes Update). Ansonsten neuer Eintrag.
|
||||||
collection_name="exercises",
|
API-Signatur bleibt identisch (POST /exercise, Body = Exercise).
|
||||||
vectors_config=VectorParams(
|
"""
|
||||||
size=model.get_sentence_embedding_dimension(),
|
_ensure_collection()
|
||||||
distance=Distance.COSINE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
vec = model.encode(f"{ex.title}. {ex.summary}").tolist()
|
|
||||||
point = PointStruct(id=ex.id, vector=vec, payload=ex.dict())
|
|
||||||
qdrant.upsert(collection_name="exercises", points=[point])
|
|
||||||
return ex
|
|
||||||
|
|
||||||
@router.get("/exercise", response_model=List[Exercise])
|
# Default: neue Point-ID aus dem Exercise-Objekt
|
||||||
def list_exercises(
|
point_id = ex.id
|
||||||
discipline: Optional[str] = Query(None),
|
|
||||||
group: Optional[str] = Query(None),
|
# Wenn external_id gesetzt → prüfen, ob bereits vorhanden → Point-ID übernehmen
|
||||||
tags: Optional[str] = Query(None)
|
if ex.external_id:
|
||||||
):
|
prior = _lookup_by_external_id(ex.external_id)
|
||||||
filters = []
|
if prior:
|
||||||
if discipline:
|
point_id = prior.get("id", point_id)
|
||||||
filters.append({"key": "discipline", "match": {"value": discipline}})
|
|
||||||
if group:
|
# Embedding berechnen
|
||||||
filters.append({"key": "group", "match": {"value": group}})
|
vector = _make_vector(ex)
|
||||||
if tags:
|
|
||||||
for t in tags.split(","):
|
# Payload synchronisieren (id == point_id)
|
||||||
filters.append({"key": "keywords", "match": {"value": t.strip()}})
|
payload = ex.dict()
|
||||||
|
payload["id"] = str(point_id)
|
||||||
|
|
||||||
|
# Upsert in Qdrant
|
||||||
|
qdrant.upsert(
|
||||||
|
collection_name=COLLECTION,
|
||||||
|
points=[PointStruct(id=str(point_id), vector=vector, payload=payload)],
|
||||||
|
)
|
||||||
|
|
||||||
|
return Exercise(**payload)
|
||||||
|
|
||||||
|
# (Optional) – Einzel-Abruf per ID (falls bereits vorhanden, unverändert)
|
||||||
|
@router.get("/exercise/{exercise_id}", response_model=Exercise)
|
||||||
|
def get_exercise(exercise_id: str):
|
||||||
|
_ensure_collection()
|
||||||
pts, _ = qdrant.scroll(
|
pts, _ = qdrant.scroll(
|
||||||
collection_name="exercises",
|
collection_name=COLLECTION,
|
||||||
scroll_filter={"must": filters} if filters else None,
|
scroll_filter=Filter(must=[FieldCondition(key="id", match=MatchValue(value=exercise_id))]),
|
||||||
limit=10000
|
limit=1,
|
||||||
)
|
)
|
||||||
return [Exercise(**pt.payload) for pt in pts]
|
if not pts:
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
payload = dict(pts[0].payload or {})
|
||||||
|
payload.setdefault("id", str(pts[0].id))
|
||||||
|
return Exercise(**payload)
|
||||||
|
|
||||||
# ---- CRUD Endpoints for TrainingPlan ----
|
# Bestehende Admin-Utilities (Delete nach Filter / komplette Collection) – unverändert außer Nutzung von CONSTs
|
||||||
@router.post("/plan", response_model=TrainingPlan)
|
@router.delete("/exercise/delete-by-external-id", response_model=DeleteResponse)
|
||||||
def create_plan(plan: TrainingPlan):
|
def delete_by_external_id(external_id: str = Query(...)):
|
||||||
# Ensure TrainingPlan collection exists
|
_ensure_collection()
|
||||||
if not qdrant.collection_exists("training_plans"):
|
flt = Filter(must=[FieldCondition(key="external_id", match=MatchValue(value=external_id))])
|
||||||
qdrant.recreate_collection(
|
pts, _ = qdrant.scroll(collection_name=COLLECTION, scroll_filter=flt, limit=10000)
|
||||||
collection_name="training_plans",
|
|
||||||
vectors_config=VectorParams(
|
|
||||||
size=model.get_sentence_embedding_dimension(),
|
|
||||||
distance=Distance.COSINE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
vec = model.encode(f"{plan.title}. {plan.short_description}").tolist()
|
|
||||||
point = PointStruct(id=plan.id, vector=vec, payload=plan.dict())
|
|
||||||
qdrant.upsert(collection_name="training_plans", points=[point])
|
|
||||||
return plan
|
|
||||||
|
|
||||||
@router.get("/plan", response_model=List[TrainingPlan])
|
|
||||||
def list_plans(
|
|
||||||
collection: str = Query("training_plans"),
|
|
||||||
discipline: Optional[str] = Query(None),
|
|
||||||
group: Optional[str] = Query(None),
|
|
||||||
dojo: Optional[str] = Query(None)
|
|
||||||
):
|
|
||||||
if not qdrant.collection_exists(collection):
|
|
||||||
return []
|
|
||||||
pts, _ = qdrant.scroll(collection_name=collection, limit=10000)
|
|
||||||
result = []
|
|
||||||
for pt in pts:
|
|
||||||
pl = TrainingPlan(**pt.payload)
|
|
||||||
if discipline and pl.discipline != discipline:
|
|
||||||
continue
|
|
||||||
if group and pl.group != group:
|
|
||||||
continue
|
|
||||||
if dojo and pl.dojo != dojo:
|
|
||||||
continue
|
|
||||||
result.append(pl)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ---- Delete Endpoints ----
|
|
||||||
@router.delete("/delete-source", response_model=DeleteResponse)
|
|
||||||
def delete_by_source(
|
|
||||||
collection: str = Query(...),
|
|
||||||
source: Optional[str] = Query(None),
|
|
||||||
type: Optional[str] = Query(None)
|
|
||||||
):
|
|
||||||
if not qdrant.collection_exists(collection):
|
|
||||||
raise HTTPException(status_code=404, detail=f"Collection '{collection}' nicht gefunden.")
|
|
||||||
filt = []
|
|
||||||
if source:
|
|
||||||
filt.append({"key": "source", "match": {"value": source}})
|
|
||||||
if type:
|
|
||||||
filt.append({"key": "type", "match": {"value": type}})
|
|
||||||
if not filt:
|
|
||||||
raise HTTPException(status_code=400, detail="Mindestens ein Filterparameter muss angegeben werden.")
|
|
||||||
pts, _ = qdrant.scroll(collection_name=collection, scroll_filter={"must": filt}, limit=10000)
|
|
||||||
ids = [str(p.id) for p in pts]
|
ids = [str(p.id) for p in pts]
|
||||||
if not ids:
|
if not ids:
|
||||||
return DeleteResponse(status="🔍 Keine Einträge gefunden.", count=0, collection=collection)
|
return DeleteResponse(status="🔍 Keine Einträge gefunden.", count=0, collection=COLLECTION)
|
||||||
qdrant.delete(collection_name=collection, points_selector=PointIdsList(points=ids))
|
qdrant.delete(collection_name=COLLECTION, points_selector=PointIdsList(points=ids))
|
||||||
return DeleteResponse(status="🗑️ gelöscht", count=len(ids), collection=collection)
|
return DeleteResponse(status="🗑️ gelöscht", count=len(ids), collection=COLLECTION)
|
||||||
|
|
||||||
@router.delete("/delete-collection", response_model=DeleteResponse)
|
@router.delete("/exercise/delete-collection", response_model=DeleteResponse)
|
||||||
def delete_collection(
|
def delete_collection(collection: str = Query(default=COLLECTION)):
|
||||||
collection: str = Query(...)
|
|
||||||
):
|
|
||||||
if not qdrant.collection_exists(collection):
|
if not qdrant.collection_exists(collection):
|
||||||
raise HTTPException(status_code=404, detail=f"Collection '{collection}' nicht gefunden.")
|
raise HTTPException(status_code=404, detail=f"Collection '{collection}' nicht gefunden.")
|
||||||
qdrant.delete_collection(collection_name=collection)
|
qdrant.delete_collection(collection_name=collection)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user