Compare commits

...

51 Commits

Author SHA1 Message Date
cadd23e554 llm-api/llm_api.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-09-03 13:00:16 +02:00
ad6df74ef4 llm-api/audit_ki_stack.sh hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-09-01 13:07:42 +02:00
9327bc48d8 llm-api/wiki_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-14 09:07:16 +02:00
508fafd0df llm-api/llm_api.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-14 08:38:43 +02:00
1d50e7042e llm-api/wiki_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-14 08:11:15 +02:00
6a4e97f4e4 llm-api/exercise_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-13 12:39:44 +02:00
59e7e64af7 PMO/WP-17-kickoff.md hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-13 12:31:40 +02:00
249f1aeea0 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 11:47:18 +02:00
0b34b85a5a llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 11:37:10 +02:00
00a8837aa1 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-13 11:24:17 +02:00
5e2591fb56 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-13 11:17:36 +02:00
93cdde13a7 scripts/bootstrap_qdrant_plans.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 08:56:08 +02:00
0c143124b3 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 08:55:35 +02:00
58d2260d89 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 08:12:10 +02:00
070f9967bc scripts/bootstrap_qdrant_plans.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 08:02:43 +02:00
d2af8881a8 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 08:01:09 +02:00
9fd41ce3f0 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 07:51:53 +02:00
d9eefcb1fa llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 07:39:31 +02:00
78cf89c0fa llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 07:28:47 +02:00
c0de60e4a5 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 07:17:59 +02:00
123df8a48a tests/test_plan_lists_wp15.py hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 07:07:29 +02:00
7f821b5723 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 07:06:08 +02:00
597b94ff25 llm-api/wiki_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 06:52:28 +02:00
375ed57778 llm-api/wiki_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-13 06:50:11 +02:00
41e5db3921 llm-api/.env.example hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 16:40:09 +02:00
af08c64032 tests/test_integrity_wp15.py gelöscht
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 16:33:38 +02:00
ed05448e56 tests/test_integrity_wp15.py hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 16:33:21 +02:00
ff58caaad0 llm-api/plan_session_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 16:26:15 +02:00
36c82ac942 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 16:25:32 +02:00
4fbfdb1c6a tests/test_integrity_wp15.py hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 16:24:33 +02:00
0805e48fe6 .gitea/workflows/deploy.yml aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 13:23:33 +02:00
427d3f5419 .gitea/workflows/deploy.yml aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 13:22:31 +02:00
16890af944 llm-api/llm_api.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 13:12:17 +02:00
32e673044f llm-api/llm_api.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 13:09:32 +02:00
40b1151023 llm-api/plan_session_router.py hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 13:08:29 +02:00
5c51d3bc4f tests/test_plan_sessions_wp15.py hinzugefügt 2025-08-12 13:07:36 +02:00
1dbcf33540 scripts/test_plans_wp15.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
2025-08-12 13:01:46 +02:00
3806f4ac47 scripts/test_plans_wp15.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 12:57:01 +02:00
4d67cd9d66 scripts/bootstrap_qdrant_plans.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 12:52:41 +02:00
9c955db191 schemas/plan_templates.json aktualisiert 2025-08-12 12:50:29 +02:00
81473e20eb llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 12:46:48 +02:00
798e103eb8 scripts/test_plans_wp15.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 10:48:04 +02:00
d65129f477 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 10:32:20 +02:00
482605e6a1 llm-api/plan_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 10:31:28 +02:00
47b2519b0b llm-api/llm_api.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 10:29:38 +02:00
c3b2ee3310 scripts/test_plans_wp15.py hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 10:27:51 +02:00
31d1e85b5c scripts/bootstrap_qdrant_plans.py hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 10:26:04 +02:00
5dbe887ce3 llm-api/plan_router.py hinzugefügt
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 2s
2025-08-12 10:24:36 +02:00
4552e33cb3 schemas/plan_sessions.json hinzugefügt 2025-08-12 10:23:40 +02:00
c53aade360 schemas/plans.json hinzugefügt 2025-08-12 10:22:57 +02:00
d88979e37a schemas/plan_templates.json hinzugefügt 2025-08-12 10:22:03 +02:00
17 changed files with 2342 additions and 187 deletions

View File

@ -3,10 +3,7 @@ name: Deploy Trainer_LLM to llm-node
on:
push:
branches: [ "main" ]
paths:
- "knowledge/**"
- "llm-api/**"
- "scripts/**"
# Kein paths-Filter mehr: neue Ordner deployen sofort mit, sobald sie in DEPLOY_DIRS stehen.
concurrency:
group: deploy-trainer-llm
@ -14,65 +11,52 @@ concurrency:
jobs:
deploy:
runs-on: linux_host # muss zum Runner-Label passen
runs-on: linux_host
env:
# -> Hier trägst du ALLE zu deployenden Top-Level-Verzeichnisse ein:
# Bei neuen Ordnern einfach anhängen (durch Leerzeichen getrennt)
DEPLOY_DIRS: "knowledge llm-api scripts schemas tests"
TARGET_BASE: "/home/llmadmin"
steps:
- name: Checkout
# Absolute URL reduziert Abhängigkeit von DEFAULT_ACTIONS_URL
uses: https://github.com/actions/checkout@v4
- name: Sanity — Runner & Commit
run: |
echo "Runner: $RUNNER_NAME Labels: $RUNNER_LABELS"
echo "Commit: $GITEA_SHA Ref: $GITEA_REF"
echo "Commit: ${GITHUB_SHA:-$GITEA_SHA} Ref: ${GITHUB_REF:-$GITEA_REF}"
uname -a
- name: Debug whoami & write test
- name: Ensure target base exists
run: |
whoami
id
getent passwd $(whoami) || true
# Testet Schreibrecht unter /home/llmadmin
touch /home/llmadmin/.write_test && rm /home/llmadmin/.write_test || echo "no write"
install -d "$TARGET_BASE"
- name: Ensure target directories exist
- name: Deploy whitelisted directories
run: |
mkdir -p /home/llmadmin/knowledge
mkdir -p /home/llmadmin/llm-api
mkdir -p /home/llmadmin/scripts
set -euo pipefail
IFS=' ' read -r -a DIRS <<< "$DEPLOY_DIRS"
- name: Rsync knowledge/
if: ${{ hashFiles('knowledge/**') != '' }}
run: |
rsync -a --delete \
--exclude='.git' \
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
knowledge/ /home/llmadmin/knowledge/
echo "Synced knowledge/"
- name: Rsync llm-api/
if: ${{ hashFiles('llm-api/**') != '' }}
run: |
rsync -a --delete \
--exclude='.git' \
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
llm-api/ /home/llmadmin/llm-api/
echo "Synced llm-api/"
- name: Rsync scripts/
if: ${{ hashFiles('scripts/**') != '' }}
run: |
rsync -a --delete \
--exclude='.git' \
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
scripts/ /home/llmadmin/scripts/
echo "Synced scripts/"
for d in "${DIRS[@]}"; do
if [ -d "$d" ]; then
# Nur wenn im Ordner auch Dateien/Unterordner liegen
if [ -n "$(find "$d" -mindepth 1 -print -quit)" ]; then
echo ">> Syncing $d -> $TARGET_BASE/$d"
install -d "$TARGET_BASE/$d"
rsync -a --delete \
--exclude='.git' \
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
"$d"/ "$TARGET_BASE/$d"/
else
echo ">> Skipping $d (leer)"
fi
else
echo ">> Skipping $d (existiert nicht im Repo)"
fi
done
- name: Optional — systemctl --user restart llm-api (ignore if missing)
continue-on-error: true
env:
XDG_RUNTIME_DIR: /run/user/${{ inputs.uid || 1000 }}
run: |
# Versuche nur zu restarten, wenn der Service existiert
if systemctl --user list-unit-files | grep -q '^llm-api.service'; then
systemctl --user restart llm-api.service
systemctl --user --no-pager status llm-api.service --full -l || true
@ -80,9 +64,9 @@ jobs:
echo "llm-api.service nicht gefunden — Schritt wird übersprungen."
fi
- name: Post-check — show latest changes
- name: Post-check — list targets
run: |
echo "Deploy complete. Listing targets:"
ls -la /home/llmadmin/knowledge | tail -n +1 || true
ls -la /home/llmadmin/llm-api | tail -n +1 || true
ls -la /home/llmadmin/scripts | tail -n +1 || true
for d in $DEPLOY_DIRS; do
echo "== $TARGET_BASE/$d =="
ls -la "$TARGET_BASE/$d" 2>/dev/null | tail -n +1 || true
done

90
PMO/WP-17-kickoff.md Normal file
View File

@ -0,0 +1,90 @@
# WP-17 Retriever & Composer (Kern ohne LLM)
## Projektkontext
Wir entwickeln eine deterministische Planerstellung aus bestehenden **plan_templates** und **exercises**.
WP-15 hat die Collections, Indizes und CRUD-APIs für `plan_templates` und `plans` produktiv geliefert.
WP-02 stellt die exercises-Collection mit Capabilities und Qdrant-Anbindung bereit.
**Technologie-Stack:** Python 3.12, FastAPI, Qdrant
---
## Ziele
Implementierung eines `/plan/generate`-Endpoints, der:
- Filter- und Vektor-Suche in Qdrant kombiniert
- Scoring nach Coverage, Diversity und Novelty durchführt
- Pläne deterministisch und ohne LLM generiert
- Zeitbudgets einhält und Wiederholungen (Novelty-Penalty) vermeidet
---
## Deliverables
1. **API**: POST `/plan/generate`
- Parameter: `discipline`, `age_group`, `target_group`, `goals`, `time_budget_minutes`, `novelty_horizon` (5), `coverage_threshold` (0.8), `strict_mode`
- Rückgabe: Plan-JSON mit Exercises-Referenzen und Metadaten
2. **Retriever**
- Filter-Layer (Payload)
- Vector-Layer (Ranking)
- Kombinierte Gewichtung
3. **Composer**
- Sections aufbauen (aus Template oder Default)
- Zeitbudget pro Section und Gesamt einhalten
- Strict-Mode: nur gültige `external_id`
4. **Scoring-Funktionen**
- Coverage (Capabilites-Abdeckung)
- Diversity (Variabilität)
- Novelty (Neuheit gegenüber Historie)
5. **Tests**
- Unit-Tests (Scoring, Filter)
- E2E: Template → Retriever → Composer → Persistenz
6. **Dokumentation**
- OpenAPI-Beispiele, Parametrierung, Konfigurationsoptionen
---
## Akzeptanzkriterien
- Identische Eingaben → identischer Plan (Determinismus)
- Keine doppelten Übungen im Plan
- Budget- und Coverage-Ziele in ≥95 % der Testfälle erreicht
- Novelty-Penalty wirkt wie konfiguriert
---
## Risiken
- Konflikte zwischen Budget, Coverage, Novelty (Priorisierung erforderlich)
- Geringe Übungsvielfalt → eingeschränkte Ergebnisse
- Performance-Einbußen bei großen Collections
---
## Technische Vorgaben
**Voreinstellungen:**
- `novelty_horizon`: 5
- `coverage_threshold`: 0.8
- Priorität bei Konflikt: 1. Budget, 2. Coverage, 3. Novelty
**Benötigte Dateien:**
- `llm-api/plan_router.py` (v0.13.4)
- `llm-api/exercise_router.py` (aus WP-02)
- `scripts/bootstrap_qdrant_plans.py` (v1.3.x)
- Schema-Definitionen für `plan_templates` und `plans`
- Beispiel-Datensätze (Golden-Cases)
- `.env` (ohne Secrets, mit API-URLs)
---
## Prompt für das Entwicklerteam (direkt nutzbar)
> **Rolle:** Entwicklerteam WP-17 Retriever & Composer (Kern ohne LLM)
> **Aufgabe:** Implementiere `/plan/generate`, der deterministisch aus plan_templates und exercises Pläne generiert.
> Nutze Filter- und Vektor-Suche in Qdrant, Scoring-Funktionen (Coverage, Diversity, Novelty) und eine Composer-Logik, die Zeitbudgets einhält.
> **Parameter:** discipline, age_group, target_group, goals, time_budget_minutes, novelty_horizon=5, coverage_threshold=0.8, strict_mode.
> **Anforderungen:** Deterministische Ergebnisse, keine Duplikate, ≥95 % Zielerreichung bei Budget/Coverage, funktionierender Novelty-Penalty.
> **Rahmen:** Python 3.12, FastAPI, Qdrant, vorhandene plan_templates/plans/exercises-Collections.
> **Liefere:** Code, Unit- und E2E-Tests, OpenAPI-Doku mit Beispielen.
> **Dateien:** siehe Liste oben.

77
llm-api/.env.example Normal file
View File

