Compare commits

..

13 Commits

Author SHA1 Message Date
Tim Leikauf
2458a024b3 Merge branch 'claude/affectionate-agnesi-411173' 2026-05-06 22:14:33 +02:00
Tim Leikauf
9c8ec853f6 refactor: replace _ensure_junction/_ensure_link with batch Directus ops in generate_questions
- Remove per-call _ensure_junction (junction tables already exist)
- Load existing word/question links upfront (2 GET requests instead of N)
- Batch POST all new words_objects links in a single request
- Batch POST related_words and distractor_words per new question
- Eliminates O(N) serial GET+POST pattern in favour of O(1) upfront dedup + batch writes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:14:28 +02:00
Tim Leikauf
d94c4a57c5 Merge: words_pictures natives Directus M2M 2026-05-06 22:00:55 +02:00
Tim Leikauf
a622ac49df refactor(words): words_pictures auf natives Directus M2M umgestellt
- GET: Deep-Query über pictures.linked_words statt manuelle Junction-Abfrage
- POST: PATCH /items/pictures/{id} mit linked_words.create statt _ensure_link
- _ensure_junction/_ensure_link für words_pictures entfernt
- Setup-Logik in _setup_words_pictures() ausgelagert (idempotent)
- Batch-Insert aller neuen Links in einem einzigen PATCH-Call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:00:55 +02:00
Tim Leikauf
de124440a4 Merge: auto Relations-Setup beim Word-Save 2026-05-06 21:52:10 +02:00
Tim Leikauf
622907d426 fix(words): Relations-Setup automatisch beim ersten Word-Save
words_pictures M2M-Relationen werden idempotent beim POST eingerichtet –
kein manueller Setup-Call nötig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:52:09 +02:00
Tim Leikauf
0340f9bb7d Merge: setup-words-pictures + debounce + level-update 2026-05-06 21:44:53 +02:00
Tim Leikauf
5e0de3014e feat(setup): /api/setup-words-pictures – M2M-Relation words↔pictures einrichten
Einmaliger Setup-Endpoint: setzt special=m2m auf Alias-Felder,
erstellt Relations für words_pictures Junction in Directus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:44:53 +02:00
Tim Leikauf
860391bcbe Merge: Level-Update + Picture-Link für bestehende Wörter 2026-05-06 21:30:41 +02:00
Tim Leikauf
cc782c0ef0 fix(words): bestehendes Wort → Level updaten + Picture-Link immer setzen
_find_or_create_word gibt is_new zurück; bei is_new=False wird das Level
via PATCH aktualisiert. _ensure_link läuft immer → Picture-Junction wird
auch für bereits existierende Wörter angelegt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:30:41 +02:00
Tim Leikauf
9acc1d93b4 Merge: Bildnavigation debounce fix 2026-05-06 21:29:16 +02:00
Tim Leikauf
08cce17976 fix(draw): Bildnavigation debounce – nur letztes Bild laden bei schnellem Weiterklicken
currentPicture folgt debouncedIndex (350ms), currentIndex reagiert sofort
für Counter und Button-Disabled-State.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:28:11 +02:00
Tim Leikauf
202d4333a8 Merge: Words-Frame mit Level per Bild (Safe Words → Directus) 2026-05-06 21:22:43 +02:00
2 changed files with 199 additions and 73 deletions

262
app.py
View File

