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
4fbfdb1c6a
commit
36c82ac942
|
|
@ -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 Payload→Model-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"])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user