@ -0,0 +1,77 @@
# ======================
# Laufzeit / Server
# ======================
UVICORN_HOST=0.0.0.0
UVICORN_PORT=8000
LOG_LEVEL=INFO
# ======================
# Qdrant Verbindung
# ======================
QDRANT_HOST=127.0.0.1
QDRANT_PORT=6333
QDRANT_URL=http://localhost:6333
# ======================
# Collections Namen
# Hinweise:
# - PLAN_COLLECTION wird von unseren neuen Routern verwendet.
# - Einige ältere Komponenten nutzen ggf. *QDRANT_COLLECTION_PLANS*/*QDRANT_COLLECTION_EXERCISES*.
# Belasse sie konsistent oder kommentiere sie aus, um Verwirrung zu vermeiden.
# ======================
PLAN_COLLECTION=plans
PLAN_TEMPLATE_COLLECTION=plan_templates
PLAN_SESSION_COLLECTION=plan_sessions
EXERCISE_COLLECTION=exercises
# Kompatibilität (optional, falls von Alt-Code gelesen):
# QDRANT_COLLECTION_PLANS=training_plans
# QDRANT_COLLECTION_EXERCISES=exercises
# ======================
# Strict-Mode für /plan
# 0 / leer = aus (Standard)
# 1/true/...= an → jede exercise_external_id muss in EXERCISE_COLLECTION existieren, sonst 422
# ======================
PLAN_STRICT_EXERCISES=0
# ======================
# Ollama (LLM) lokal
# ======================
OLLAMA_URL=http://127.0.0.1:11434/api/generate
OLLAMA_ENDPOINT=/api/generate
OLLAMA_MODEL=mistral
OLLAMA_TIMEOUT_SECONDS=120
# ======================
# Embeddings
# ======================
EMBEDDING_MODEL=all-MiniLM-L6-v2
EMBEDDING_DIM=384
# ======================
# FastAPI / App Defaults
# ======================
DEFAULT_COLLECTION=default
API_TITLE="KI Trainerassistent API"
API_DESCRIPTION="Lokale API für Trainingsplanung (Karate, Gewaltschutz, etc.)"
# ======================
# Wiki Importer
# ======================
API_BASE_URL=http://localhost:8000
WIKI_BASE_URL=https://karatetrainer.net
WIKI_API_URL=https://karatetrainer.net/api.php
WIKI_BOT_USER=Bot
WIKI_BOT_PASSWORD=***set_me***
WIKI_SMW_LIMIT=500
WIKI_SMW_OFFSET=0
WIKI_TIMEOUT=15
WIKI_BATCH=50
WIKI_RETRIES=1
WIKI_SLEEP_MS=0
# ======================
# Test-/Hilfs-URLs (für pytest & Tools)
# ======================
BASE_URL=http://127.0.0.1:8000
QDRANT_BASE=http://127.0.0.1:6333

45
llm-api/audit_ki_stack.sh Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=== SYSTEM ==="
uname -a || true
echo
echo "CPU/Mem:"
lscpu | egrep 'Model name|CPU\(s\)|Thread|Core|Socket' || true
free -h || true
echo
echo "Disk:"
df -hT | awk 'NR==1 || /\/(srv|opt|home|var|$)/'
echo
echo "=== DOCKER ==="
docker --version || true
docker compose version || docker-compose --version || true
echo
echo "Running containers:"
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}" || true
echo
echo "=== PYTHON ==="
python3 --version || true
python3.12 --version || true
pip --version || true
echo
echo "=== NODE/NPM (für n8n, falls nativ) ==="
node -v || true
npm -v || true
echo
echo "=== BESETZTE PORTS (root zeigt Prozesse) ==="
for p in 8000 6333 11434 5678; do
echo "--- Port $p ---"
(sudo ss -ltnp | grep ":$p ") || echo "frei"
done
echo
echo "=== DIENSTE / HINWEISE ==="
systemctl list-units --type=service | egrep -i 'qdrant|ollama|n8n|uvicorn|gunicorn' || true
echo
echo "Fertig. Prüfe ob Ports frei sind und welche Container bereits laufen."

View File