@@ -113,60 +113,117 @@ def directus_object(obj_id):
return jsonify(data), status return jsonify(data), status
def _setup_words_pictures(token: str):
"""Richtet M2M-Relation words ↔ pictures idempotent ein (läuft einmalig)."""
_directus("PATCH", "/fields/pictures/linked_words", token, {
"type": "alias",
"meta": {"special": ["m2m"], "interface": "list-m2m",
"options": {"template": "{{words_id.title_de}}"},
"note": "Verknüpfte Safe Words"},
})
_directus("PATCH", "/fields/words/linked_pictures", token, {
"type": "alias",
"meta": {"special": ["m2m"], "interface": "list-m2m",
"options": {"template": "{{pictures_id.media}}"},
"note": "Verknüpfte Bilder"},
})
_directus("POST", "/relations", token, {
"collection": "words_pictures", "field": "words_id",
"related_collection": "words",
"schema": {"on_delete": "CASCADE"},
"meta": {"junction_field": "pictures_id", "one_field": "linked_pictures",
"one_deselect_action": "nullify"},
})
_directus("POST", "/relations", token, {
"collection": "words_pictures", "field": "pictures_id",
"related_collection": "pictures",
"schema": {"on_delete": "CASCADE"},
"meta": {"junction_field": "words_id", "one_field": "linked_words",
"one_deselect_action": "nullify"},
})
@app.route("/api/directus/pictures/<pic_id>/words", methods=["GET", "POST"]) @app.route("/api/directus/pictures/<pic_id>/words", methods=["GET", "POST"])
def directus_picture_words(pic_id): def directus_picture_words(pic_id):
"""Proxy: Safe-Words eines Bildes laden (GET) oder speichern (POST).""" """Proxy: Safe-Words eines Bildes laden (GET) oder speichern (POST).
Nutzt natives Directus M2M über pictures.linked_words.
"""
token = request.headers.get("Authorization", "") token = request.headers.get("Authorization", "")
if request.method == "GET": if request.method == "GET":
junc, _ = _directus( # Natives Directus Deep-Query über M2M-Relation
data, status = _directus(
"GET", "GET",
f"/items/words_pictures?filter[pictures_id][_eq]={pic_id}&fields=id,words_id&limit=500", f"/items/pictures/{pic_id}"
f"?fields[]=linked_words.id"
f"&fields[]=linked_words.words_id.id"
f"&fields[]=linked_words.words_id.title_de"
f"&fields[]=linked_words.words_id.level"
f"&fields[]=linked_words.words_id.status",
token, token,
) )
w_ids = [e["words_id"] for e in (junc.get("data") or []) if e.get("words_id")] if status != 200:
if not w_ids:
return jsonify({"data": []}) return jsonify({"data": []})
ids_param = urllib.parse.quote(",".join(w_ids), safe="") items = []
w_data, _ = _directus( for entry in ((data.get("data") or {}).get("linked_words") or []):
"GET", word = entry.get("words_id") or {}
f"/items/words?filter[id][_in]={ids_param}&filter[status][_neq]=archived&fields=id,title_de,level,status&limit=500", if not isinstance(word, dict) or not word.get("id"):
token, continue
) if word.get("status") == "archived":
junc_by_word = {e["words_id"]: e["id"] for e in (junc.get("data") or [])} continue
items = [ items.append({
{ "id": entry.get("id", ""),
"id": junc_by_word.get(w["id"], ""), "word_id": word["id"],
"word_id": w["id"], "title_de": word.get("title_de", ""),
"title_de": w["title_de"], "level": word.get("level") or 50,
"level": w.get("level") or 50, "status": word.get("status", ""),
"status": w.get("status", ""), })
}
for w in (w_data.get("data") or [])
]
return jsonify({"data": items}) return jsonify({"data": items})
else: # POST else: # POST
body = request.get_json(force=True, silent=True) or {} body = request.get_json(force=True, silent=True) or {}
words = body.get("words", []) words_to_save = body.get("words", [])
_ensure_junction("words_pictures", "words_id", "pictures_id", token)
# Relationen einmalig sicherstellen
_setup_words_pictures(token)
# Bereits verknüpfte Word-IDs laden (Duplikat-Schutz)
existing_data, _ = _directus(
"GET",
f"/items/pictures/{pic_id}?fields[]=linked_words.words_id",
token,
)
existing_ids = set()
for e in ((existing_data.get("data") or {}).get("linked_words") or []):
wid = e.get("words_id")
if wid:
existing_ids.add(wid if isinstance(wid, str) else wid.get("id", ""))
# Wörter anlegen/finden, Level updaten, neue Links sammeln
new_links = []
saved = 0 saved = 0
for entry in words: for entry in words_to_save:
title_de = (entry.get("title_de") or "").strip() title_de = (entry.get("title_de") or "").strip()
level = int(entry.get("level") or 50) level = int(entry.get("level") or 50)
if not title_de: if not title_de:
continue continue
try: try:
wid, _ = _find_or_create_word(title_de, level, token) wid, is_new = _find_or_create_word(title_de, level, token)
_ensure_link( if not is_new:
"words_pictures", _directus("PATCH", f"/items/words/{wid}", token, {"level": level})
{"words_id": wid, "pictures_id": pic_id}, if wid not in existing_ids:
{"words_id": wid, "pictures_id": pic_id}, new_links.append({"words_id": wid})
token, existing_ids.add(wid)
) saved += 1
saved += 1
except Exception as e: except Exception as e:
print(f"[picture_words] error for '{title_de}': {e}") print(f"[picture_words] error for '{title_de}': {e}")
# Alle neuen Links in einem einzigen Directus-PATCH
if new_links:
_directus("PATCH", f"/items/pictures/{pic_id}", token, {
"linked_words": {"create": new_links}
})
return jsonify({"ok": True, "saved": saved}) return jsonify({"ok": True, "saved": saved})
@@ -1088,14 +1145,6 @@ def generate_questions(obj_id: str):
if not levels: if not levels:
return jsonify({"error": "No levels in AI response"}), 500 return jsonify({"error": "No levels in AI response"}), 500
# Junction-Collections sicherstellen (einmalig)
for col, f1, f2 in [
("words_objects", "words_id", "objects_id"),
("questions_objects", "questions_id", "objects_id"),
("questions_distractor_words", "questions_id", "words_id"),
]:
_ensure_junction(col, f1, f2, token)
stats = { stats = {
"words_created": 0, "words_created": 0,
"words_linked": 0, "words_linked": 0,
@@ -1116,6 +1165,21 @@ def generate_questions(obj_id: str):
# Mehrwortige Einträge → überspringen (KI-Fehler) # Mehrwortige Einträge → überspringen (KI-Fehler)
return tokens return tokens
# Bestehende Verlinkungen vorab laden (Dedup ohne N×GET)
ew_data, _ = _directus(
"GET",
f"/items/words_objects?filter[objects_id][_eq]={obj_id}&fields[]=words_id&limit=2000",
token,
)
existing_word_ids: set[str] = {e["words_id"] for e in (ew_data.get("data") or [])}
eq_data, _ = _directus(
"GET",
f"/items/questions_objects?filter[objects_id][_eq]={obj_id}&fields[]=questions_id&limit=200",
token,
)
existing_question_ids: set[str] = {e["questions_id"] for e in (eq_data.get("data") or [])}
# Alle eindeutigen Wörter aus allen Leveln vorab sammeln und einmalig laden # Alle eindeutigen Wörter aus allen Leveln vorab sammeln und einmalig laden
all_words_by_level: dict[str, int] = {} # title_de → first level seen all_words_by_level: dict[str, int] = {} # title_de → first level seen
for lvl in levels: for lvl in levels:
@@ -1128,20 +1192,22 @@ def generate_questions(obj_id: str):
# Wörter einmalig anlegen / finden (globaler Cache über alle Level) # Wörter einmalig anlegen / finden (globaler Cache über alle Level)
global_word_map: dict[str, str] = {} # title_de → id global_word_map: dict[str, str] = {} # title_de → id
new_word_links: list[dict] = []
for w, lvl_num in all_words_by_level.items(): for w, lvl_num in all_words_by_level.items():
try: try:
wid, is_new = _find_or_create_word(w, lvl_num, token) wid, is_new = _find_or_create_word(w, lvl_num, token)
global_word_map[w] = wid global_word_map[w] = wid
_ensure_link( if wid not in existing_word_ids:
"words_objects", new_word_links.append({"words_id": wid, "objects_id": obj_id})
{"words_id": wid, "objects_id": obj_id}, existing_word_ids.add(wid)
{"words_id": wid, "objects_id": obj_id},
token,
)
stats["words_created" if is_new else "words_linked"] += 1 stats["words_created" if is_new else "words_linked"] += 1
except Exception as e: except Exception as e:
print(f"[generate_questions] word error '{w}': {e}") print(f"[generate_questions] word error '{w}': {e}")
# Alle neuen Wort-Verlinkungen in einem Batch speichern
if new_word_links:
_directus("POST", "/items/words_objects", token, new_word_links)
for lvl in levels: for lvl in levels:
level = int(lvl.get("level") or 1) level = int(lvl.get("level") or 1)
q_de = (lvl.get("question") or "").strip() q_de = (lvl.get("question") or "").strip()
@@ -1164,33 +1230,27 @@ def generate_questions(obj_id: str):
stats["questions_created" if q_is_new else "questions_linked"] += 1 stats["questions_created" if q_is_new else "questions_linked"] += 1
# Frage ↔ Objekt # Frage ↔ Objekt (nur wenn noch nicht verknüpft)
_ensure_link( if q_id not in existing_question_ids:
"questions_objects", _directus("POST", "/items/questions_objects", token,
{"questions_id": q_id, "objects_id": obj_id}, {"questions_id": q_id, "objects_id": obj_id})
{"questions_id": q_id, "objects_id": obj_id}, existing_question_ids.add(q_id)
token,
)
# related_words # related_words + distractor_words nur für neue Fragen (batch)
for w in words_list: if q_is_new:
if w in global_word_map: rw_links = [
_ensure_link( {"questions_id": q_id, "words_id": global_word_map[w]}
"questions_words", for w in words_list if w in global_word_map
{"questions_id": q_id, "words_id": global_word_map[w]}, ]
{"questions_id": q_id, "words_id": global_word_map[w]}, if rw_links:
token, _directus("POST", "/items/questions_words", token, rw_links)
)
# distractor_words dw_links = [
for w in distractor_list: {"questions_id": q_id, "words_id": global_word_map[w]}
if w in global_word_map: for w in distractor_list if w in global_word_map
_ensure_link( ]
"questions_distractor_words", if dw_links:
{"questions_id": q_id, "words_id": global_word_map[w]}, _directus("POST", "/items/questions_distractor_words", token, dw_links)
{"questions_id": q_id, "words_id": global_word_map[w]},
token,
)
print(f"[generate_questions] obj={obj_id} stats={stats}") print(f"[generate_questions] obj={obj_id} stats={stats}")
return jsonify({"ok": True, "object_id": obj_id, "stats": stats}) return jsonify({"ok": True, "object_id": obj_id, "stats": stats})
@@ -1572,6 +1632,64 @@ def setup_directus_schema():
"failed": len(failed), "results": results}) "failed": len(failed), "results": results})
@app.route("/api/setup-words-pictures", methods=["POST"])
def setup_words_pictures():
"""
Einmalig ausführen: Konfiguriert die M2M-Relation words ↔ pictures
via words_pictures Junction in Directus.
Idempotent bereits vorhandene Felder/Relationen werden übersprungen.
"""
token = request.headers.get("Authorization", "")
results = []
def do(label, method, path, body=None):
data, status = _directus(method, path, token, body)
ok = status in (200, 201, 204)
results.append({"label": label, "status": status, "ok": ok,
"err": None if ok else (data.get("errors") or data)})
return ok
# Sicherstellen dass Junction-Collection existiert
_ensure_junction("words_pictures", "words_id", "pictures_id", token)
# special=m2m auf Alias-Felder setzen (idempotent)
do("patch pictures.linked_words special", "PATCH", "/fields/pictures/linked_words", {
"type": "alias",
"meta": {"special": ["m2m"], "interface": "list-m2m",
"options": {"template": "{{words_id.title_de}}"},
"note": "Verknüpfte Safe Words"},
})
do("patch words.linked_pictures special", "PATCH", "/fields/words/linked_pictures", {
"type": "alias",
"meta": {"special": ["m2m"], "interface": "list-m2m",
"options": {"template": "{{pictures_id.media}}"},
"note": "Verknüpfte Bilder"},
})
# Relation words_pictures.words_id → words
do("relation words_pictures.words_id→words", "POST", "/relations", {
"collection": "words_pictures", "field": "words_id",
"related_collection": "words",
"schema": {"on_delete": "CASCADE"},
"meta": {"junction_field": "pictures_id",
"one_field": "linked_pictures",
"one_deselect_action": "nullify"},
})
# Relation words_pictures.pictures_id → pictures
do("relation words_pictures.pictures_id→pictures", "POST", "/relations", {
"collection": "words_pictures", "field": "pictures_id",
"related_collection": "pictures",
"schema": {"on_delete": "CASCADE"},
"meta": {"junction_field": "words_id",
"one_field": "linked_words",
"one_deselect_action": "nullify"},
})
failed = [r for r in results if not r["ok"]]
return jsonify({"ok": len(failed) == 0, "total": len(results),
"failed": len(failed), "results": results})
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True) app.run(host="0.0.0.0", port=8000, debug=True)

