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-12 16:25:32 +02:00
parent 4fbfdb1c6a
commit 36c82ac942

View File

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*- # -*- 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. Minimal-CRUD für Plan-Templates & Pläne (POST/GET) + Idempotenz via Fingerprint.
Erweiterung ggü. v0.9.0: optionale Section-Felder ideal/supplement + materialisierte Erweiterungen:
Facettenfelder für Qdrant-Indizes; robustere PayloadModel-Konvertierung. - 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 fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field 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_COLLECTION = os.getenv("PLAN_COLLECTION") or os.getenv("QDRANT_COLLECTION_PLANS", "plans")
PLAN_TEMPLATE_COLLECTION = os.getenv("PLAN_TEMPLATE_COLLECTION", "plan_templates") PLAN_TEMPLATE_COLLECTION = os.getenv("PLAN_TEMPLATE_COLLECTION", "plan_templates")
PLAN_SESSION_COLLECTION = os.getenv("PLAN_SESSION_COLLECTION", "plan_sessions") PLAN_SESSION_COLLECTION = os.getenv("PLAN_SESSION_COLLECTION", "plan_sessions")
EXERCISE_COLLECTION = os.getenv("EXERCISE_COLLECTION", "exercises")
# ----------------- # -----------------
# Modelle # Modelle
@ -34,8 +37,8 @@ class TemplateSection(BaseModel):
name: str name: str
target_minutes: int target_minutes: int
must_keywords: List[str] = [] must_keywords: List[str] = []
ideal_keywords: List[str] = [] # NEU ideal_keywords: List[str] = [] # optional
supplement_keywords: List[str] = [] # NEU supplement_keywords: List[str] = [] # optional
forbid_keywords: List[str] = [] forbid_keywords: List[str] = []
capability_targets: Dict[str, int] = {} 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} data = {k: payload[k] for k in payload.keys() if k in allowed}
return model_cls(**data) 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 # Endpoints
# ----------------- # -----------------
@ -168,8 +181,8 @@ def create_plan_template(t: PlanTemplate):
sections = payload.get("sections", []) or [] sections = payload.get("sections", []) or []
for s in sections: for s in sections:
s["must_keywords"] = _norm_list(s.get("must_keywords") or []) s["must_keywords"] = _norm_list(s.get("must_keywords") or [])
s["ideal_keywords"] = _norm_list(s.get("ideal_keywords") or []) # NEU s["ideal_keywords"] = _norm_list(s.get("ideal_keywords") or [])
s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or []) # NEU s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or [])
s["forbid_keywords"] = _norm_list(s.get("forbid_keywords") or []) s["forbid_keywords"] = _norm_list(s.get("forbid_keywords") or [])
# Materialisierte Facettenfelder (stabile KEYWORD-Indizes in Qdrant) # Materialisierte Facettenfelder (stabile KEYWORD-Indizes in Qdrant)
@ -196,19 +209,38 @@ def get_plan_template(tpl_id: str):
@router.post("/plan", response_model=Plan) @router.post("/plan", response_model=Plan)
def create_plan(p: Plan): def create_plan(p: Plan):
_ensure_collection(PLAN_COLLECTION) _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) fp = _fingerprint_for_plan(p)
p.fingerprint = p.fingerprint or fp p.fingerprint = p.fingerprint or fp
# Idempotenz # 4) Idempotenz
existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint) existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint)
if existing: if existing:
return _as_model(Plan, existing) return _as_model(Plan, existing)
# Normalisieren # 5) Normalisieren & upsert
p.goals = _norm_list(p.goals) p.goals = _norm_list(p.goals)
payload = p.model_dump() payload = p.model_dump()
# ISO8601 sicherstellen
if isinstance(payload.get("created_at"), datetime): if isinstance(payload.get("created_at"), datetime):
payload["created_at"] = payload["created_at"].astimezone(timezone.utc).isoformat() 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) found = _get_by_field(PLAN_COLLECTION, "id", plan_id)
if not found: if not found:
raise HTTPException(status_code=404, detail="not found") raise HTTPException(status_code=404, detail="not found")
# created_at zurück in datetime (ISO)
if isinstance(found.get("created_at"), str): if isinstance(found.get("created_at"), str):
try: try:
found["created_at"] = datetime.fromisoformat(found["created_at"]) found["created_at"] = datetime.fromisoformat(found["created_at"])