@ -1,15 +1,17 @@
# -*- coding: utf-8 -*-
"""
exercise_router.py v1.7.0
exercise_router.py v1.7.1 (Swagger angereichert)
Neu:
- Endpoint **POST /exercise/search**: kombinierbare Filter (discipline, duration, equipment any/all, keywords any/all,
capability_geN / capability_eqN + names) + optionaler Vektor-Query (query-Text). Ausgabe inkl. Score.
- Facetten erweitert: neben capability_ge1..ge5 jetzt auch capability_eq1..eq5.
- Idempotenz-Fix & Payload-Scroll (aus v1.6.2) beibehalten.
- API-Signaturen bestehender Routen unverändert.
Ergänzt:
- Aussagekräftige summary/description/response_description je Endpoint
- Beispiele (x-codeSamples) für curl-Aufrufe
- Pydantic-Felder mit description + json_schema_extra (Beispiele)
- Keine API-Signatur-/Pfadänderungen, keine Prefix-Änderungen
Hinweis: Die eq/ge-Felder werden beim Upsert gesetzt; für Alt-Punkte einmal das Backfill laufen lassen.
Hinweis:
- Endpunkte bleiben weiterhin unter /exercise/* (weil die Routenstrings bereits /exercise/... enthalten).
- Falls du später einen APIRouter-Prefix setzen willst, dann bitte die Pfade unten von '/exercise/...' auf relative Pfade ändern,
sonst entstehen Doppelpfade.
"""
from fastapi import APIRouter, HTTPException, Query
@ -27,77 +29,137 @@ from qdrant_client.models import (
FieldCondition,
MatchValue,
)
import logging
import os
router = APIRouter()
logger = logging.getLogger("exercise_router")
logger.setLevel(logging.INFO)
# Router ohne prefix (Pfadstrings enthalten bereits '/exercise/...')
router = APIRouter(tags=["exercise"])
# =========================
# Models
# =========================
class Exercise(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
id: str = Field(default_factory=lambda: str(uuid4()), description="Interne UUID (Qdrant-Punkt-ID)")
# Upsert-Metadaten
external_id: Optional[str] = None
fingerprint: Optional[str] = None
source: Optional[str] = None
imported_at: Optional[datetime] = None
external_id: Optional[str] = Field(default=None, description="Upsert-Schlüssel (z. B. 'mw:{pageid}')")
fingerprint: Optional[str] = Field(default=None, description="sha256 der Kernfelder für Idempotenz/Diff")
source: Optional[str] = Field(default=None, description="Quelle (z. B. 'mediawiki', 'pdf-import', …)")
imported_at: Optional[datetime] = Field(default=None, description="Zeitpunkt des Imports (ISO-8601)")
# Domain-Felder
title: str
summary: str
short_description: str
keywords: List[str] = []
link: Optional[str] = None
discipline: str
group: Optional[str] = None
age_group: str
target_group: str
min_participants: int
duration_minutes: int
capabilities: Dict[str, int] = {}
category: str
purpose: str
execution: str
notes: str
preparation: str
method: str
equipment: List[str] = []
title: str = Field(..., description="Übungstitel")
summary: str = Field(..., description="Kurzbeschreibung/Ziel der Übung")
short_description: str = Field(..., description="Alternative Kurzform / Teaser")
keywords: List[str] = Field(default_factory=list, description="Freie Schlagworte (normalisiert)")
link: Optional[str] = Field(default=None, description="Kanonsiche URL/Permalink zur Quelle")
discipline: str = Field(..., description="Disziplin (z. B. Karate)")
group: Optional[str] = Field(default=None, description="Optionale Gruppierung/Kategorie")
age_group: str = Field(..., description="Altersgruppe (z. B. Kinder/Schüler/Teenager/Erwachsene)")
target_group: str = Field(..., description="Zielgruppe (z. B. Breitensportler)")
min_participants: int = Field(..., ge=0, description="Minimale Gruppenstärke")
duration_minutes: int = Field(..., ge=0, description="Dauer in Minuten")
capabilities: Dict[str, int] = Field(default_factory=dict, description="Fähigkeiten-Map: {Name: Level 1..5}")
category: str = Field(..., description="Abschnitt / Kategorie (z. B. Aufwärmen, Grundschule, …)")
purpose: str = Field(..., description="Zweck/Zielabsicht")
execution: str = Field(..., description="Durchführungsschritte (Markdown/Wiki-ähnlich)")
notes: str = Field(..., description="Hinweise/Coaching-Cues")
preparation: str = Field(..., description="Vorbereitung/Material")
method: str = Field(..., description="Methodik/Didaktik")
equipment: List[str] = Field(default_factory=list, description="Benötigte Hilfsmittel")
model_config = {
"json_schema_extra": {
"example": {
"external_id": "mw:218",
"title": "Affenklatschen",
"summary": "Koordination & Aufmerksamkeit mit Ballwechseln",
"short_description": "Ballgewöhnung im Stand/Gehen/Laufen",
"keywords": ["Hand-Auge-Koordination", "Reaktion"],
"link": "https://www.karatetrainer.de/index.php?title=Affenklatschen",
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensportler",
"min_participants": 4,
"duration_minutes": 8,
"capabilities": {"Reaktionsfähigkeit": 2, "Kopplungsfähigkeit": 2},
"category": "Aufwärmen",
"purpose": "Aufmerksamkeit & Reaktionskette aktivieren",
"execution": "* Paarweise aufstellen …",
"notes": "* nicht zu lange werden lassen",
"preparation": "* Bälle bereit halten",
"method": "* klare Regeln/Strafrunde",
"equipment": ["Bälle"]
}
}
}
class DeleteResponse(BaseModel):
status: str
count: int
collection: str
status: str = Field(..., description="Statusmeldung")
count: int = Field(..., ge=0, description="Anzahl betroffener Punkte")
collection: str = Field(..., description="Qdrant-Collection-Name")
class ExerciseSearchRequest(BaseModel):
# Optionaler Semantik-Query (Vektor)
query: Optional[str] = None
limit: int = Field(default=20, ge=1, le=200)
offset: int = Field(default=0, ge=0)
query: Optional[str] = Field(default=None, description="Freitext für Vektor-Suche (optional)")
limit: int = Field(default=20, ge=1, le=200, description="Max. Treffer")
offset: int = Field(default=0, ge=0, description="Offset/Pagination")
# Einfache Filter
discipline: Optional[str] = None
target_group: Optional[str] = None
age_group: Optional[str] = None
max_duration: Optional[int] = Field(default=None, ge=0)
discipline: Optional[str] = Field(default=None, description="z. B. Karate")
target_group: Optional[str] = Field(default=None, description="z. B. Breitensportler")
age_group: Optional[str] = Field(default=None, description="z. B. Teenager")
max_duration: Optional[int] = Field(default=None, ge=0, description="Obergrenze Minuten")
# Listen-Filter
equipment_any: Optional[List[str]] = None # mindestens eins muss passen
equipment_all: Optional[List[str]] = None # alle müssen passen
keywords_any: Optional[List[str]] = None
keywords_all: Optional[List[str]] = None
equipment_any: Optional[List[str]] = Field(default=None, description="Mind. eines muss passen")
equipment_all: Optional[List[str]] = Field(default=None, description="Alle müssen passen")
keywords_any: Optional[List[str]] = Field(default=None, description="Mind. eines muss passen")
keywords_all: Optional[List[str]] = Field(default=None, description="Alle müssen passen")
# Capabilities (Namen + Level-Operator)
capability_names: Optional[List[str]] = None
capability_ge_level: Optional[int] = Field(default=None, ge=1, le=5)
capability_eq_level: Optional[int] = Field(default=None, ge=1, le=5)
capability_names: Optional[List[str]] = Field(default=None, description="Capability-Bezeichnungen")
capability_ge_level: Optional[int] = Field(default=None, ge=1, le=5, description="Level ≥ N")
capability_eq_level: Optional[int] = Field(default=None, ge=1, le=5, description="Level == N")
model_config = {
"json_schema_extra": {
"examples": [{
"discipline": "Karate",
"max_duration": 12,
"equipment_any": ["Bälle"],
"capability_names": ["Reaktionsfähigkeit"],
"capability_ge_level": 2,
"limit": 5
}, {
"query": "Aufwärmen Reaktionsfähigkeit 10min Teenager Bälle",
"discipline": "Karate",
"limit": 3
}]
}
}
class ExerciseSearchHit(BaseModel):
id: str
score: Optional[float] = None
payload: Exercise
id: str = Field(..., description="Qdrant-Punkt-ID")
score: Optional[float] = Field(default=None, description="Ähnlichkeitsscore (nur bei Vektor-Suche)")
payload: Exercise = Field(..., description="Übungsdaten (Payload)")
class ExerciseSearchResponse(BaseModel):
hits: List[ExerciseSearchHit]
hits: List[ExerciseSearchHit] = Field(..., description="Trefferliste")
model_config = {
"json_schema_extra": {
"example": {
"hits": [{
"id": "c1f1-…",
"score": 0.78,
"payload": Exercise.model_config["json_schema_extra"]["example"]
}]
}
}
}
# =========================
# Helpers
@ -160,6 +222,12 @@ def _norm_list(xs: List[Any]) -> List[str]:
def _facet_capabilities(caps: Dict[str, Any]) -> Dict[str, List[str]]:
"""
Leitet Facettenfelder aus der capabilities-Map ab:
- capability_keys: alle Namen
- capability_geN: Namen mit Level >= N (1..5)
- capability_eqN: Namen mit Level == N (1..5)
"""
caps = caps or {}
def names_where(pred) -> List[str]:
@ -194,6 +262,7 @@ def _facet_capabilities(caps: Dict[str, Any]) -> Dict[str, List[str]]:
def _response_strip_extras(payload: Dict[str, Any]) -> Dict[str, Any]:
# Nur definierte Exercise-Felder zurückgeben (saubere API)
allowed = set(Exercise.model_fields.keys())
return {k: v for k, v in payload.items() if k in allowed}
@ -209,8 +278,7 @@ def _build_filter(req: ExerciseSearchRequest) -> Filter:
if req.age_group:
must.append(FieldCondition(key="age_group", match=MatchValue(value=req.age_group)))
if req.max_duration is not None:
# Range ohne Import zusätzlicher Modelle: Qdrant akzeptiert auch {'range': {'lte': n}} per JSON;
# über Client-Modell tun wir es hier nicht, da wir Filter primär für Keyword-Felder nutzen.
# Range in Qdrant: über rohen JSON-Range-Ausdruck (Client-Modell hat keinen Komfort-Wrapper)
must.append({"key": "duration_minutes", "range": {"lte": int(req.max_duration)}})
# equipment
@ -218,7 +286,6 @@ def _build_filter(req: ExerciseSearchRequest) -> Filter:
for it in req.equipment_all:
must.append(FieldCondition(key="equipment", match=MatchValue(value=it)))
if req.equipment_any:
# OR: über 'should' Liste
for it in req.equipment_any:
should.append(FieldCondition(key="equipment", match=MatchValue(value=it)))
@ -248,22 +315,55 @@ def _build_filter(req: ExerciseSearchRequest) -> Filter:
flt = Filter(must=must)
if should:
# qdrant: 'should' mit implizitem minimum_should_match=1
# Qdrant: 'should' entspricht OR mit minimum_should_match=1
flt.should = should
return flt
# =========================
# Endpoints
# =========================
@router.get("/exercise/by-external-id")
def get_exercise_by_external_id(external_id: str = Query(..., min_length=3)):
@router.get(
"/exercise/by-external-id",
summary="Übung per external_id abrufen",
description=(
"Liefert die Übung mit der gegebenen `external_id` (z. B. `mw:{pageid}`). "
"Verwendet einen Qdrant-Filter auf dem Payload-Feld `external_id`."
),
response_description="Vollständiger Exercise-Payload oder 404 bei Nichtfund.",
openapi_extra={
"x-codeSamples": [{
"lang": "bash",
"label": "curl",
"source": "curl -s 'http://localhost:8000/exercise/by-external-id?external_id=mw:218' | jq ."
}]
}
)
def get_exercise_by_external_id(external_id: str = Query(..., min_length=3, description="Upsert-Schlüssel, z. B. 'mw:218'")):
found = _lookup_by_external_id(external_id)
if not found:
raise HTTPException(status_code=404, detail="not found")
return found
@router.post("/exercise", response_model=Exercise)
@router.post(
"/exercise",
response_model=Exercise,
summary="Create/Update (idempotent per external_id)",
description=(
"Legt eine Übung an oder aktualisiert sie. Wenn `external_id` vorhanden und bereits in der Collection existiert, "
"wird **Update** auf dem bestehenden Punkt ausgeführt (Upsert). `keywords`/`equipment` werden normalisiert, "
"Capability-Facetten (`capability_ge1..5`, `capability_eq1..5`, `capability_keys`) automatisch abgeleitet. "
"Der Vektor wird aus Kernfeldern (title/summary/short_description/purpose/execution/notes) berechnet."
),
response_description="Gespeicherter Exercise-Datensatz (Payload-View).",
openapi_extra={
"x-codeSamples": [{
"lang": "bash",
"label": "curl",
"source": "curl -s -X POST http://localhost:8000/exercise -H 'Content-Type: application/json' -d @exercise.json | jq ."
}]
}
)
def create_or_update_exercise(ex: Exercise):
_ensure_collection()
@ -290,7 +390,20 @@ def create_or_update_exercise(ex: Exercise):
return Exercise(**_response_strip_extras(payload))
@router.get("/exercise/{exercise_id}", response_model=Exercise)
@router.get(
"/exercise/{exercise_id}",
response_model=Exercise,
summary="Übung per interner ID (Qdrant-Punkt-ID) lesen",
description="Scrollt nach `id` und gibt den Payload als Exercise zurück.",
response_description="Exercise-Payload oder 404 bei Nichtfund.",
openapi_extra={
"x-codeSamples": [{
"lang": "bash",
"label": "curl",
"source": "curl -s 'http://localhost:8000/exercise/1234-uuid' | jq ."
}]
}
)
def get_exercise(exercise_id: str):
_ensure_collection()
pts, _ = qdrant.scroll(
@ -306,7 +419,32 @@ def get_exercise(exercise_id: str):
return Exercise(**_response_strip_extras(payload))
@router.post("/exercise/search", response_model=ExerciseSearchResponse)
@router.post(
"/exercise/search",
response_model=ExerciseSearchResponse,
summary="Suche Übungen (Filter + optional Vektor)",
description=(
"Kombinierbare Filter auf Payload-Feldern (`discipline`, `age_group`, `target_group`, `equipment`, `keywords`, "
"`capability_geN/eqN`) und **optional** Vektor-Suche via `query`. "
"`should`-Filter (equipment_any/keywords_any) wirken als OR (minimum_should_match=1). "
"`max_duration` wird als Range (lte) angewandt. Ergebnis enthält bei Vektor-Suche `score`, sonst `null`."
),
response_description="Trefferliste (payload + Score bei Vektor-Suche).",
openapi_extra={
"x-codeSamples": [
{
"lang": "bash",
"label": "Filter",
"source": "curl -s -X POST http://localhost:8000/exercise/search -H 'Content-Type: application/json' -d '{\"discipline\":\"Karate\",\"max_duration\":12,\"equipment_any\":[\"Bälle\"],\"capability_names\":[\"Reaktionsfähigkeit\"],\"capability_ge_level\":2,\"limit\":5}' | jq ."
},
{
"lang": "bash",
"label": "Vektor + Filter",
"source": "curl -s -X POST http://localhost:8000/exercise/search -H 'Content-Type: application/json' -d '{\"query\":\"Aufwärmen 10min Teenager Bälle\",\"discipline\":\"Karate\",\"limit\":3}' | jq ."
}
]
}
)
def search_exercises(req: ExerciseSearchRequest) -> ExerciseSearchResponse:
_ensure_collection()
flt = _build_filter(req)
@ -314,7 +452,6 @@ def search_exercises(req: ExerciseSearchRequest) -> ExerciseSearchResponse:
hits: List[ExerciseSearchHit] = []
if req.query:
vec = _make_vector_from_query(req.query)
# qdrant_client.search unterstützt offset/limit
res = qdrant.search(
collection_name=COLLECTION,
query_vector=vec,
@ -327,8 +464,7 @@ def search_exercises(req: ExerciseSearchRequest) -> ExerciseSearchResponse:
payload.setdefault("id", str(h.id))
hits.append(ExerciseSearchHit(id=str(h.id), score=float(h.score or 0.0), payload=Exercise(**_response_strip_extras(payload))))
else:
# Filter-only: per Scroll (ohne Score); einfache Paginierung via offset/limit
# Hole offset+limit Punkte und simuliere Score=None
# Filter-only: Scroll-Paginierung, Score=None
collected = 0
skipped = 0
next_offset = None
@ -357,8 +493,24 @@ def search_exercises(req: ExerciseSearchRequest) -> ExerciseSearchResponse:
return ExerciseSearchResponse(hits=hits)
@router.delete("/exercise/delete-by-external-id", response_model=DeleteResponse)
def delete_by_external_id(external_id: str = Query(...)):
@router.delete(
"/exercise/delete-by-external-id",
response_model=DeleteResponse,
summary="Löscht Punkte mit gegebener external_id",
description=(
"Scrollt nach `external_id` und löscht alle passenden Punkte. "
"Idempotent: wenn nichts gefunden → count=0. Vorsicht: **löscht dauerhaft**."
),
response_description="Status + Anzahl gelöschter Punkte.",
openapi_extra={
"x-codeSamples": [{
"lang": "bash",
"label": "curl",
"source": "curl -s 'http://localhost:8000/exercise/delete-by-external-id?external_id=mw:9999' | jq ."
}]
}
)
def delete_by_external_id(external_id: str = Query(..., description="Upsert-Schlüssel, z. B. 'mw:218'")):
_ensure_collection()
flt = Filter(must=[FieldCondition(key="external_id", match=MatchValue(value=external_id))])
pts, _ = qdrant.scroll(collection_name=COLLECTION, scroll_filter=flt, limit=10000, with_payload=False)
@ -369,8 +521,24 @@ def delete_by_external_id(external_id: str = Query(...)):
return DeleteResponse(status="🗑️ gelöscht", count=len(ids), collection=COLLECTION)
@router.delete("/exercise/delete-collection", response_model=DeleteResponse)
def delete_collection(collection: str = Query(default=COLLECTION)):
@router.delete(
"/exercise/delete-collection",
response_model=DeleteResponse,
summary="Collection komplett löschen",
description=(
"Entfernt die gesamte Collection aus Qdrant. **Gefährlich** alle Übungen sind danach weg. "
"Nutze nur in Testumgebungen oder für einen kompletten Neuaufbau."
),
response_description="Status. count=0 (nicht relevant beim Drop).",
openapi_extra={
"x-codeSamples": [{
"lang": "bash",
"label": "curl",
"source": "curl -s 'http://localhost:8000/exercise/delete-collection?collection=exercises' | jq ."
}]
}
)
def delete_collection(collection: str = Query(default=COLLECTION, description="Collection-Name (Default: 'exercises')")):
if not qdrant.collection_exists(collection):
raise HTTPException(status_code=404, detail=f"Collection '{collection}' nicht gefunden.")
qdrant.delete_collection(collection_name=collection)
@ -384,7 +552,6 @@ TEST_DOC = """
Speicher als tests/test_exercise_search.py und mit pytest laufen lassen.
import os, requests
BASE = os.getenv("API_BASE", "http://localhost:8000")
# 1) Filter-only

View File

@ -1,33 +1,161 @@
from dotenv import load_dotenv
load_dotenv() # Lädt Variablen aus .env in os.environ
# -*- coding: utf-8 -*-
"""
llm_api.py v1.2.0 (zentraler .env-Bootstrap, saubere Router-Einbindung, Swagger-Doku)
Änderungen ggü. v1.1.6:
- Zentrales .env-Bootstrapping VOR allen Router-Imports (findet Datei robust; setzt LLMAPI_ENV_FILE/LLMAPI_ENV_BOOTSTRAPPED)
- Konsistente Swagger-Beschreibung + Tags-Metadaten
- Router ohne doppelte Prefixe einbinden (die Prefixe werden in den Routern definiert)
- Root-/health und /version Endpoints
- Defensive Includes (Router-Importfehler verhindern Server-Absturz; Logging statt Crash)
- Beibehaltener globaler Fehlerhandler (generische 500)
Hinweis:
- wiki_router im Canvas (v1.4.2) nutzt bereits robustes .env-Loading, respektiert aber die zentral gesetzten ENV-Variablen.
- Wenn du ENV-Datei an anderem Ort hast, setze in der Systemd-Unit `Environment=LLMAPI_ENV_FILE=/pfad/.env`.
"""
from __future__ import annotations
import os
from pathlib import Path
from textwrap import dedent
from typing import Optional
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from clients import model, qdrant
from wiki_router import router as wiki_router
from embed_router import router as embed_router
from exercise_router import router as exercise_router
from fastapi.middleware.cors import CORSMiddleware
# Version
__version__ = "1.1.6"
# ----------------------
# Zentraler .env-Bootstrap (VOR Router-Imports ausführen!)
# ----------------------
def _bootstrap_env() -> Optional[str]:
try:
from dotenv import load_dotenv, find_dotenv
except Exception:
print("[env] python-dotenv nicht installiert überspringe .env-Loading", flush=True)
return None
candidates: list[str] = []
if os.getenv("LLMAPI_ENV_FILE"):
candidates.append(os.getenv("LLMAPI_ENV_FILE") or "")
fd = find_dotenv(".env", usecwd=True)
if fd:
candidates.append(fd)
candidates += [
str(Path.cwd() / ".env"),
str(Path(__file__).parent / ".env"),
str(Path.home() / ".env"),
str(Path.home() / ".llm-api.env"),
"/etc/llm-api.env",
]
for p in candidates:
try:
if p and Path(p).exists():
if load_dotenv(p, override=False):
os.environ["LLMAPI_ENV_FILE"] = p
os.environ["LLMAPI_ENV_BOOTSTRAPPED"] = "1"
print(f"[env] loaded: {p}", flush=True)
return p
except Exception as e:
print(f"[env] load failed for {p}: {e}", flush=True)
print("[env] no .env found; using process env", flush=True)
return None
_ENV_SRC = _bootstrap_env()
# ----------------------
# App + OpenAPI-Metadaten
# ----------------------
__version__ = "1.2.0"
print(f"[DEBUG] llm_api.py version {__version__} loaded from {__file__}", flush=True)
TAGS = [
{
"name": "wiki",
"description": dedent(
"""
MediaWiki-Proxy (Health, Login, Page-Info/Parse, SMW-Ask).
**ENV**: `WIKI_API_URL`, `WIKI_TIMEOUT`, `WIKI_RETRIES`, `WIKI_SLEEP_MS`, `WIKI_BATCH`.
"""
),
},
{
"name": "exercise",
"description": dedent(
"""
Übungen (Upsert, Suche, Delete). Upsert-Schlüssel: `external_id` (z. B. `mw:{pageid}`).
**ENV**: `EXERCISE_COLLECTION`, `QDRANT_HOST`, `QDRANT_PORT`.
"""
),
},
{
"name": "plans",
"description": "Trainingspläne (Templates/Generate/Export).",
},
]
# FastAPI-Instanz
app = FastAPI(
title="KI Trainerassistent API",
description="Modulare API für Trainingsplanung und MediaWiki-Import",
description=dedent(
f"""
Modulare API für Trainingsplanung und MediaWiki-Import.
**Version:** {__version__}
## Quickstart (CLI)
```bash
python3 wiki_importer.py --all
python3 wiki_importer.py --all --category "Übungen" --dry-run
```
"""
),
version=__version__,
openapi_tags=TAGS,
swagger_ui_parameters={"docExpansion": "list", "defaultModelsExpandDepth": 0},
)
# Globaler Fehlerhandler
# Optional: CORS für lokale UIs/Tools
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ----------------------
# Globaler Fehlerhandler (generisch)
# ----------------------
@app.exception_handler(Exception)
async def unicorn_exception_handler(request, exc):
return JSONResponse(status_code=500, content={"detail": "Interner Serverfehler."})
# Router einbinden
app.include_router(wiki_router)
app.include_router(embed_router)
app.include_router(exercise_router)
# ----------------------
# Router einbinden (WICHTIG: keine zusätzlichen Prefixe hier setzen)
# ----------------------
def _include_router_safely(name: str, import_path: str):
try:
module = __import__(import_path, fromlist=["router"]) # lazy import nach ENV-Bootstrap
app.include_router(module.router)
print(f"[router] {name} included", flush=True)
except Exception as e:
print(f"[router] {name} NOT included: {e}", flush=True)
_include_router_safely("wiki_router", "wiki_router") # prefix in Datei: /import/wiki
_include_router_safely("embed_router", "embed_router")
_include_router_safely("exercise_router", "exercise_router")
_include_router_safely("plan_router", "plan_router")
_include_router_safely("plan_session_router", "plan_session_router")
# ----------------------
# Basis-Endpunkte
# ----------------------
@app.get("/health", tags=["wiki"], summary="API-Health (lokal)")
def api_health():
return {"status": "ok"}
@app.get("/version", tags=["wiki"], summary="API-Version & ENV-Quelle")
def api_version():
return {"version": __version__, "env_file": _ENV_SRC}

521
llm-api/plan_router.py Normal file
View File

@ -0,0 +1,521 @@
# -*- coding: utf-8 -*-
"""
plan_router.py v0.13.4 (WP-15)
Änderungen ggü. v0.13.3
- Idempotenter POST /plan: Wenn ein Plan mit gleichem Fingerprint existiert und die neue
Anfrage ein späteres `created_at` trägt, wird der gespeicherte Plan mit dem neueren
`created_at` und `created_at_ts` aktualisiert (kein Duplikat, aber zeitlich frisch).
- /plans: Mehrseitiges Scrollen bleibt aktiv; Zeitfenster-Filter robust (serverseitig + Fallback).
"""
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from uuid import uuid4
from datetime import datetime, timezone
import hashlib
import json
import os
from clients import model, qdrant
from qdrant_client.models import (
PointStruct, Filter, FieldCondition, MatchValue,
VectorParams, Distance, Range
)
router = APIRouter(tags=["plans"])
# -----------------
# Konfiguration
# -----------------
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
# -----------------
class TemplateSection(BaseModel):
name: str
target_minutes: int
must_keywords: List[str] = []
ideal_keywords: List[str] = []
supplement_keywords: List[str] = []
forbid_keywords: List[str] = []
capability_targets: Dict[str, int] = {}
class PlanTemplate(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
name: str
discipline: str
age_group: str
target_group: str
total_minutes: int
sections: List[TemplateSection] = []
goals: List[str] = []
equipment_allowed: List[str] = []
created_by: str
version: str
class PlanItem(BaseModel):
exercise_external_id: str
duration: int
why: str
class PlanSection(BaseModel):
name: str
items: List[PlanItem] = []
minutes: int
class Plan(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
template_id: Optional[str] = None
title: str
discipline: str
age_group: str
target_group: str
total_minutes: int
sections: List[PlanSection] = []
goals: List[str] = []
capability_summary: Dict[str, int] = {}
novelty_against_last_n: Optional[float] = None
fingerprint: Optional[str] = None
created_by: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
source: str = "API"
class PlanTemplateList(BaseModel):
items: List[PlanTemplate]
limit: int
offset: int
count: int
class PlanList(BaseModel):
items: List[Plan]
limit: int
offset: int
count: int
# -----------------
# Helpers
# -----------------
def _ensure_collection(name: str):
if not qdrant.collection_exists(name):
qdrant.recreate_collection(
collection_name=name,
vectors_config=VectorParams(size=model.get_sentence_embedding_dimension(), distance=Distance.COSINE),
)
def _norm_list(xs: List[str]) -> List[str]:
seen, out = set(), []
for x in xs or []:
s = str(x).strip()
k = s.casefold()
if s and k not in seen:
seen.add(k)
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 = {
"title": p.title,
"total_minutes": int(p.total_minutes),
"items": [
{"exercise_external_id": it.exercise_external_id, "duration": int(it.duration)}
for sec in p.sections
for it in (sec.items or [])
],
}
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):
flt = Filter(must=[FieldCondition(key=key, match=MatchValue(value=value))])
pts, _ = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=1, with_payload=True)
if not pts:
return None
point = pts[0]
payload = dict(point.payload or {})
payload.setdefault("id", str(point.id))
return {"id": point.id, "payload": payload}
def _as_model(model_cls, payload: Dict[str, Any]):
fields = getattr(model_cls, "model_fields", None) or getattr(model_cls, "__fields__", {})
allowed = set(fields.keys())
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)
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):
out = []
offset = None
page = max(1, min(page, 1024))
while len(out) < need:
pts, offset = qdrant.scroll(collection_name=collection, scroll_filter=flt, limit=min(page, need - len(out)), with_payload=True, offset=offset)
if not pts:
break
out.extend(pts)
if offset is None:
break
return out
# -----------------
# Endpoints: Templates
# -----------------
@router.post(
"/plan_templates",
response_model=PlanTemplate,
summary="Create a plan template",
description=(
"Erstellt ein Plan-Template (Strukturplanung).\n\n"
"• Mehrere Sections erlaubt.\n"
"• Section-Felder: must/ideal/supplement/forbid keywords + capability_targets.\n"
"• Materialisierte Facettenfelder (section_*) werden intern geschrieben, um Qdrant-Filter zu beschleunigen."
),
)
def create_plan_template(t: PlanTemplate):
_ensure_collection(PLAN_TEMPLATE_COLLECTION)
payload = t.model_dump()
payload["goals"] = _norm_list(payload.get("goals"))
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 [])
s["supplement_keywords"] = _norm_list(s.get("supplement_keywords") or [])
s["forbid_keywords"] = _norm_list(s.get("forbid_keywords") or [])
# Materialisierte Facetten (KEYWORD-Indizes)
payload["section_names"] = _norm_list([s.get("name", "") for s in sections])
payload["section_must_keywords"] = _norm_list([kw for s in sections for kw in (s.get("must_keywords") or [])])
payload["section_ideal_keywords"] = _norm_list([kw for s in sections for kw in (s.get("ideal_keywords") or [])])
payload["section_supplement_keywords"] = _norm_list([kw for s in sections for kw in (s.get("supplement_keywords") or [])])
payload["section_forbid_keywords"] = _norm_list([kw for s in sections for kw in (s.get("forbid_keywords") or [])])
vec = _embed(_template_embed_text(t))
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,
summary="Read a plan template by id",
description="Liest ein Template anhand seiner ID und gibt nur die Schemafelder zurück (zusätzliche Payload wird herausgefiltert).",
)
def get_plan_template(tpl_id: str):
_ensure_collection(PLAN_TEMPLATE_COLLECTION)
found = _get_by_field(PLAN_TEMPLATE_COLLECTION, "id", tpl_id)
if not found:
raise HTTPException(status_code=404, detail="not found")
return _as_model(PlanTemplate, found["payload"])
@router.get(
"/plan_templates",
response_model=PlanTemplateList,
summary="List plan templates (filterable)",
description=(
"Listet Plan-Templates mit Filtern.\n\n"
"**Filter** (exakte Matches, KEYWORD-Felder):\n"
"- discipline, age_group, target_group\n"
"- section: Section-Name (nutzt materialisierte `section_names`)\n"
"- goal: Ziel (nutzt `goals`)\n"
"- keyword: trifft auf beliebige Section-Keyword-Felder (must/ideal/supplement/forbid).\n\n"
"**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)."
),
)
def list_plan_templates(
discipline: Optional[str] = Query(None, description="Filter: Disziplin (exaktes KEYWORD-Match)", example="Karate"),
age_group: Optional[str] = Query(None, description="Filter: Altersgruppe", example="Teenager"),
target_group: Optional[str] = Query(None, description="Filter: Zielgruppe", example="Breitensport"),
section: Optional[str] = Query(None, description="Filter: Section-Name (materialisiert)", example="Warmup"),
goal: Optional[str] = Query(None, description="Filter: Trainingsziel", example="Technik"),
keyword: Optional[str] = Query(None, description="Filter: Keyword in must/ideal/supplement/forbid", example="Koordination"),
limit: int = Query(20, ge=1, le=200, description="Max. Anzahl Items"),
offset: int = Query(0, ge=0, description="Start-Offset für Paging"),
):
_ensure_collection(PLAN_TEMPLATE_COLLECTION)
must: List[Any] = []
should: List[Any] = []
if discipline:
must.append(FieldCondition(key="discipline", match=MatchValue(value=discipline)))
if age_group:
must.append(FieldCondition(key="age_group", match=MatchValue(value=age_group)))
if target_group:
must.append(FieldCondition(key="target_group", match=MatchValue(value=target_group)))
if section:
must.append(FieldCondition(key="section_names", match=MatchValue(value=section)))
if goal:
must.append(FieldCondition(key="goals", match=MatchValue(value=goal)))
if keyword:
for k in ("section_must_keywords","section_ideal_keywords","section_supplement_keywords","section_forbid_keywords"):
should.append(FieldCondition(key=k, match=MatchValue(value=keyword)))
flt = Filter(must=must or None, should=should or None) if (must or should) else None
need = max(offset + limit, 1)
pts = _scroll_collect(PLAN_TEMPLATE_COLLECTION, flt, need)
items: List[PlanTemplate] = []
for p in pts[offset:offset+limit]:
payload = dict(p.payload or {})
payload.setdefault("id", str(p.id))
items.append(_as_model(PlanTemplate, payload))
return PlanTemplateList(items=items, limit=limit, offset=offset, count=len(items))
# -----------------
# Endpoints: Pläne
# -----------------
@router.post(
"/plan",
response_model=Plan,
summary="Create a concrete training plan",
description=(
"Erstellt einen konkreten Trainingsplan.\n\n"
"Idempotenz: gleicher Fingerprint (title + items) → gleicher Plan (kein Duplikat).\n"
"Bei erneutem POST mit späterem `created_at` wird `created_at`/`created_at_ts` des bestehenden Plans aktualisiert."
),
)
def create_plan(p: Plan):
_ensure_collection(PLAN_COLLECTION)
# 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}")
# Optional: Strict-Mode Exercises gegen EXERCISE_COLLECTION 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))})
# Fingerprint
fp = _fingerprint_for_plan(p)
p.fingerprint = p.fingerprint or fp
# Ziel-ISO + TS aus Request berechnen (auch wenn Duplikat)
req_payload = p.model_dump()
dt = req_payload.get("created_at")
if isinstance(dt, datetime):
dt = dt.astimezone(timezone.utc).isoformat()
elif isinstance(dt, str):
try:
_ = datetime.fromisoformat(dt.replace("Z", "+00:00"))
except Exception:
dt = datetime.now(timezone.utc).isoformat()
else:
dt = datetime.now(timezone.utc).isoformat()
req_payload["created_at"] = dt
req_ts = _parse_iso_to_ts(dt)
req_payload["created_at_ts"] = float(req_ts)
# Dup-Check
existing = _get_by_field(PLAN_COLLECTION, "fingerprint", p.fingerprint)
if existing:
# Falls neues created_at später ist → gespeicherten Plan aktualisieren
cur = existing["payload"]
cur_ts = cur.get("created_at_ts")
if cur_ts is None:
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))
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,
summary="Read a training plan by id",
description="Liest einen Plan anhand seiner ID. `created_at` wird (falls ISO-String) zu `datetime` geparst.",
)
def get_plan(plan_id: str):
_ensure_collection(PLAN_COLLECTION)
found = _get_by_field(PLAN_COLLECTION, "id", plan_id)
if not found:
raise HTTPException(status_code=404, detail="not found")
payload = found["payload"]
if isinstance(payload.get("created_at"), str):
try:
payload["created_at"] = datetime.fromisoformat(payload["created_at"])
except Exception:
pass
return _as_model(Plan, payload)
@router.get(
"/plans",
response_model=PlanList,
summary="List training plans (filterable)",
description=(
"Listet Trainingspläne mit Filtern.\n\n"
"**Filter** (exakte Matches, KEYWORD-Felder):\n"
"- created_by, discipline, age_group, target_group, goal\n"
"- section: Section-Name (nutzt materialisiertes `plan_section_names`)\n"
"- created_from / created_to: ISO-8601 Zeitfenster → serverseitiger Range-Filter über `created_at_ts` (FLOAT). "
"Falls 0 Treffer: zweiter Durchlauf ohne Zeit-Range + lokale Zeitprüfung.\n\n"
"**Pagination:** limit/offset. Feld `count` entspricht der Anzahl zurückgegebener Items (keine Gesamtsumme)."
),
)
def list_plans(
created_by: Optional[str] = Query(None, description="Filter: Ersteller", example="tester"),
discipline: Optional[str] = Query(None, description="Filter: Disziplin", example="Karate"),
age_group: Optional[str] = Query(None, description="Filter: Altersgruppe", example="Teenager"),
target_group: Optional[str] = Query(None, description="Filter: Zielgruppe", example="Breitensport"),
goal: Optional[str] = Query(None, description="Filter: Trainingsziel", example="Technik"),
section: Optional[str] = Query(None, description="Filter: Section-Name", example="Warmup"),
created_from: Optional[str] = Query(None, description="Ab-Zeitpunkt (ISO 8601, z. B. 2025-08-12T00:00:00Z)", example="2025-08-12T00:00:00Z"),
created_to: Optional[str] = Query(None, description="Bis-Zeitpunkt (ISO 8601)", example="2025-08-13T00:00:00Z"),
limit: int = Query(20, ge=1, le=200, description="Max. Anzahl Items"),
offset: int = Query(0, ge=0, description="Start-Offset für Paging"),
):
_ensure_collection(PLAN_COLLECTION)
# Grundfilter (ohne Zeit)
base_must: List[Any] = []
if created_by:
base_must.append(FieldCondition(key="created_by", match=MatchValue(value=created_by)))
if discipline:
base_must.append(FieldCondition(key="discipline", match=MatchValue(value=discipline)))
if age_group:
base_must.append(FieldCondition(key="age_group", match=MatchValue(value=age_group)))
if target_group:
base_must.append(FieldCondition(key="target_group", match=MatchValue(value=target_group)))
if goal:
base_must.append(FieldCondition(key="goals", match=MatchValue(value=goal)))
if section:
base_must.append(FieldCondition(key="plan_section_names", match=MatchValue(value=section)))
# Serverseitiger Zeitbereich
range_args: Dict[str, float] = {}
try:
if created_from:
range_args["gte"] = float(datetime.fromisoformat(created_from.replace("Z", "+00:00")).timestamp())
if created_to:
range_args["lte"] = float(datetime.fromisoformat(created_to.replace("Z", "+00:00")).timestamp())
except Exception:
range_args = {}
applied_server_range = bool(range_args)
must_with_time = list(base_must)
if applied_server_range:
must_with_time.append(FieldCondition(key="created_at_ts", range=Range(**range_args)))
need = max(offset + limit, 1)
# 1) Scroll mit Zeit-Range (falls vorhanden)
pts = _scroll_collect(PLAN_COLLECTION, Filter(must=must_with_time or None) if must_with_time else None, need)
# 2) Fallback: 0 Treffer → ohne Zeit-Range scrollen und lokal filtern
fallback_local_time_check = False
if applied_server_range and not pts:
pts = _scroll_collect(PLAN_COLLECTION, Filter(must=base_must or None) if base_must else None, need)
fallback_local_time_check = True
def _in_window(py: Dict[str, Any]) -> bool:
if not (created_from or created_to):
return True
if applied_server_range and not fallback_local_time_check:
return True # serverseitig bereits gefiltert
ts = py.get("created_at")
if isinstance(ts, dict) and ts.get("$date"):
ts = ts["$date"]
if isinstance(py.get("created_at_ts"), (int, float)):
dt = datetime.fromtimestamp(float(py["created_at_ts"]), tz=timezone.utc)
elif isinstance(ts, str):
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
except Exception:
return False
elif isinstance(ts, datetime):
dt = ts
else:
return False
ok = True
if created_from:
try:
ok = ok and dt >= datetime.fromisoformat(created_from.replace("Z", "+00:00"))
except Exception:
pass
if created_to:
try:
ok = ok and dt <= datetime.fromisoformat(created_to.replace("Z", "+00:00"))
except Exception:
pass
return ok
payloads: List[Dict[str, Any]] = []
for p in pts:
py = dict(p.payload or {})
py.setdefault("id", str(p.id))
if _in_window(py):
payloads.append(py)
sliced = payloads[offset:offset+limit]
items = [_as_model(Plan, x) for x in sliced]
return PlanList(items=items, limit=limit, offset=offset, count=len(items))

View File

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""
plan_session_router.py v0.2.0 (WP-15)
CRUD-Minimum für Plan-Sessions (POST/GET) mit Referenz-Validierung.
Kompatibel zum Qdrant-Client-Stil der bestehenden Router.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, conint
from typing import List, Optional, Dict, Any
from uuid import uuid4
from datetime import datetime, timezone
import os
from clients import model, qdrant
from qdrant_client.models import PointStruct, Filter, FieldCondition, MatchValue, VectorParams, Distance
router = APIRouter(tags=["plan_sessions"])
# -----------------
# Konfiguration
# -----------------
PLAN_SESSION_COLLECTION = os.getenv("PLAN_SESSION_COLLECTION", "plan_sessions")
PLAN_COLLECTION = os.getenv("PLAN_COLLECTION", "plans")
# -----------------
# Modelle
# -----------------
class Feedback(BaseModel):
rating: conint(ge=1, le=5)
notes: str
class PlanSession(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
plan_id: str
executed_at: datetime
location: str
coach: str
group_label: str
feedback: Feedback
used_equipment: List[str] = []
# -----------------
# Helpers
# -----------------
def _ensure_collection(name: str):
if not qdrant.collection_exists(name):
qdrant.recreate_collection(
collection_name=name,
vectors_config=VectorParams(size=model.get_sentence_embedding_dimension(), distance=Distance.COSINE),
)
def _norm_list(xs: List[str]) -> List[str]:
seen, out = set(), []
for x in xs or []:
s = str(x).strip()
k = s.casefold()
if s and k not in seen:
seen.add(k)
out.append(s)
return sorted(out, key=str.casefold)
def _session_embed_text(s: PlanSession) -> str:
parts = [s.plan_id, s.location, s.coach, s.group_label, s.feedback.notes]
return ". ".join([p for p in parts if p])
def _embed(text: str):
return model.encode(text or "").tolist()
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)
if not pts:
return None
payload = dict(pts[0].payload or {})
payload.setdefault("id", str(pts[0].id))
return payload
# -----------------
# Endpoints
# -----------------
@router.post("/plan_sessions", response_model=PlanSession)
def create_plan_session(s: PlanSession):
_ensure_collection(PLAN_SESSION_COLLECTION)
# Referenz auf Plan prüfen
if not _get_by_field(PLAN_COLLECTION, "id", s.plan_id):
raise HTTPException(status_code=422, detail=f"Unknown plan_id: {s.plan_id}")
# Normalisieren
s.used_equipment = _norm_list(s.used_equipment)
payload = s.model_dump()
# ISO8601 für executed_at sicherstellen
if isinstance(payload.get("executed_at"), datetime):
payload["executed_at"] = payload["executed_at"].astimezone(timezone.utc).isoformat()
vec = _embed(_session_embed_text(s))
qdrant.upsert(collection_name=PLAN_SESSION_COLLECTION, points=[PointStruct(id=str(s.id), vector=vec, payload=payload)])
return s
@router.get("/plan_sessions/{session_id}", response_model=PlanSession)
def get_plan_session(session_id: str):
_ensure_collection(PLAN_SESSION_COLLECTION)
found = _get_by_field(PLAN_SESSION_COLLECTION, "id", session_id)
if not found:
raise HTTPException(status_code=404, detail="not found")
if isinstance(found.get("executed_at"), str):
try:
found["executed_at"] = datetime.fromisoformat(found["executed_at"])
except Exception:
pass
return PlanSession(**found)

View File

@ -1,66 +1,119 @@
# -*- coding: utf-8 -*-
"""
wiki_router.py v1.4.1 (stabil & nachvollziehbar)
wiki_router.py v1.4.3 (Swagger + robustes .env + optionaler ENV-Login)
Änderungen ggü. v1.4.0:
- /info: Optionalen Request-Parameter entfernt (FastAPI/Pydantic Typfehler behoben)
- Keine API-Signaturänderungen der Routen
Änderungen ggü. v1.4.2:
- **/login/env** hinzugefügt: Login mit WIKI_BOT_USER/WIKI_BOT_PASSWORD aus ENV (Secrets werden nie ausgegeben)
- .env-Bootstrap robuster und **vor** dem ersten Aufruf geloggt
- /.meta/env/runtime um Credentials-Flags ergänzt (ohne Klartext)
- response_description-Strings mit JSON-Beispielen sauber gequotet
- Keine Breaking-Changes (Signaturen & Pfade unverändert)
Ziele:
- /semantic/pages reichert pageid/fullurl für ALLE Titel batchweise an (redirects=1, converttitles=1)
- /info robust: 404 statt 500, mit Titel-Varianten
- Wiederholungen & Throttling gegen MediaWiki
- Optionale Diagnose-Ausgaben und Coverage-Kennzahlen
Wenn ihr stattdessen den Prefix im Router setzen wollt, einfach in der APIRouter-Zeile unten
prefix="/import/wiki" ergänzen und in main.py OHNE prefix einbinden.
Prefix-Hinweis:
- Der Router setzt `prefix="/import/wiki"`. In `llm_api.py` **ohne** weiteren Prefix einbinden.
"""
from typing import Dict, Any, Optional, List
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from textwrap import dedent
import os, time, logging
import requests
from dotenv import load_dotenv
load_dotenv()
from dotenv import load_dotenv, find_dotenv
from starlette.responses import PlainTextResponse
# -------------------------------------------------
# Logging **vor** .env-Bootstrap initialisieren
# -------------------------------------------------
logger = logging.getLogger("wiki_router")
logger.setLevel(logging.INFO)
router = APIRouter(prefix="/import/wiki", tags=["wiki"])
# -------------------------------------------------
# Robustes .env-Loading (findet Datei auch außerhalb des CWD)
# -------------------------------------------------
# -------- Konfiguration --------
def _bootstrap_env() -> Optional[str]:
"""Versucht mehrere typische Pfade für .env zu laden und loggt die Fundstelle.
Reihenfolge:
1) env `LLMAPI_ENV_FILE`
2) find_dotenv() relativ zum CWD
3) CWD/.env
4) Verzeichnis dieser Datei /.env
5) $HOME/.env
6) $HOME/.llm-api.env
7) /etc/llm-api.env
"""
candidates: List[str] = []
if os.getenv("LLMAPI_ENV_FILE"):
candidates.append(os.getenv("LLMAPI_ENV_FILE") or "")
fd = find_dotenv(".env", usecwd=True)
if fd:
candidates.append(fd)
candidates += [
os.path.join(os.getcwd(), ".env"),
os.path.join(os.path.dirname(__file__), ".env"),
os.path.expanduser("~/.env"),
os.path.expanduser("~/.llm-api.env"),
"/etc/llm-api.env",
]
for path in candidates:
try:
if path and os.path.exists(path):
loaded = load_dotenv(path, override=False)
if loaded:
logger.info("wiki_router: .env geladen aus %s", path)
return path
except Exception as e:
logger.warning("wiki_router: .env laden fehlgeschlagen (%s): %s", path, e)
logger.info("wiki_router: keine .env gefunden verwende Prozess-Umgebung")
return None
_BOOTSTRAP_ENV = _bootstrap_env()
# -------------------------------------------------
# Router & Konfiguration
# -------------------------------------------------
router = APIRouter(prefix="/import/wiki", tags=["wiki"])
# Hinweis: Werte werden NACH dem .env-Bootstrap aus os.environ gelesen.
# Änderungen an .env erfordern i. d. R. einen Neustart des Dienstes.
WIKI_API_URL = os.getenv("WIKI_API_URL", "https://karatetrainer.net/api.php")
WIKI_TIMEOUT = float(os.getenv("WIKI_TIMEOUT", "15"))
WIKI_BATCH = int(os.getenv("WIKI_BATCH", "50"))
WIKI_RETRIES = int(os.getenv("WIKI_RETRIES", "1")) # zusätzliche Versuche bei Upstream-Fehlern
WIKI_SLEEPMS = int(os.getenv("WIKI_SLEEP_MS", "0")) # Throttle zwischen Requests
WIKI_SLEEPMS = int(os.getenv("WIKI_SLEEP_MS", "0")) # Throttle zwischen Requests (Millisekunden)
# Single Session (Cookies für Login)
wiki_session = requests.Session()
wiki_session.headers.update({"User-Agent": "local-llm-wiki-proxy/1.4.1"})
wiki_session.headers.update({"User-Agent": "local-llm-wiki-proxy/1.4.3"})
# -------- Schemas --------
# -------------------------------------------------
# Schemas
# -------------------------------------------------
class WikiLoginRequest(BaseModel):
username: str
password: str
username: str = Field(..., description="MediaWiki-Benutzername (kein .env-Wert)")
password: str = Field(..., description="MediaWiki-Passwort (kein .env-Wert)")
model_config = {"json_schema_extra": {"example": {"username": "Bot", "password": "••••••"}}}
class WikiLoginResponse(BaseModel):
status: str
message: Optional[str] = None
status: str = Field(..., description="'success' bei erfolgreichem Login")
message: Optional[str] = Field(None, description="Optionale Zusatzinfos")
class PageInfoResponse(BaseModel):
pageid: int
title: str
fullurl: str
pageid: int = Field(..., description="Eindeutige PageID der MediaWiki-Seite")
title: str = Field(..., description="Aufgelöster Titel (kann von Eingabe abweichen, z. B. Redirect/Normalize)")
fullurl: str = Field(..., description="Kanonsiche URL zur Seite")
model_config = {"json_schema_extra": {"example": {"pageid": 218, "title": "Affenklatschen", "fullurl": "https://…/index.php?title=Affenklatschen"}}}
class PageContentResponse(BaseModel):
pageid: int
title: str
wikitext: str
pageid: int = Field(..., description="PageID der angefragten Seite")
title: str = Field(..., description="Echo des mitgegebenen Titels (optional)")
wikitext: str = Field(..., description="Roh-Wikitext (inkl. Templates), keine Sanitization")
model_config = {"json_schema_extra": {"example": {"pageid": 218, "title": "Affenklatschen", "wikitext": "{{ÜbungInfoBox|…}}"}}}
# -------- Utils --------
# -------------------------------------------------
# Utils
# -------------------------------------------------
def _sleep():
if WIKI_SLEEPMS > 0:
@ -68,6 +121,9 @@ def _sleep():
def _request_with_retry(method: str, params: Dict[str, Any], *, data: Dict[str, Any] | None = None) -> requests.Response:
"""Wrapper um requests.* mit Retry/Throttle und konsistenten 502-Fehlern bei Upstream-Problemen.
Nutzt .env: WIKI_RETRIES, WIKI_SLEEP_MS, WIKI_TIMEOUT, WIKI_API_URL
"""
last_exc: Optional[Exception] = None
for attempt in range(WIKI_RETRIES + 1):
try:
@ -86,6 +142,7 @@ def _request_with_retry(method: str, params: Dict[str, Any], *, data: Dict[str,
def _normalize_variants(title: str) -> List[str]:
"""Erzeuge robuste Titel-Varianten: Leerzeichen/Unterstrich, Bindestrich/Dash-Varianten."""
t = (title or "").strip()
variants = {t}
if " " in t:
@ -98,6 +155,9 @@ def _normalize_variants(title: str) -> List[str]:
def _fetch_pageinfo_batch(titles: List[str]) -> Dict[str, Dict[str, Any]]:
"""Batch-Resolver für PageInfo (pageid, fullurl). Respektiert Redirects & Titel-Normalisierung.
Achtung: Große Kategorien werden in Chunks à WIKI_BATCH verarbeitet. Throttling via WIKI_SLEEP_MS.
"""
if not titles:
return {}
out: Dict[str, Dict[str, Any]] = {}
@ -136,17 +196,166 @@ def _fetch_pageinfo_batch(titles: List[str]) -> Dict[str, Dict[str, Any]]:
_sleep()
return out
# -------- Endpoints --------
@router.get("/health")
def health(verbose: Optional[int] = Query(default=0)) -> Dict[str, Any]:
# einfacher Ping
# -------------------------------------------------
# Doku-Konstanten (Markdown/.env)
# -------------------------------------------------
MANUAL_WIKI_IMPORTER = dedent("""
# wiki_importer.py Kurzanleitung
## Voraussetzungen
- API erreichbar: `GET /import/wiki/health` (Status `ok`)
- .env:
- `API_BASE_URL=http://localhost:8000`
- `WIKI_BOT_USER`, `WIKI_BOT_PASSWORD`
- optional: `EXERCISE_COLLECTION=exercises`
## Smoke-Test (3 Läufe)
```bash
python3 wiki_importer.py --title "Affenklatschen" --category "Übungen" --smoke-test
```
## Vollimport
```bash
python3 wiki_importer.py --all
# optional:
python3 wiki_importer.py --all --category "Übungen"
python3 wiki_importer.py --all --dry-run
```
## Idempotenz-Logik
- external_id = `mw:{pageid}`
- Fingerprint (sha256) über: `title, summary, execution, notes, duration_minutes, capabilities, keywords`
- Entscheid:
- not found create
- fingerprint gleich skip
- fingerprint ungleich update (+ `imported_at`)
## Mapping (Wiki → Exercise)
- Schlüsselworte `keywords` (`,`-getrennt, getrimmt, dedupliziert)
- Hilfsmittel `equipment`
- Disziplin `discipline`
- Durchführung/Notizen/Vorbereitung/Methodik `execution`, `notes`, `preparation`, `method`
- Capabilities `capabilities` (Level 1..5) + Facetten (`capability_ge1..5`, `capability_eq1..5`, `capability_keys`)
- Metadaten `external_id`, `source="mediawiki"`, `imported_at`
## Troubleshooting
- 404 bei `/import/wiki/info?...`: prüfe Prefix (kein Doppelprefix), Titelvarianten
- 401 Login: echte User-Creds verwenden
- 502 Upstream: `WIKI_API_URL`/TLS prüfen; Timeouts/Retry/Throttle (`WIKI_TIMEOUT`, `WIKI_RETRIES`, `WIKI_SLEEP_MS`)
""")
ENV_DOC = [
{"name": "WIKI_API_URL", "desc": "Basis-URL zur MediaWiki-API (z. B. http://…/w/api.php)"},
{"name": "WIKI_TIMEOUT", "desc": "Timeout in Sekunden (Default 15)"},
{"name": "WIKI_RETRIES", "desc": "Anzahl zusätzlicher Versuche (Default 1)"},
{"name": "WIKI_SLEEP_MS", "desc": "Throttle zwischen Requests in Millisekunden (Default 0)"},
{"name": "WIKI_BATCH", "desc": "Batchgröße für Titel-Enrichment (Default 50)"},
{"name": "WIKI_BOT_USER", "desc": "(optional) Benutzername für /login/env **Wert wird nie im Klartext zurückgegeben**"},
{"name": "WIKI_BOT_PASSWORD", "desc": "(optional) Passwort für /login/env **Wert wird nie im Klartext zurückgegeben**"},
]
# -------------------------------------------------
# Doku-/Meta-Endpunkte
# -------------------------------------------------
@router.get(
"/manual/wiki_importer",
summary="Handbuch: wiki_importer.py (Markdown)",
description="Kompaktes Handbuch mit .env-Hinweisen, Aufrufen, Idempotenz und Troubleshooting.",
response_class=PlainTextResponse,
response_description="Markdown-Text.",
openapi_extra={
"x-codeSamples": [
{"lang": "bash", "label": "Vollimport (Standard)", "source": "python3 wiki_importer.py --all"},
{"lang": "bash", "label": "Dry-Run + Kategorie", "source": "python3 wiki_importer.py --all --category \"Übungen\" --dry-run"},
]
},
)
def manual_wiki_importer():
return MANUAL_WIKI_IMPORTER
@router.get(
"/meta/env",
summary=".env Referenz (Wiki-bezogen)",
description="Listet die relevanten Umgebungsvariablen für die Wiki-Integration auf (ohne Werte).",
response_description="Array aus {name, desc}.",
)
def meta_env() -> List[Dict[str, str]]:
return ENV_DOC
@router.get(
"/meta/env/runtime",
summary=".env Runtime (wirksame Werte)",
description="Zeigt die aktuell wirksamen Konfigurationswerte für den Wiki-Router (ohne Secrets) und die geladene .env-Quelle.",
response_description="Objekt mit 'loaded_from' und 'env' (Key→Value).",
)
def meta_env_runtime() -> Dict[str, Any]:
keys = ["WIKI_API_URL", "WIKI_TIMEOUT", "WIKI_RETRIES", "WIKI_SLEEP_MS", "WIKI_BATCH"]
has_user = bool(os.getenv("WIKI_BOT_USER"))
has_pwd = bool(os.getenv("WIKI_BOT_PASSWORD"))
return {
"loaded_from": _BOOTSTRAP_ENV,
"env": {k: os.getenv(k) for k in keys},
"credentials": {
"WIKI_BOT_USER_set": has_user,
"WIKI_BOT_PASSWORD_set": has_pwd,
"ready_for_login_env": has_user and has_pwd,
},
}
# -------------------------------------------------
# API-Endpunkte
# -------------------------------------------------
@router.get(
"/health",
summary="Ping & Site-Info des MediaWiki-Upstreams",
description=dedent(
"""
Führt einen leichten `meta=siteinfo`-Request gegen den konfigurierten MediaWiki-Upstream aus.
**Besonderheiten**
- Nutzt eine persistente `requests.Session` (Cookies werden für spätere Aufrufe wiederverwendet).
- Respektiert `.env`: `WIKI_API_URL`, `WIKI_TIMEOUT`, `WIKI_RETRIES`, `WIKI_SLEEP_MS`.
- Bei Upstream-Problemen wird **HTTP 502** geworfen (statt 500).
**Hinweis**: Je nach Wiki-Konfiguration sind detaillierte Infos (Generator/Sitename) nur **nach Login** sichtbar.
"""
),
response_description='`{"status":"ok"}` oder mit `wiki.sitename/generator` bei `verbose=1`.',
openapi_extra={
"x-codeSamples": [
{"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/health?verbose=1' | jq ."}
]
},
)
def health(verbose: Optional[int] = Query(default=0, description="1 = Site-Metadaten (sitename/generator) mitsenden")) -> Dict[str, Any]:
resp = _request_with_retry("GET", {"action": "query", "meta": "siteinfo", "format": "json"})
if verbose:
info = resp.json().get("query", {}).get("general", {})
return {"status": "ok", "wiki": {"sitename": info.get("sitename"), "generator": info.get("generator")}}
return {"status": "ok"}
@router.post("/login", response_model=WikiLoginResponse)
@router.post(
"/login",
response_model=WikiLoginResponse,
summary="MediaWiki-Login (Session-Cookies werden serverseitig gespeichert)",
description=dedent(
"""
Meldet den Proxy am MediaWiki an. Unterstützt `clientlogin` (mit `loginreturnurl`) und
**fällt zurück** auf `action=login`, falls erforderlich. Erfolgreiche Logins hinterlegen die
Session-Cookies in der Server-Session und gelten für nachfolgende Requests.
**Besonderheiten**
- Erwartet **Benutzername/Passwort im Body** (keine .env-Creds).
- Verwendet vor dem Login ein Logintoken (`meta=tokens`).
- Rückgabe `{\"status\":\"success\"}` bei Erfolg, sonst **401**.
- Respektiert Retry/Throttle aus `.env`.
"""
),
response_description='`{"status":"success"}` bei Erfolg.',
)
def login(data: WikiLoginRequest):
# Token holen
tok = _request_with_retry("GET", {"action": "query", "meta": "tokens", "type": "login", "format": "json"})
@ -182,15 +391,63 @@ def login(data: WikiLoginRequest):
return WikiLoginResponse(status="success")
raise HTTPException(status_code=401, detail=f"Login fehlgeschlagen: {res}")
@router.get("/semantic/pages")
def semantic_pages(category: str = Query(..., description="Kategorie ohne 'Category:'")) -> Dict[str, Any]:
# Rohdaten aus SMW (Ask)
@router.post(
"/login/env",
response_model=WikiLoginResponse,
summary="MediaWiki-Login mit .env-Credentials",
description=dedent(
"""
Führt den Login mit **WIKI_BOT_USER/WIKI_BOT_PASSWORD** aus der Prozess-Umgebung durch.
Praktisch für geplante Jobs/CLI ohne Übergabe im Body. Secrets werden **nie** im Klartext zurückgegeben.
**Voraussetzung**: Beide Variablen sind gesetzt (siehe `/import/wiki/meta/env/runtime`).
"""
),
response_description='`{"status":"success"}` bei Erfolg.',
openapi_extra={
"x-codeSamples": [
{"lang": "bash", "label": "curl", "source": "curl -s -X POST http://localhost:8000/import/wiki/login/env | jq ."}
]
},
)
def login_env():
user = os.getenv("WIKI_BOT_USER")
pwd = os.getenv("WIKI_BOT_PASSWORD")
if not user or not pwd:
raise HTTPException(status_code=400, detail="WIKI_BOT_USER/WIKI_BOT_PASSWORD nicht gesetzt")
return login(WikiLoginRequest(username=user, password=pwd))
@router.get(
"/semantic/pages",
summary="SMW-Ask-Ergebnisse einer Kategorie mit PageID/URL anreichern",
description=dedent(
"""
Ruft Semantic MediaWiki via `action=ask` auf und liefert ein **Dictionary**: `{"Titel": {...}}`.
Anschließend werden **alle** Titel batchweise via `prop=info` um `pageid` und `fullurl` ergänzt
(berücksichtigt Redirects & Titel-Normalisierung). Große Kategorien werden in Chunks der Größe
`WIKI_BATCH` verarbeitet. Throttling gemäß `WIKI_SLEEP_MS`.
**Rückgabe**
- Key = Seitentitel
- Value = ursprüngliche Ask-Daten **plus** `pageid` & `fullurl` (falls auflösbar)
- Kann leeres Objekt `{}` sein (z. B. wenn Login erforderlich oder Kategorie leer).
"""
),
response_description="Dictionary pro Titel; Felder `pageid/fullurl` sind evtl. nicht für alle Titel gesetzt.",
openapi_extra={
"x-codeSamples": [
{"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/semantic/pages?category=%C3%9Cbungen' | jq . | head"}
]
},
)
def semantic_pages(category: str = Query(..., description="Kategorie-Name **ohne** 'Category:' Präfix")) -> Dict[str, Any]:
ask_query = f"[[Category:{category}]]|limit=50000"
r = _request_with_retry("GET", {"action": "ask", "query": ask_query, "format": "json"})
results = r.json().get("query", {}).get("results", {}) or {}
titles = list(results.keys())
# Batch-Anreicherung mit pageid/fullurl für ALLE Titel
info_map = _fetch_pageinfo_batch(titles)
enriched: Dict[str, Any] = {}
@ -208,21 +465,64 @@ def semantic_pages(category: str = Query(..., description="Kategorie ohne 'Categ
logger.info("/semantic/pages: %d Titel, %d ohne pageid nach Enrichment", len(results), missing)
return enriched
@router.get("/parsepage", response_model=PageContentResponse)
def parse_page(pageid: int = Query(...), title: str = Query(None)):
@router.get(
"/parsepage",
response_model=PageContentResponse,
summary="Wikitext einer Seite per pageid holen",
description=dedent(
"""
Liefert den **Roh-Wikitext** (`prop=wikitext`) zu einer Seite. Der optionale `title`-Parameter dient
nur als Echo/Diagnose. Für strukturierte Extraktion (Infobox/Abschnitte) muss der Aufrufer den
Wikitext selbst parsen.
**Besonderheiten**
- Erfordert ggf. vorherigen Login (private Wikis).
- Throttling/Retry gemäß `.env`.
- Upstream-Fehler werden als **502** gemeldet.
"""
),
response_description="Roh-Wikitext (keine HTML-Transformation).",
openapi_extra={
"x-codeSamples": [
{"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/parsepage?pageid=218&title=Affenklatschen' | jq ."}
]
},
)
def parse_page(pageid: int = Query(..., description="Numerische PageID der Seite"), title: str = Query(None, description="Optional: Seitentitel (nur Echo)")):
resp = _request_with_retry("GET", {"action": "parse", "pageid": pageid, "prop": "wikitext", "format": "json"})
wikitext = resp.json().get("parse", {}).get("wikitext", {}).get("*", "")
return PageContentResponse(pageid=pageid, title=title or "", wikitext=wikitext)
@router.get("/info", response_model=PageInfoResponse)
def page_info(title: str = Query(..., description="Seitentitel")):
# 1. Versuch: wie geliefert, mit redirects/converttitles
@router.get(
"/info",
response_model=PageInfoResponse,
summary="PageID/URL zu einem Titel auflösen (inkl. Varianten)",
description=dedent(
"""
Versucht zuerst eine **exakte** Auflösung des angegebenen Titels (mit `redirects=1`, `converttitles=1`).
Falls nicht erfolgreich, werden **Titel-Varianten** getestet (LeerzeichenUnterstrich, BindestrichGedankenstrich).
Bei Erfolg Rückgabe mit `pageid`, aufgelöstem `title` und `fullurl`. Andernfalls **404**.
**Typische Fälle**
- Unterschiedliche Schreibweisen (z. B. "Yoko Geri" vs. "Yoko_Geri").
- Redirect-Ketten es wird der **kanonische** Titel/URL geliefert.
"""
),
response_description="Erfolg: PageInfo. Fehler: 404 'Page not found: <title>'.",
openapi_extra={
"x-codeSamples": [
{"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/info?title=Affenklatschen' | jq ."}
]
},
)
def page_info(title: str = Query(..., description="Seitentitel (unscharf; Varianten werden versucht)")):
res = _fetch_pageinfo_batch([title])
if res.get(title):
d = res[title]
return PageInfoResponse(pageid=d["pageid"], title=title, fullurl=d.get("fullurl", ""))
# 2. Varianten probieren
for v in _normalize_variants(title):
if v == title:
continue
@ -231,5 +531,4 @@ def page_info(title: str = Query(..., description="Seitentitel")):
d = res2[v]
return PageInfoResponse(pageid=d["pageid"], title=v, fullurl=d.get("fullurl", ""))
# 3. sauber 404
raise HTTPException(status_code=404, detail=f"Page not found: {title}")

View File

@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.local/schemas/plan_sessions.json",
"title": "PlanSession",
"type": "object",
"additionalProperties": false,
"required": ["id", "plan_id", "executed_at", "location", "coach", "group_label", "feedback", "used_equipment"],
"properties": {
"id": { "type": "string" },
"plan_id": { "type": "string" },
"executed_at": { "type": "string", "format": "date-time" },
"location": { "type": "string" },
"coach": { "type": "string" },
"group_label": { "type": "string" },
"feedback": {
"type": "object",
"additionalProperties": false,
"required": ["rating", "notes"],
"properties": {
"rating": { "type": "integer", "minimum": 1, "maximum": 5 },
"notes": { "type": "string" }
}
},
"used_equipment": { "type": "array", "items": { "type": "string" } }
}
}

View File

@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.local/schemas/plan_templates.json",
"title": "PlanTemplate",
"type": "object",
"additionalProperties": false,
"required": [
"id", "name", "discipline", "age_group", "target_group", "total_minutes",
"sections", "goals", "equipment_allowed", "created_by", "version"
],
"properties": {
"id": { "type": "string" },
"name": { "type": "string", "minLength": 1 },
"discipline": { "type": "string" },
"age_group": { "type": "string" },
"target_group": { "type": "string" },
"total_minutes": { "type": "integer", "minimum": 0 },
"sections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "target_minutes", "must_keywords", "forbid_keywords", "capability_targets"],
"properties": {
"name": { "type": "string" },
"target_minutes": { "type": "integer", "minimum": 0 },
"must_keywords": { "type": "array", "items": { "type": "string" }, "default": [] },
"ideal_keywords": { "type": "array", "items": { "type": "string" }, "default": [] },
"supplement_keywords": { "type": "array", "items": { "type": "string" }, "default": [] },
"forbid_keywords": { "type": "array", "items": { "type": "string" }, "default": [] },
"capability_targets": { "type": "object", "additionalProperties": { "type": "integer" } }
}
}
},
"goals": { "type": "array", "items": { "type": "string" } },
"equipment_allowed": { "type": "array", "items": { "type": "string" } },
"created_by": { "type": "string" },
"version": { "type": "string" }
}
}

53
schemas/plans.json Normal file
View File

@ -0,0 +1,53 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.local/schemas/plans.json",
"title": "Plan",
"type": "object",
"additionalProperties": false,
"required": [
"id", "template_id", "title", "discipline", "age_group", "target_group",
"total_minutes", "sections", "goals", "capability_summary", "created_by",
"created_at", "source", "fingerprint"
],
"properties": {
"id": { "type": "string" },
"template_id": { "type": ["string", "null"] },
"title": { "type": "string", "minLength": 1 },
"discipline": { "type": "string" },
"age_group": { "type": "string" },
"target_group": { "type": "string" },
"total_minutes": { "type": "integer", "minimum": 0 },
"sections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "items", "minutes"],
"properties": {
"name": { "type": "string" },
"minutes": { "type": "integer", "minimum": 0 },
"items": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["exercise_external_id", "duration", "why"],
"properties": {
"exercise_external_id": { "type": "string" },
"duration": { "type": "integer", "minimum": 0 },
"why": { "type": "string" }
}
}
}
}
}
},
"goals": { "type": "array", "items": { "type": "string" } },
"capability_summary": { "type": "object", "additionalProperties": { "type": "integer" } },
"novelty_against_last_n": { "type": ["number", "null"] },
"fingerprint": { "type": "string" },
"created_by": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" },
"source": { "type": "string" }
}
}

View File

@ -0,0 +1,80 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Bootstrap (idempotent) für Qdrant-Collections rund um Pläne v1.3.0
- Fügt fehlende Payload-Indizes hinzu (KEYWORD/FLOAT), idempotent.
- NEU: FLOAT-Index `plans.created_at_ts` für serverseitige Zeitfensterfilter.
"""
import os
from qdrant_client import QdrantClient
from qdrant_client.models import PayloadSchemaType
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
PLANS = os.getenv("PLAN_COLLECTION") or os.getenv("QDRANT_COLLECTION_PLANS", "plans")
TEMPLATES = os.getenv("PLAN_TEMPLATE_COLLECTION", "plan_templates")
SESSIONS = os.getenv("PLAN_SESSION_COLLECTION", "plan_sessions")
INDEX_SPECS = {
TEMPLATES: [
("discipline", PayloadSchemaType.KEYWORD),
("age_group", PayloadSchemaType.KEYWORD),
("target_group", PayloadSchemaType.KEYWORD),
("section_names", PayloadSchemaType.KEYWORD),
("section_must_keywords", PayloadSchemaType.KEYWORD),
("section_ideal_keywords", PayloadSchemaType.KEYWORD),
("section_supplement_keywords", PayloadSchemaType.KEYWORD),
("section_forbid_keywords", PayloadSchemaType.KEYWORD),
("goals", PayloadSchemaType.KEYWORD),
],
PLANS: [
("discipline", PayloadSchemaType.KEYWORD),
("age_group", PayloadSchemaType.KEYWORD),
("target_group", PayloadSchemaType.KEYWORD),
("sections.name", PayloadSchemaType.KEYWORD), # legacy, belassen
("plan_section_names", PayloadSchemaType.KEYWORD),
("goals", PayloadSchemaType.KEYWORD),
("created_by", PayloadSchemaType.KEYWORD),
("created_at", PayloadSchemaType.KEYWORD),
("created_at_ts", PayloadSchemaType.FLOAT), # NEU
("fingerprint", PayloadSchemaType.KEYWORD),
("title", PayloadSchemaType.KEYWORD),
],
SESSIONS: [
("plan_id", PayloadSchemaType.KEYWORD),
("executed_at", PayloadSchemaType.KEYWORD),
("coach", PayloadSchemaType.KEYWORD),
("group_label", PayloadSchemaType.KEYWORD),
],
}
def _create_indexes(client: QdrantClient, collection: str, specs):
try:
client.get_collection(collection)
print(f"[Bootstrap v1.3.0] Collection '{collection}' ok.")
except Exception as e:
print(f"[Bootstrap v1.3.0] WARN: Collection '{collection}' nicht gefunden (wird beim ersten Upsert erstellt). Details: {e}")
return
for field, schema in specs:
try:
client.create_payload_index(collection_name=collection, field_name=field, field_schema=schema)
print(f"[Bootstrap v1.3.0] Index created: {collection}.{field} ({schema})")
except Exception as e:
print(f"[Bootstrap v1.3.0] Index exists or skipped: {collection}.{field} -> {e}")
def main():
print(f"[Bootstrap v1.3.0] Qdrant @ {QDRANT_HOST}:{QDRANT_PORT}")
print(f"[Bootstrap v1.3.0] Collections: TEMPLATES={TEMPLATES} PLANS={PLANS} SESSIONS={SESSIONS}")
client = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT)
for coll, specs in INDEX_SPECS.items():
_create_indexes(client, coll, specs)
print("[Bootstrap v1.3.0] done.")
if __name__ == "__main__":
main()