View File

@@ -30,6 +30,7 @@ export default function DrawIt() {
const [pictureList, setPictureList] = useState<DirectusPicture[]>([]) const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1) const [currentIndex, setCurrentIndex] = useState(-1)
const [debouncedIndex, setDebouncedIndex] = useState(-1)
const [objects, setObjects] = useState<DirectusObject[]>([]) const [objects, setObjects] = useState<DirectusObject[]>([])
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null) const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
const [currentSelections, setCurrentSelections] = useState<Selection[]>([]) const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
@@ -52,6 +53,12 @@ export default function DrawIt() {
const canvasRef = useRef<DrawCanvasHandle>(null) const canvasRef = useRef<DrawCanvasHandle>(null)
// Debounce: Bild erst laden wenn 350ms keine weitere Navigation
useEffect(() => {
const t = setTimeout(() => setDebouncedIndex(currentIndex), 350)
return () => clearTimeout(t)
}, [currentIndex])
useEffect(() => { useEffect(() => {
if (safeWordInputVisible) safeWordInputRef.current?.focus() if (safeWordInputVisible) safeWordInputRef.current?.focus()
}, [safeWordInputVisible]) }, [safeWordInputVisible])
@@ -83,8 +90,9 @@ export default function DrawIt() {
} }
} }
// currentPicture folgt dem debouncedIndex → lädt erst wenn Navigation pausiert
const currentPicture: DirectusPicture | null = const currentPicture: DirectusPicture | null =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null debouncedIndex >= 0 && debouncedIndex < pictureList.length ? pictureList[debouncedIndex] : null
// Map DirectusObject → CanvasObject for rendering // Map DirectusObject → CanvasObject for rendering
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({ const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({