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
0b34b85a5a
commit
249f1aeea0
|
|
@ -1,13 +1,12 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
plan_router.py – v0.13.3 (WP-15)
|
plan_router.py – v0.13.4 (WP-15)
|
||||||
|
|
||||||
Minimal-CRUD + List/Filter für Templates & Pläne.
|
Änderungen ggü. v0.13.3
|
||||||
|
- Idempotenter POST /plan: Wenn ein Plan mit gleichem Fingerprint existiert und die neue
|
||||||
Änderungen ggü. v0.13.2
|
Anfrage ein späteres `created_at` trägt, wird der gespeicherte Plan mit dem neueren
|
||||||
- /plans: Mehrseitiges Scrollen, bis mindestens offset+limit Treffer eingesammelt sind.
|
`created_at` und `created_at_ts` aktualisiert (kein Duplikat, aber zeitlich „frisch“).
|
||||||
- Stabilisiert Zeitfenster-Filter in großen Collections; verhindert leere Resultate,
|
- /plans: Mehrseitiges Scrollen bleibt aktiv; Zeitfenster-Filter robust (serverseitig + Fallback).
|
||||||
wenn gesuchte Items nicht auf der ersten Scroll-Seite liegen.
|
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -41,8 +40,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] = [] # wünschenswert
|
ideal_keywords: List[str] = []
|
||||||
supplement_keywords: List[str] = [] # ergänzend
|
supplement_keywords: List[str] = []
|
||||||
forbid_keywords: List[str] = []
|
forbid_keywords: List[str] = []
|
||||||
capability_targets: Dict[str, int] = {}
|
capability_targets: Dict[str, int] = {}
|
||||||
|
|
||||||
|
|
@ -147,14 +146,15 @@ def _fingerprint_for_plan(p: Plan) -> str:
|
||||||
raw = json.dumps(core, sort_keys=True, ensure_ascii=False)
|
raw = json.dumps(core, sort_keys=True, ensure_ascii=False)
|
||||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
def _get_by_field(collection: str, key: str, value: Any) -> Optional[Dict[str, Any]]:
|
def _get_by_field(collection: str, key: str, value: Any):
|
||||||
flt = Filter(must=[FieldCondition(key=key, match=MatchValue(value=value))])
|
flt = Filter(must=[FieldCondition(key=key, match=MatchValue(value=value))])
|
||||||
pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=True)
|
pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=True)
|
||||||
if not pts:
|
if not pts:
|
||||||
return None
|
return None
|
||||||
payload = dict(pts[0].payload or {})
|
point = pts[0]
|
||||||
payload.setdefault("id", str(pts[0].id))
|
payload = dict(point.payload or {})
|
||||||
return payload
|
payload.setdefault("id", str(point.id))
|
||||||
|
return {"id": point.id, "payload": payload}
|
||||||
|
|
||||||
def _as_model(model_cls, payload: Dict[str, Any]):
|
def _as_model(model_cls, payload: Dict[str, Any]):
|
||||||
fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {})
|
fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {})
|
||||||
|
|
@ -170,8 +170,13 @@ def _exists_in_collection(collection: str, key: str, value: Any) -> bool:
|
||||||
pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=False)
|
pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=False)
|
||||||
return bool(pts)
|
return bool(pts)
|
||||||
|
|
||||||
|
def _parse_iso_to_ts(iso_str: str) -> float:
|
||||||
|
try:
|
||||||
|
return float(datetime.fromisoformat(iso_str.replace("Z", "+00:00")).timestamp())
|
||||||
|
except Exception:
|
||||||
|
return float(datetime.now(timezone.utc).timestamp())
|
||||||
|
|
||||||
def _scroll_collect(collection: str, flt: Optional[Filter], need: int, page: int = 256):
|
def _scroll_collect(collection: str, flt: Optional[Filter], need: int, page: int = 256):
|
||||||
"""Scrollt mehrere Seiten und sammelt mind. `need` Punkte ein (oder bis keine mehr kommen)."""
|
|
||||||
out = []
|
out = []
|
||||||
offset = None
|
offset = None
|
||||||
page = max(1, min(page, 1024))
|
page = max(1, min(page, 1024))
|
||||||
|
|
@ -231,7 +236,7 @@ def get_plan_template(tpl_id: str):
|
||||||
found = _get_by_field(PLAN_TEMPLATE_COLLECTION, "id", tpl_id)
|
found = _get_by_field(PLAN_TEMPLATE_COLLECTION, "id", tpl_id)
|
||||||
if not found:
|
if not found:
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
return _as_model(PlanTemplate, found)
|
return _as_model(PlanTemplate, found["payload"])
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/plan_templates",
|
"/plan_templates",
|
||||||
|
|
@ -271,18 +276,10 @@ def list_plan_templates(
|
||||||
if goal:
|
if goal:
|
||||||
must.append(FieldCondition(key="goals", match=MatchValue(value=goal)))
|
must.append(FieldCondition(key="goals", match=MatchValue(value=goal)))
|
||||||
if keyword:
|
if keyword:
|
||||||
for k in (
|
for k in ("section_must_keywords","section_ideal_keywords","section_supplement_keywords","section_forbid_keywords"):
|
||||||
"section_must_keywords",
|
|
||||||
"section_ideal_keywords",
|
|
||||||
"section_supplement_keywords",
|
|
||||||
"section_forbid_keywords",
|
|
||||||
):
|
|
||||||
should.append(FieldCondition(key=k, match=MatchValue(value=keyword)))
|
should.append(FieldCondition(key=k, match=MatchValue(value=keyword)))
|
||||||
|
|
||||||
flt = None
|
flt = Filter(must=must or None, should=should or None) if (must or should) else None
|
||||||
if must or should:
|
|
||||||
flt = Filter(must=must or None, should=should or None)
|
|
||||||
|
|
||||||
need = max(offset + limit, 1)
|
need = max(offset + limit, 1)
|
||||||
pts = _scroll_collect(PLAN_TEMPLATE_COLLECTION, flt, need)
|
pts = _scroll_collect(PLAN_TEMPLATE_COLLECTION, flt, need)
|
||||||
items: List[PlanTemplate] = []
|
items: List[PlanTemplate] = []
|
||||||
|
|
@ -302,7 +299,7 @@ def list_plan_templates(
|
||||||
description=(
|
description=(
|
||||||
"Erstellt einen konkreten Trainingsplan.\n\n"
|
"Erstellt einen konkreten Trainingsplan.\n\n"
|
||||||
"Idempotenz: gleicher Fingerprint (title + items) → gleicher Plan (kein Duplikat).\n"
|
"Idempotenz: gleicher Fingerprint (title + items) → gleicher Plan (kein Duplikat).\n"
|
||||||
"Optional: Validierung von template_id und Exercises (Strict-Mode)."
|
"Bei erneutem POST mit späterem `created_at` wird `created_at`/`created_at_ts` des bestehenden Plans aktualisiert."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def create_plan(p: Plan):
|
def create_plan(p: Plan):
|
||||||
|
|
@ -324,19 +321,13 @@ def create_plan(p: Plan):
|
||||||
if missing:
|
if missing:
|
||||||
raise HTTPException(status_code=422, detail={"error": "unknown exercise_external_id", "missing": sorted(set(missing))})
|
raise HTTPException(status_code=422, detail={"error": "unknown exercise_external_id", "missing": sorted(set(missing))})
|
||||||
|
|
||||||
# Fingerprint + Idempotenz
|
# Fingerprint
|
||||||
fp = _fingerprint_for_plan(p)
|
fp = _fingerprint_for_plan(p)
|
||||||
p.fingerprint = p.fingerprint or fp
|
p.fingerprint = p.fingerprint or fp
|
||||||
existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint)
|
|
||||||
if existing:
|
|
||||||
return _as_model(Plan, existing)
|
|
||||||
|
|
||||||
# Normalisieren + Materialisierung
|
# Ziel-ISO + TS aus Request berechnen (auch wenn Duplikat)
|
||||||
p.goals = _norm_list(p.goals)
|
req_payload = p.model_dump()
|
||||||
payload = p.model_dump()
|
dt = req_payload.get("created_at")
|
||||||
|
|
||||||
# created_at → ISO + numerischer Zeitstempel (FLOAT)
|
|
||||||
dt = payload.get("created_at")
|
|
||||||
if isinstance(dt, datetime):
|
if isinstance(dt, datetime):
|
||||||
dt = dt.astimezone(timezone.utc).isoformat()
|
dt = dt.astimezone(timezone.utc).isoformat()
|
||||||
elif isinstance(dt, str):
|
elif isinstance(dt, str):
|
||||||
|
|
@ -346,22 +337,53 @@ def create_plan(p: Plan):
|
||||||
dt = datetime.now(timezone.utc).isoformat()
|
dt = datetime.now(timezone.utc).isoformat()
|
||||||
else:
|
else:
|
||||||
dt = datetime.now(timezone.utc).isoformat()
|
dt = datetime.now(timezone.utc).isoformat()
|
||||||
payload["created_at"] = dt
|
req_payload["created_at"] = dt
|
||||||
try:
|
req_ts = _parse_iso_to_ts(dt)
|
||||||
ts = datetime.fromisoformat(dt.replace("Z", "+00:00")).timestamp()
|
req_payload["created_at_ts"] = float(req_ts)
|
||||||
except Exception:
|
|
||||||
ts = datetime.now(timezone.utc).timestamp()
|
|
||||||
payload["created_at_ts"] = float(ts)
|
|
||||||
|
|
||||||
# Materialisierte Section-Namen (robuste Filter/Indizes)
|
# Dup-Check
|
||||||
try:
|
existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint)
|
||||||
payload["plan_section_names"] = _norm_list([
|
if existing:
|
||||||
(s.get("name") or "").strip() for s in (payload.get("sections") or []) if isinstance(s, dict)
|
# Falls neues created_at später ist → gespeicherten Plan aktualisieren
|
||||||
])
|
cur = existing["payload"]
|
||||||
except Exception:
|
cur_ts = cur.get("created_at_ts")
|
||||||
payload["plan_section_names"] = _norm_list([
|
if cur_ts is None:
|
||||||
(getattr(s, "name", "") or "").strip() for s in (p.sections or [])
|
cur_ts = _parse_iso_to_ts(str(cur.get("created_at", dt)))
|
||||||
])
|
if req_ts > float(cur_ts):
|
||||||
|
try:
|
||||||
|
qdrant.set_payload(
|
||||||
|
collection_name=PLAN_COLLECTION,
|
||||||
|
payload={"created_at": req_payload["created_at"], "created_at_ts": req_payload["created_at_ts"]},
|
||||||
|
points=[existing["id"]],
|
||||||
|
)
|
||||||
|
# Antwort-Objekt aktualisieren
|
||||||
|
cur["created_at"] = req_payload["created_at"]
|
||||||
|
cur["created_at_ts"] = req_payload["created_at_ts"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _as_model(Plan, cur)
|
||||||
|
|
||||||
|
# Neu anlegen
|
||||||
|
p.goals = _norm_list(p.goals)
|
||||||
|
payload = req_payload # enthält bereits korrektes created_at + created_at_ts
|
||||||
|
payload.update({
|
||||||
|
"id": p.id,
|
||||||
|
"template_id": p.template_id,
|
||||||
|
"title": p.title,
|
||||||
|
"discipline": p.discipline,
|
||||||
|
"age_group": p.age_group,
|
||||||
|
"target_group": p.target_group,
|
||||||
|
"total_minutes": p.total_minutes,
|
||||||
|
"sections": [s.model_dump() for s in p.sections],
|
||||||
|
"goals": _norm_list(p.goals),
|
||||||
|
"capability_summary": p.capability_summary,
|
||||||
|
"novelty_against_last_n": p.novelty_against_last_n,
|
||||||
|
"fingerprint": p.fingerprint,
|
||||||
|
"created_by": p.created_by,
|
||||||
|
"source": p.source,
|
||||||
|
})
|
||||||
|
# Section-Namen materialisieren
|
||||||
|
payload["plan_section_names"] = _norm_list([ (s.get("name") or "").strip() for s in (payload.get("sections") or []) if isinstance(s, dict) ])
|
||||||
|
|
||||||
vec = _embed(_plan_embed_text(p))
|
vec = _embed(_plan_embed_text(p))
|
||||||
qdrant.upsert(collection_name=PLAN_COLLECTION, points=[PointStruct(id=str(p.id), vector=vec, payload=payload)])
|
qdrant.upsert(collection_name=PLAN_COLLECTION, points=[PointStruct(id=str(p.id), vector=vec, payload=payload)])
|
||||||
|
|
@ -378,12 +400,13 @@ 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")
|
||||||
if isinstance(found.get("created_at"), str):
|
payload = found["payload"]
|
||||||
|
if isinstance(payload.get("created_at"), str):
|
||||||
try:
|
try:
|
||||||
found["created_at"] = datetime.fromisoformat(found["created_at"])
|
payload["created_at"] = datetime.fromisoformat(payload["created_at"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return _as_model(Plan, found)
|
return _as_model(Plan, payload)
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/plans",
|
"/plans",
|
||||||
|
|
@ -457,13 +480,12 @@ def list_plans(
|
||||||
def _in_window(py: Dict[str, Any]) -> bool:
|
def _in_window(py: Dict[str, Any]) -> bool:
|
||||||
if not (created_from or created_to):
|
if not (created_from or created_to):
|
||||||
return True
|
return True
|
||||||
# Wenn serverseitig Range aktiv war und Treffer kamen, brauchen wir keinen lokalen Check
|
|
||||||
if applied_server_range and not fallback_local_time_check:
|
if applied_server_range and not fallback_local_time_check:
|
||||||
return True
|
return True # serverseitig bereits gefiltert
|
||||||
ts = py.get("created_at")
|
ts = py.get("created_at")
|
||||||
if isinstance(ts, dict) and ts.get("$date"):
|
if isinstance(ts, dict) and ts.get("$date"):
|
||||||
ts = ts["$date"]
|
ts = ts["$date"]
|
||||||
if isinstance(ts, (int, float)) and py.get("created_at_ts") is not None:
|
if isinstance(py.get("created_at_ts"), (int, float)):
|
||||||
dt = datetime.fromtimestamp(float(py["created_at_ts"]), tz=timezone.utc)
|
dt = datetime.fromtimestamp(float(py["created_at_ts"]), tz=timezone.utc)
|
||||||
elif isinstance(ts, str):
|
elif isinstance(ts, str):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user