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
070f9967bc
commit
58d2260d89
|
|
@ -1,16 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
plan_router.py – v0.12.4 (WP-15)
|
||||
plan_router.py – v0.12.6 (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
|
||||
+ List-/Filter-Endpoints – robust & dokumentiert.
|
||||
|
||||
Änderungen ggü. v0.12.1/0.12.3:
|
||||
- FIX: POST /plan materialisiert `plan_section_names` im Payload (robuste Section-Filter).
|
||||
- GET /plans filtert auf `plan_section_names` (statt verschachteltem `sections.name`).
|
||||
- GET /plan_templates & GET /plans: Filter + Paging + Swagger-Beschreibungen.
|
||||
- Optional: Strict-Checks (template_id/exercises) via ENV.
|
||||
Änderungen ggü. v0.12.4:
|
||||
- FIX: POST /plan materialisiert jetzt `plan_section_names` (dedupliziert, getrimmt, sortiert)
|
||||
- FIX: GET /plans filtert ausschließlich über `plan_section_names` (kein verschachteltes `sections.name` mehr)
|
||||
- Doku: Swagger-Descriptions/Beispiele für alle neuen Query-Parameter
|
||||
- Beibehalt: optionale Strict-Validierung von Exercises via ENV `PLAN_STRICT_EXERCISES`
|
||||
- Beibehalt: Referenz-Validierung `template_id` → 422, wenn unbekannt
|
||||
|
||||
Hinweis: KEYWORD-Index für `plans.plan_section_names` per bootstrap sicherstellen.
|
||||
Voraussetzung: KEYWORD-Index für `plans.plan_section_names` durch bootstrap-script (v1.2.x)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -41,8 +43,8 @@ class TemplateSection(BaseModel):
|
|||
name: str
|
||||
target_minutes: int
|
||||
must_keywords: List[str] = []
|
||||
ideal_keywords: List[str] = [] # optional (wünschenswert)
|
||||
supplement_keywords: List[str] = [] # optional (ergänzend)
|
||||
ideal_keywords: List[str] = [] # wünschenswert
|
||||
supplement_keywords: List[str] = [] # ergänzend
|
||||
forbid_keywords: List[str] = []
|
||||
capability_targets: Dict[str, int] = {}
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ class PlanList(BaseModel):
|
|||
# -----------------
|
||||
# Helpers
|
||||
# -----------------
|
||||
|
||||
def _ensure_collection(name: str):
|
||||
"""Legt Collection an, wenn sie fehlt (analog exercise_router)."""
|
||||
if not qdrant.collection_exists(name):
|
||||
|
|
@ -109,6 +112,7 @@ def _ensure_collection(name: str):
|
|||
vectors_config=VectorParams(size=model.get_sentence_embedding_dimension(), distance=Distance.COSINE),
|
||||
)
|
||||
|
||||
|
||||
def _norm_list(xs: List[str]) -> List[str]:
|
||||
"""Trimmen, casefolded deduplizieren, stabil sortieren."""
|
||||
seen, out = set(), []
|
||||
|
|
@ -120,21 +124,25 @@ def _norm_list(xs: List[str]) -> List[str]:
|
|||
out.append(s)
|
||||
return sorted(out, key=str.casefold)
|
||||
|
||||
|
||||
def _template_embed_text(tpl: PlanTemplate) -> str:
|
||||
parts = [tpl.name, tpl.discipline, tpl.age_group, tpl.target_group]
|
||||
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:
|
||||
"""sha256(title, total_minutes, sections.items.exercise_external_id, sections.items.duration)"""
|
||||
core = {
|
||||
|
|
@ -149,6 +157,7 @@ def _fingerprint_for_plan(p: Plan) -> str:
|
|||
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)
|
||||
|
|
@ -158,6 +167,7 @@ def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, A
|
|||
payload.setdefault("id", str(pts[0].id))
|
||||
return payload
|
||||
|
||||
|
||||
def _as_model(model_cls, payload: Dict[str, Any]):
|
||||
"""Filtert unbekannte Payload-Felder heraus (Pydantic v1/v2 kompatibel)."""
|
||||
fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {})
|
||||
|
|
@ -165,9 +175,11 @@ 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)
|
||||
|
|
@ -209,6 +221,7 @@ def create_plan_template(t: PlanTemplate):
|
|||
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,
|
||||
|
|
@ -222,6 +235,7 @@ def get_plan_template(tpl_id: str):
|
|||
raise HTTPException(status_code=404, detail="not found")
|
||||
return _as_model(PlanTemplate, found)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/plan_templates",
|
||||
response_model=PlanTemplateList,
|
||||
|
|
@ -339,6 +353,7 @@ def create_plan(p: Plan):
|
|||
qdrant.upsert(collection_name=PLAN_COLLECTION, points=[PointStruct(id=str(p.id), vector=vec, payload=payload)])
|
||||
return p
|
||||
|
||||
|
||||
@router.get(
|
||||
"/plan/{plan_id}",
|
||||
response_model=Plan,
|
||||
|
|
@ -357,6 +372,7 @@ def get_plan(plan_id: str):
|
|||
pass
|
||||
return _as_model(Plan, found)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/plans",
|
||||
response_model=PlanList,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user