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 -*-
|
||||
"""
|
||||
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.
|
||||
Erweiterung ggü. v0.9.0: optionale Section-Felder ideal/supplement + materialisierte
|
||||
Facettenfelder für Qdrant-Indizes; robustere Payload→Model-Konvertierung.
|
||||
Erweiterungen:
|
||||
- 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 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_TEMPLATE_COLLECTION = os.getenv("PLAN_TEMPLATE_COLLECTION", "plan_templates")
|
||||
PLAN_SESSION_COLLECTION = os.getenv("PLAN_SESSION_COLLECTION", "plan_sessions")
|
||||
EXERCISE_COLLECTION = os.getenv("EXERCISE_COLLECTION", "exercises")
|
||||
|
||||
# -----------------
|
||||
# Modelle
|
||||
|
|
@ -34,8 +37,8 @@ class TemplateSection(BaseModel):
|
|||
name: str
|
||||
target_minutes: int
|
||||
must_keywords: List[str] = []
|
||||
ideal_keywords: List[str] = [] # NEU
|
||||
supplement_keywords: List[str] = [] # NEU
|
||||
ideal_keywords: List[str] = [] # optional
|
||||
supplement_keywords: List[str] = [] # optional
|
||||
forbid_keywords: List[str] = []
|
||||
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}
|
||||
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
|
||||
# -----------------
|
||||
|
|
@ -168,8 +181,8 @@ def create_plan_template(t: PlanTemplate):
|
|||
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 []) # NEU
|
||||
s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or []) # NEU
|
||||
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 [])
|
||||
|
||||
# Materialisierte Facettenfelder (stabile KEYWORD-Indizes in Qdrant)
|
||||
|
|
@ -196,19 +209,38 @@ def get_plan_template(tpl_id: str):
|
|||
@router.post("/plan", response_model=Plan)
|
||||
def create_plan(p: Plan):
|
||||
_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)
|
||||
p.fingerprint = p.fingerprint or fp
|
||||
|
||||
# Idempotenz
|
||||
# 4) Idempotenz
|
||||
existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint)
|
||||
if existing:
|
||||
return _as_model(Plan, existing)
|
||||
|
||||
# Normalisieren
|
||||
# 5) Normalisieren & upsert
|
||||
p.goals = _norm_list(p.goals)
|
||||
payload = p.model_dump()
|
||||
# ISO8601 sicherstellen
|
||||
if isinstance(payload.get("created_at"), datetime):
|
||||
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)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
# created_at zurück in datetime (ISO)
|
||||
if isinstance(found.get("created_at"), str):
|
||||
try:
|
||||
found["created_at"] = datetime.fromisoformat(found["created_at"])
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user