Compare commits
15 Commits
claude/aff
...
5b99bef765
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b99bef765 | ||
|
|
5357805530 | ||
|
|
2458a024b3 | ||
|
|
9c8ec853f6 | ||
|
|
d94c4a57c5 | ||
|
|
a622ac49df | ||
|
|
de124440a4 | ||
|
|
622907d426 | ||
|
|
0340f9bb7d | ||
|
|
5e0de3014e | ||
|
|
860391bcbe | ||
|
|
cc782c0ef0 | ||
|
|
9acc1d93b4 | ||
|
|
08cce17976 | ||
|
|
202d4333a8 |
262
app.py
262
app.py
@@ -113,60 +113,117 @@ def directus_object(obj_id):
|
||||
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"])
|
||||
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", "")
|
||||
|
||||
if request.method == "GET":
|
||||
junc, _ = _directus(
|
||||
# Natives Directus Deep-Query über M2M-Relation
|
||||
data, status = _directus(
|
||||
"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,
|
||||
)
|
||||
w_ids = [e["words_id"] for e in (junc.get("data") or []) if e.get("words_id")]
|
||||
if not w_ids:
|
||||
if status != 200:
|
||||
return jsonify({"data": []})
|
||||
ids_param = urllib.parse.quote(",".join(w_ids), safe="")
|
||||
w_data, _ = _directus(
|
||||
"GET",
|
||||
f"/items/words?filter[id][_in]={ids_param}&filter[status][_neq]=archived&fields=id,title_de,level,status&limit=500",
|
||||
token,
|
||||
)
|
||||
junc_by_word = {e["words_id"]: e["id"] for e in (junc.get("data") or [])}
|
||||
items = [
|
||||
{
|
||||
"id": junc_by_word.get(w["id"], ""),
|
||||
"word_id": w["id"],
|
||||
"title_de": w["title_de"],
|
||||
"level": w.get("level") or 50,
|
||||
"status": w.get("status", ""),
|
||||
}
|
||||
for w in (w_data.get("data") or [])
|
||||
]
|
||||
items = []
|
||||
for entry in ((data.get("data") or {}).get("linked_words") or []):
|
||||
word = entry.get("words_id") or {}
|
||||
if not isinstance(word, dict) or not word.get("id"):
|
||||
continue
|
||||
if word.get("status") == "archived":
|
||||
continue
|
||||
items.append({
|
||||
"id": entry.get("id", ""),
|
||||
"word_id": word["id"],
|
||||
"title_de": word.get("title_de", ""),
|
||||
"level": word.get("level") or 50,
|
||||
"status": word.get("status", ""),
|
||||
})
|
||||
return jsonify({"data": items})
|
||||
|
||||
else: # POST
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
words = body.get("words", [])
|
||||
_ensure_junction("words_pictures", "words_id", "pictures_id", token)
|
||||
words_to_save = body.get("words", [])
|
||||
|
||||
# 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
|
||||
for entry in words:
|
||||
for entry in words_to_save:
|
||||
title_de = (entry.get("title_de") or "").strip()
|
||||
level = int(entry.get("level") or 50)
|
||||
if not title_de:
|
||||
continue
|
||||
try:
|
||||
wid, _ = _find_or_create_word(title_de, level, token)
|
||||
_ensure_link(
|
||||
"words_pictures",
|
||||
{"words_id": wid, "pictures_id": pic_id},
|
||||
{"words_id": wid, "pictures_id": pic_id},
|
||||
token,
|
||||
)
|
||||
saved += 1
|
||||
wid, is_new = _find_or_create_word(title_de, level, token)
|
||||
if not is_new:
|
||||
_directus("PATCH", f"/items/words/{wid}", token, {"level": level})
|
||||
if wid not in existing_ids:
|
||||
new_links.append({"words_id": wid})
|
||||
existing_ids.add(wid)
|
||||
saved += 1
|
||||
except Exception as 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})
|
||||
|
||||
|
||||
@@ -1088,14 +1145,6 @@ def generate_questions(obj_id: str):
|
||||
if not levels:
|
||||
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 = {
|
||||
"words_created": 0,
|
||||
"words_linked": 0,
|
||||
@@ -1116,6 +1165,18 @@ def generate_questions(obj_id: str):
|
||||
# Mehrwortige Einträge → überspringen (KI-Fehler)
|
||||
return tokens
|
||||
|
||||
# Bestehende Verlinkungen vorab laden – ein einziger GET auf das Objekt
|
||||
obj_links_data, _ = _directus(
|
||||
"GET",
|
||||
f"/items/objects/{obj_id}"
|
||||
f"?fields[]=linked_words.words_id"
|
||||
f"&fields[]=linked_questions.questions_id",
|
||||
token,
|
||||
)
|
||||
obj_data = obj_links_data.get("data") or {}
|
||||
existing_word_ids: set[str] = {lw["words_id"] for lw in (obj_data.get("linked_words") or [])}
|
||||
existing_question_ids: set[str] = {lq["questions_id"] for lq in (obj_data.get("linked_questions") or [])}
|
||||
|
||||
# Alle eindeutigen Wörter aus allen Leveln vorab sammeln und einmalig laden
|
||||
all_words_by_level: dict[str, int] = {} # title_de → first level seen
|
||||
for lvl in levels:
|
||||
@@ -1128,20 +1189,25 @@ def generate_questions(obj_id: str):
|
||||
|
||||
# Wörter einmalig anlegen / finden (globaler Cache über alle Level)
|
||||
global_word_map: dict[str, str] = {} # title_de → id
|
||||
new_word_links: list[dict] = []
|
||||
for w, lvl_num in all_words_by_level.items():
|
||||
try:
|
||||
wid, is_new = _find_or_create_word(w, lvl_num, token)
|
||||
global_word_map[w] = wid
|
||||
_ensure_link(
|
||||
"words_objects",
|
||||
{"words_id": wid, "objects_id": obj_id},
|
||||
{"words_id": wid, "objects_id": obj_id},
|
||||
token,
|
||||
)
|
||||
if wid not in existing_word_ids:
|
||||
new_word_links.append({"words_id": wid})
|
||||
existing_word_ids.add(wid)
|
||||
stats["words_created" if is_new else "words_linked"] += 1
|
||||
except Exception as e:
|
||||
print(f"[generate_questions] word error '{w}': {e}")
|
||||
|
||||
# Alle neuen Wort-Verlinkungen in einem Batch via natives M2M
|
||||
if new_word_links:
|
||||
_directus("PATCH", f"/items/objects/{obj_id}", token,
|
||||
{"linked_words": {"create": new_word_links}})
|
||||
|
||||
new_question_links: list[dict] = []
|
||||
|
||||
for lvl in levels:
|
||||
level = int(lvl.get("level") or 1)
|
||||
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
|
||||
|
||||
# Frage ↔ Objekt
|
||||
_ensure_link(
|
||||
"questions_objects",
|
||||
{"questions_id": q_id, "objects_id": obj_id},
|
||||
{"questions_id": q_id, "objects_id": obj_id},
|
||||
token,
|
||||
)
|
||||
# Frage ↔ Objekt (für Batch am Ende sammeln)
|
||||
if q_id not in existing_question_ids:
|
||||
new_question_links.append({"questions_id": q_id})
|
||||
existing_question_ids.add(q_id)
|
||||
|
||||
# related_words
|
||||
for w in words_list:
|
||||
if w in global_word_map:
|
||||
_ensure_link(
|
||||
"questions_words",
|
||||
{"questions_id": q_id, "words_id": global_word_map[w]},
|
||||
{"questions_id": q_id, "words_id": global_word_map[w]},
|
||||
token,
|
||||
)
|
||||
# related_words + distractor_words nur für neue Fragen (batch, natives M2M)
|
||||
if q_is_new:
|
||||
q_patch: dict = {}
|
||||
rw = [{"words_id": global_word_map[w]} for w in words_list if w in global_word_map]
|
||||
if rw:
|
||||
q_patch["related_words"] = {"create": rw}
|
||||
dw = [{"words_id": global_word_map[w]} for w in distractor_list if w in global_word_map]
|
||||
if dw:
|
||||
q_patch["distractor_words"] = {"create": dw}
|
||||
if q_patch:
|
||||
_directus("PATCH", f"/items/questions/{q_id}", token, q_patch)
|
||||
|
||||
# distractor_words
|
||||
for w in distractor_list:
|
||||
if w in global_word_map:
|
||||
_ensure_link(
|
||||
"questions_distractor_words",
|
||||
{"questions_id": q_id, "words_id": global_word_map[w]},
|
||||
{"questions_id": q_id, "words_id": global_word_map[w]},
|
||||
token,
|
||||
)
|
||||
# Alle neuen Fragen-Verlinkungen in einem Batch via natives M2M
|
||||
if new_question_links:
|
||||
_directus("PATCH", f"/items/objects/{obj_id}", token,
|
||||
{"linked_questions": {"create": new_question_links}})
|
||||
|
||||
print(f"[generate_questions] obj={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})
|
||||
|
||||
|
||||
@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__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function DrawIt() {
|
||||
|
||||
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const [debouncedIndex, setDebouncedIndex] = useState(-1)
|
||||
const [objects, setObjects] = useState<DirectusObject[]>([])
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null)
|
||||
const [currentSelections, setCurrentSelections] = useState<Selection[]>([])
|
||||
@@ -52,6 +53,12 @@ export default function DrawIt() {
|
||||
|
||||
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(() => {
|
||||
if (safeWordInputVisible) safeWordInputRef.current?.focus()
|
||||
}, [safeWordInputVisible])
|
||||
@@ -83,8 +90,9 @@ export default function DrawIt() {
|
||||
}
|
||||
}
|
||||
|
||||
// currentPicture folgt dem debouncedIndex → lädt erst wenn Navigation pausiert
|
||||
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
|
||||
const canvasObjects: CanvasObject[] = objects.map((obj, i) => ({
|
||||
|
||||
Reference in New Issue
Block a user