241
scripts/test_plans_wp15.py Normal file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Unit-, Integrations- und E2E-Tests für WP-15 (v1.2.0)."""
import os
import json
import requests
import pytest
from datetime import datetime, timezone
BASE = os.getenv("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
QDRANT = os.getenv("QDRANT_BASE", "http://127.0.0.1:6333").rstrip("/")
TPL_COLL = os.getenv("PLAN_TEMPLATE_COLLECTION", "plan_templates")
# ---------- Helpers ----------
def _fp_local(plan_payload: dict) -> str:
import hashlib
core = {
"title": plan_payload["title"],
"total_minutes": int(plan_payload["total_minutes"]),
"items": [
{"exercise_external_id": it["exercise_external_id"], "duration": int(it["duration"])}
for sec in plan_payload["sections"]
for it in sec.get("items", [])
],
}
raw = json.dumps(core, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _unique_len_lower(xs):
seen = set()
out = []
for x in xs:
k = x.casefold()
if k not in seen:
seen.add(k)
out.append(x)
return len(out)
# ---------- Unit ----------
def test_fingerprint_unit_v12():
p = {
"title": "Montag Reaktion",
"total_minutes": 90,
"sections": [
{"name": "Warmup", "minutes": 15, "items": [
{"exercise_external_id": "ex:001", "duration": 10, "why": "Aufwärmen"}
]}
],
}
fp1 = _fp_local(p)
p2 = json.loads(json.dumps(p, ensure_ascii=False))
fp2 = _fp_local(p2)
assert fp1 == fp2
# ---------- Integration: Templates mit mehreren Sections + ideal/supplement ----------
def test_template_sections_ideal_supplement_roundtrip():
tpl = {
"name": "Std 90 v1.1",
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensport",
"total_minutes": 90,
"sections": [
{
"name": "Warmup",
"target_minutes": 15,
"must_keywords": ["Reaktion", "reaktion"], # Duplikat in anderer Schreibweise
"ideal_keywords": ["Koordination", "koordination"], # Duplikat in anderer Schreibweise
"supplement_keywords": ["Teamspiel", " Teamspiel "], # Duplikat mit Whitespace
"forbid_keywords": [],
"capability_targets": {"Reaktionsfähigkeit": 2, "Mobilität": 1}
},
{
"name": "Technikblock",
"target_minutes": 30,
"must_keywords": ["Mae-Geri"],
"ideal_keywords": ["Timing"],
"supplement_keywords": ["Partnerarbeit"],
"forbid_keywords": ["Bodenarbeit"],
"capability_targets": {"Technikpräzision": 2, "Schnelligkeit": 1}
}
],
"goals": ["Technik", "Kondition"],
"equipment_allowed": ["Bälle"],
"created_by": "tester",
"version": "1.1"
}
r = requests.post(f"{BASE}/plan_templates", json=tpl)
assert r.status_code == 200, r.text
tpl_id = r.json()["id"]
r2 = requests.get(f"{BASE}/plan_templates/{tpl_id}")
assert r2.status_code == 200, r2.text
got = r2.json()
# Prüfen: Beide Sections vorhanden
assert len(got["sections"]) == 2
s1 = got["sections"][0]
# Normalisierung: Duplikate entfernt (case-insensitive), Whitespace getrimmt
assert _unique_len_lower(s1["must_keywords"]) == len(s1["must_keywords"]) == 1
assert _unique_len_lower(s1["ideal_keywords"]) == len(s1["ideal_keywords"]) == 1
assert _unique_len_lower(s1["supplement_keywords"]) == len(s1["supplement_keywords"]) == 1
# ---------- Optional: Qdrant-Payload-Check (materialisierte Felder) ----------
def test_qdrant_materialized_fields_template():
"""
Robust: Erst frisches Template anlegen (mit neuen Feldern), dann Qdrant-Scroll
mit Filter auf genau diese Template-ID. So treffen wir sicher einen Punkt,
der die materialisierten Felder enthält unabhängig von älteren Datensätzen.
"""
# 1) Frisches Template erzeugen (mit ideal/supplement)
tpl = {
"name": "Std 90 v1.1 materialized-check",
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensport",
"total_minutes": 90,
"sections": [
{
"name": "Warmup",
"target_minutes": 15,
"must_keywords": ["Reaktion"],
"ideal_keywords": ["Koordination"],
"supplement_keywords": ["Teamspiel"],
"forbid_keywords": [],
"capability_targets": {"Reaktionsfähigkeit": 2}
}
],
"goals": ["Technik"],
"equipment_allowed": ["Bälle"],
"created_by": "tester",
"version": "1.1"
}
r = requests.post(f"{BASE}/plan_templates", json=tpl)
assert r.status_code == 200, r.text
tpl_id = r.json()["id"]
# 2) Qdrant gezielt nach genau diesem Punkt scrollen (Payload enthält id)
try:
rq = requests.post(
f"{QDRANT}/collections/{TPL_COLL}/points/scroll",
json={
"with_payload": True,
"limit": 1,
"filter": {"must": [{"key": "id", "match": {"value": tpl_id}}]}
},
timeout=2.0,
)
except Exception:
pytest.skip("Qdrant nicht erreichbar überspringe materialisierte Feldprüfung")
if rq.status_code != 200:
pytest.skip(f"Qdrant-Scroll liefert {rq.status_code}")
js = rq.json()
pts = (js.get("result") or {}).get("points") or []
if not pts:
pytest.skip("Keine Übereinstimmung in plan_templates überspringe")
payload = pts[0].get("payload") or {}
for key in [
"section_names",
"section_must_keywords",
"section_ideal_keywords",
"section_supplement_keywords",
"section_forbid_keywords",
]:
assert key in payload
assert isinstance(payload[key], list)
# ---------- E2E: Plan anlegen + Idempotenz trotz variierender Nebenfelder ----------
def test_plan_e2e_idempotence_same_fingerprint():
# Template minimal für Bezug
tpl = {
"name": "Std 90 for plan",
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensport",
"total_minutes": 90,
"sections": [
{"name": "Warmup", "target_minutes": 15, "must_keywords": [], "forbid_keywords": [], "capability_targets": {}}
],
"goals": [],
"equipment_allowed": [],
"created_by": "tester",
"version": "1.0"
}
r1 = requests.post(f"{BASE}/plan_templates", json=tpl)
assert r1.status_code == 200
tpl_id = r1.json()["id"]
plan_base = {
"template_id": tpl_id,
"title": "KW32 Montag",
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensport",
"total_minutes": 90,
"sections": [
{"name": "Warmup", "minutes": 15, "items": [
{"exercise_external_id": "ex:001", "duration": 10, "why": "Aufwärmen"}
]}
],
"created_by": "tester",
"created_at": datetime.now(timezone.utc).isoformat(),
"source": "test"
}
# Plan A mit goals/capability_summary
plan_a = dict(plan_base)
plan_a.update({
"goals": ["Technik"],
"capability_summary": {"Reaktionsfähigkeit": 2}
})
r2 = requests.post(f"{BASE}/plan", json=plan_a)
assert r2.status_code == 200, r2.text
plan_id = r2.json()["id"]
# Plan B gleicher Fingerprint (gleiche items), aber andere Nebenfelder
plan_b = dict(plan_base)
plan_b.update({
"goals": ["Kondition"],
"capability_summary": {"Reaktionsfähigkeit": 3}
})
r3 = requests.post(f"{BASE}/plan", json=plan_b)
assert r3.status_code == 200
assert r3.json()["id"] == plan_id, "Idempotenz verletzt: gleicher Fingerprint muss gleiche ID liefern"
# GET prüfen
r4 = requests.get(f"{BASE}/plan/{plan_id}")
assert r4.status_code == 200
js = r4.json()
assert js["title"] == "KW32 Montag"

View File

@ -0,0 +1,93 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Integritätstests für Referenzen (Templates, Exercises, Plans)."""
import os, requests, uuid
from datetime import datetime, timezone
BASE = os.getenv("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
def _make_exercise(ext_id: str):
payload = {
"external_id": ext_id,
"fingerprint": "it-sha",
"source": "IntegrityTest",
"title": "Übung Dummy",
"summary": "",
"short_description": "",
"keywords": ["Reaktion"],
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensport",
"min_participants": 1,
"duration_minutes": 5,
"capabilities": {"Reaktionsfähigkeit": 1},
"category": "Übungen",
"purpose": "",
"execution": "",
"notes": "",
"preparation": "",
"method": "",
"equipment": []
}
r = requests.post(f"{BASE}/exercise", json=payload)
assert r.status_code == 200, r.text
def test_plan_requires_existing_template():
# Übung anlegen für gültigen Plan (damit Exercise-Check nicht stört)
exid = f"it:{uuid.uuid4()}"; _make_exercise(exid)
# Plan mit nicht existenter template_id
plan = {
"template_id": "does-not-exist",
"title": "Plan mit falschem Template",
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensport",
"total_minutes": 30,
"sections": [{"name": "Block", "minutes": 30, "items": [{"exercise_external_id": exid, "duration": 10, "why": ""}]}],
"goals": [],
"capability_summary": {},
"created_by": "tester",
"created_at": datetime.now(timezone.utc).isoformat(),
"source": "test"
}
r = requests.post(f"{BASE}/plan", json=plan)
assert r.status_code == 422
def test_plan_session_requires_existing_plan():
sess = {
"plan_id": "does-not-exist",
"executed_at": datetime.now(timezone.utc).isoformat(),
"location": "Dojo",
"coach": "X",
"group_label": "Y",
"feedback": {"rating": 3, "notes": ""},
"used_equipment": []
}
r = requests.post(f"{BASE}/plan_sessions", json=sess)
assert r.status_code == 422
def test_strict_exercises_if_env():
# Nur sinnvoll, wenn Server mit PLAN_STRICT_EXERCISES=1 gestartet wurde
if os.getenv("PLAN_STRICT_EXERCISES") not in {"1", "true", "yes", "on"}:
import pytest; pytest.skip("Strict-Mode nicht aktiv Test übersprungen")
plan = {
"title": "Plan mit unbekannter Übung",
"discipline": "Karate",
"age_group": "Teenager",
"target_group": "Breitensport",
"total_minutes": 10,
"sections": [{"name": "Block", "minutes": 10, "items": [{"exercise_external_id": "unknown:xyz", "duration": 5, "why": ""}]}],
"goals": [],
"capability_summary": {},
"created_by": "tester",
"created_at": datetime.now(timezone.utc).isoformat(),
"source": "test"
}
r = requests.post(f"{BASE}/plan", json=plan)
assert r.status_code == 422

View File

@ -0,0 +1,73 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Listen-/Filter-Tests für Templates & Pläne (v0.1.0)."""
import os
import requests
from datetime import datetime, timezone, timedelta
BASE = os.getenv("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
def test_list_plan_templates_filters():
# Zwei Templates anlegen (unterschiedliche Sections/Goals)
tpl1 = {
"name": "ListTpl A", "discipline": "Karate", "age_group": "Teenager", "target_group": "Breitensport", "total_minutes": 60,
"sections": [{"name": "Warmup", "target_minutes": 10, "must_keywords": ["Reaktion"], "forbid_keywords": [], "ideal_keywords": ["Koordination"], "supplement_keywords": [], "capability_targets": {}}],
"goals": ["Technik"], "equipment_allowed": [], "created_by": "tester", "version": "1.0"
}
tpl2 = {
"name": "ListTpl B", "discipline": "Karate", "age_group": "Erwachsene", "target_group": "Breitensport", "total_minutes": 60,
"sections": [{"name": "Technikblock", "target_minutes": 30, "must_keywords": ["Mae-Geri"], "forbid_keywords": [], "ideal_keywords": ["Timing"], "supplement_keywords": ["Partnerarbeit"], "capability_targets": {}}],
"goals": ["Kondition"], "equipment_allowed": [], "created_by": "tester", "version": "1.0"
}
r1 = requests.post(f"{BASE}/plan_templates", json=tpl1); assert r1.status_code == 200
r2 = requests.post(f"{BASE}/plan_templates", json=tpl2); assert r2.status_code == 200
# Filter: discipline=Karate & section=Warmup → sollte tpl1 enthalten
r = requests.get(f"{BASE}/plan_templates", params={"discipline": "Karate", "section": "Warmup", "limit": 10, "offset": 0})
assert r.status_code == 200, r.text
js = r.json(); assert js["count"] >= 1
assert any(tpl["name"] == "ListTpl A" for tpl in js["items"])
# Filter: keyword=Timing → sollte tpl2 treffen (ideal_keywords)
r = requests.get(f"{BASE}/plan_templates", params={"keyword": "Timing"})
assert r.status_code == 200
js = r.json(); names = [t["name"] for t in js["items"]]
assert "ListTpl B" in names
def test_list_plans_filters_and_window():
# Plan A (Teenager), Plan B (Erwachsene)
base_tpl = {
"name": "ListTpl PlanBase", "discipline": "Karate", "age_group": "Teenager", "target_group": "Breitensport", "total_minutes": 45,
"sections": [{"name": "Warmup", "target_minutes": 10, "must_keywords": [], "forbid_keywords": [], "capability_targets": {}}],
"goals": ["Technik"], "equipment_allowed": [], "created_by": "tester", "version": "1.0"
}
rt = requests.post(f"{BASE}/plan_templates", json=base_tpl); assert rt.status_code == 200
tpl_id = rt.json()["id"]
now = datetime.now(timezone.utc)
plan_a = {
"template_id": tpl_id, "title": "List Plan A", "discipline": "Karate", "age_group": "Teenager", "target_group": "Breitensport", "total_minutes": 45,
"sections": [{"name": "Warmup", "minutes": 10, "items": []}], "goals": ["Technik"], "capability_summary": {}, "created_by": "tester",
"created_at": now.isoformat(), "source": "test"
}
plan_b = {
"template_id": tpl_id, "title": "List Plan B", "discipline": "Karate", "age_group": "Erwachsene", "target_group": "Breitensport", "total_minutes": 45,
"sections": [{"name": "Technikblock", "minutes": 30, "items": []}], "goals": ["Kondition"], "capability_summary": {}, "created_by": "tester",
"created_at": (now + timedelta(seconds=1)).isoformat(), "source": "test"
}
ra = requests.post(f"{BASE}/plan", json=plan_a); assert ra.status_code == 200
rb = requests.post(f"{BASE}/plan", json=plan_b); assert rb.status_code == 200
# Filter: age_group=Teenager & section=Warmup → sollte Plan A enthalten
r = requests.get(f"{BASE}/plans", params={"age_group": "Teenager", "section": "Warmup"})
assert r.status_code == 200
js = r.json(); assert js["count"] >= 1
assert any(p["title"] == "List Plan A" for p in js["items"])
# Zeitfenster: created_from nach Plan A → sollte Plan B enthalten
r = requests.get(f"{BASE}/plans", params={"created_from": (now + timedelta(milliseconds=500)).isoformat()})
assert r.status_code == 200
js = r.json(); titles = [p["title"] for p in js["items"]]
assert "List Plan B" in titles

View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Integration/E2E-Tests für plan_sessions (v0.1.0)."""
import os
import requests
from datetime import datetime, timezone
BASE = os.getenv("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
def test_plan_session_create_read():
# 1) Template minimal
tpl = {
"name": "Std 60 for sessions",
"discipline": "Karate",
"age_group": "Erwachsene",
"target_group": "Breitensport",
"total_minutes": 60,
"sections": [
{"name": "Warmup", "target_minutes": 10, "must_keywords": [], "forbid_keywords": [], "capability_targets": {}}
],
"goals": [],
"equipment_allowed": [],
"created_by": "tester",
"version": "1.0"
}
r1 = requests.post(f"{BASE}/plan_templates", json=tpl)
assert r1.status_code == 200, r1.text
# 2) Plan minimal
plan = {
"template_id": r1.json()["id"],
"title": "SessionPlan",
"discipline": "Karate",
"age_group": "Erwachsene",
"target_group": "Breitensport",
"total_minutes": 60,
"sections": [
{"name": "Warmup", "minutes": 10, "items": []}
],
"goals": [],
"capability_summary": {},
"created_by": "tester",
"created_at": datetime.now(timezone.utc).isoformat(),
"source": "test"
}
r2 = requests.post(f"{BASE}/plan", json=plan)
assert r2.status_code == 200, r2.text
plan_id = r2.json()["id"]
# 3) Session anlegen
session = {
"plan_id": plan_id,
"executed_at": datetime.now(timezone.utc).isoformat(),
"location": "Dojo A",
"coach": "Sensei K.",
"group_label": "Montag 18:00",
"feedback": {"rating": 4, "notes": "Gute Energie, Warmup etwas zu kurz."},
"used_equipment": [" Pratzen ", "Bälle", "pratzen"]
}
r3 = requests.post(f"{BASE}/plan_sessions", json=session)
assert r3.status_code == 200, r3.text
sess_id = r3.json()["id"]
# 4) Session lesen & Normalisierung prüfen
r4 = requests.get(f"{BASE}/plan_sessions/{sess_id}")
assert r4.status_code == 200
js = r4.json()
# used_equipment dedupliziert/trimmed/casefolded → len == 2
assert len(js["used_equipment"]) == 2
assert "Pratzen" in js["used_equipment"] or "pratzen" in [x.lower() for x in js["used_equipment"]]
def test_plan_session_invalid_rating():
# Minimaler Plan für Referenz
tpl = {
"name": "Std 45 for sessions",
"discipline": "Karate",
"age_group": "Erwachsene",
"target_group": "Breitensport",
"total_minutes": 45,
"sections": [
{"name": "Warmup", "target_minutes": 10, "must_keywords": [], "forbid_keywords": [], "capability_targets": {}}
],
"goals": [],
"equipment_allowed": [],
"created_by": "tester",
"version": "1.0"
}
r1 = requests.post(f"{BASE}/plan_templates", json=tpl)
assert r1.status_code == 200
plan = {
"template_id": r1.json()["id"],
"title": "Fehlerfall",
"discipline": "Karate",
"age_group": "Erwachsene",
"target_group": "Breitensport",
"total_minutes": 45,
"sections": [
{"name": "Warmup", "minutes": 10, "items": []}
],
"goals": [],
"capability_summary": {},
"created_by": "tester",
"created_at": datetime.now(timezone.utc).isoformat(),
"source": "test"
}
r2 = requests.post(f"{BASE}/plan", json=plan)
assert r2.status_code == 200
bad_session = {
"plan_id": r2.json()["id"],
"executed_at": datetime.now(timezone.utc).isoformat(),
"location": "Dojo B",
"coach": "Assist.",
"group_label": "Dienstag 19:00",
"feedback": {"rating": 7, "notes": "invalid"}, # ungültig (1..5)
"used_equipment": []
}
r_bad = requests.post(f"{BASE}/plan_sessions", json=bad_session)
assert r_bad.status_code == 422