llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s

This commit is contained in:
Lars 2025-08-13 07:28:47 +02:00
parent c0de60e4a5
commit 78cf89c0fa

View File

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
plan_router.py v0.12.1 (WP-15) plan_router.py v0.12.2 (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.1: NEU/Änderungen ggü. v0.12.0:
- Fix: Filter `section` bei `/plans` nutzt jetzt **materialisierte** `plan_section_names` (robust gg. Qdrant-Versionen). - GET /plan_templates (Liste/Filter)
- Neues Payload-Feld beim POST `/plan`: `plan_section_names` (Liste) wird automatisch gesetzt und indexiert. - GET /plans (Liste/Filter)
- Swagger-Doku aktualisiert. - Fix: Filter `section` bei `/plans` nutzt materialisierte `plan_section_names`
- POST /plan materialisiert `plan_section_names` automatisch
- Ausführlichere Swagger-Doku
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -97,7 +99,6 @@ class PlanList(BaseModel):
# ----------------- # -----------------
# Helpers # Helpers
# ----------------- # -----------------
def _ensure_collection(name: str): def _ensure_collection(name: str):
if not qdrant.collection_exists(name): if not qdrant.collection_exists(name):
qdrant.recreate_collection( qdrant.recreate_collection(
@ -105,7 +106,6 @@ def _ensure_collection(name: str):
vectors_config=VectorParams(size=model.get_sentence_embedding_dimension(), distance=Distance.COSINE), vectors_config=VectorParams(size=model.get_sentence_embedding_dimension(), distance=Distance.COSINE),
) )
def _norm_list(xs: List[str]) -> List[str]: def _norm_list(xs: List[str]) -> List[str]:
seen, out = set(), [] seen, out = set(), []
for x in xs or []: for x in xs or []:
@ -116,133 +116,5 @@ def _norm_list(xs: List[str]) -> List[str]:
out.append(s) out.append(s)
return sorted(out, key=str.casefold) return sorted(out, key=str.casefold)
def _template_embed_text(tpl: PlanTemplate) -> str: def _template_embed_text(tpl: PlanTemplate) -> str:
parts = [tpl.name, tpl.discipline, tpl.age_group, tpl.target_group] par
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": [
{"exercise_external_id": it.exercise_external_id, "duration": int(it.duration)}
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
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)
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)
# -----------------
# CRUD (bestehend)
# -----------------
@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."
),
)
def create_plan_template(t: PlanTemplate):
_ensure_collection(PLAN_TEMPLATE_COLLECTION)
payload = t.model_dump()
payload["goals"] = _norm_list(payload.get("goals"))
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 [])
s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or [])
s["forbid_keywords"] = _norm_list(s.get("forbid_keywords") or [])
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 [])])
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
@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).",
)
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")
return _as_model(PlanTemplate, found)
@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)."
),
)
def create_plan(p: Plan):
_ensure_collection(PLAN_COLLECTION)
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 []:
ex