Generate it / Publish it: Claude Haiku integration + Generate page redesign
- GenerateIt page: objects sidebar, readOnly canvas, collapsible prompt bar - Generate it: calls Claude Haiku, saves questions/words to Directus as draft - Publish it: promotes draft questions/words to published - Deduplication: links existing words/questions instead of duplicating - GenerateObjectsList: tree view with user_notes labels - DrawCanvas: readOnly prop to disable mouse interaction - api.ts: generateQuestions + publishQuestions endpoints - requirements.txt: anthropic==0.40.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
295
app.py
295
app.py
@@ -2,13 +2,18 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
|
||||
from flask import Flask, send_from_directory, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from PIL import Image
|
||||
import ollama
|
||||
import anthropic as _anthropic_sdk
|
||||
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
|
||||
DIRECTUS_URL = "https://db.hejyou.com"
|
||||
|
||||
@@ -878,8 +883,294 @@ def crop_image():
|
||||
)
|
||||
|
||||
|
||||
# ── Claude / Directus Helpers ─────────────────────────────────────────────────
|
||||
|
||||
def _ensure_junction(collection: str, field1: str, field2: str, token: str):
|
||||
"""Create a simple M2M junction collection in Directus if it doesn't exist."""
|
||||
_, status = _directus("GET", f"/collections/{collection}", token)
|
||||
if status == 200:
|
||||
return
|
||||
body = {
|
||||
"collection": collection,
|
||||
"schema": {},
|
||||
"meta": {"hidden": True, "icon": "import_export"},
|
||||
"fields": [
|
||||
{
|
||||
"field": "id",
|
||||
"type": "integer",
|
||||
"schema": {"is_primary_key": True, "has_auto_increment": True},
|
||||
"meta": {"hidden": True},
|
||||
},
|
||||
{"field": field1, "type": "uuid", "schema": {}, "meta": {"hidden": True}},
|
||||
{"field": field2, "type": "uuid", "schema": {}, "meta": {"hidden": True}},
|
||||
],
|
||||
}
|
||||
_directus("POST", "/collections", token, body)
|
||||
|
||||
|
||||
def _ensure_link(collection: str, match: dict, payload: dict, token: str):
|
||||
"""Insert a junction row only if it doesn't already exist."""
|
||||
qs = "&".join(
|
||||
f"filter[{k}][_eq]={urllib.parse.quote(str(v), safe='')}"
|
||||
for k, v in match.items()
|
||||
)
|
||||
data, status = _directus("GET", f"/items/{collection}?{qs}&limit=1", token)
|
||||
if status == 200 and data.get("data"):
|
||||
return # already linked
|
||||
_directus("POST", f"/items/{collection}", token, payload)
|
||||
|
||||
|
||||
def _find_or_create_word(title_de: str, level: int, token: str):
|
||||
"""Return (word_id, is_new). Creates word with status=draft if missing."""
|
||||
enc = urllib.parse.quote(title_de, safe="")
|
||||
data, status = _directus(
|
||||
"GET", f"/items/words?filter[title_de][_eq]={enc}&fields=id&limit=1", token
|
||||
)
|
||||
if status == 200 and data.get("data"):
|
||||
return data["data"][0]["id"], False
|
||||
|
||||
body = {"status": "draft", "title_de": title_de, "level": level}
|
||||
data, status = _directus("POST", "/items/words", token, body)
|
||||
if status in (200, 201):
|
||||
return data["data"]["id"], True
|
||||
raise RuntimeError(f"Word creation failed ({status}): {data}")
|
||||
|
||||
|
||||
def _find_or_create_question(question_de: str, answer_de: str, level: int,
|
||||
short_answer_id, obj_id: str, token: str):
|
||||
"""Return (question_id, is_new). Creates question with status=draft if missing."""
|
||||
enc = urllib.parse.quote(question_de, safe="")
|
||||
data, status = _directus(
|
||||
"GET",
|
||||
f"/items/questions?filter[question_de][_eq]={enc}&fields=id&limit=1",
|
||||
token,
|
||||
)
|
||||
if status == 200 and data.get("data"):
|
||||
return data["data"][0]["id"], False
|
||||
|
||||
body = {
|
||||
"status": "draft",
|
||||
"question_de": question_de,
|
||||
"answer_de": answer_de,
|
||||
"level": level,
|
||||
"object": obj_id,
|
||||
}
|
||||
if short_answer_id:
|
||||
body["short_answer"] = short_answer_id
|
||||
|
||||
data, status = _directus("POST", "/items/questions", token, body)
|
||||
if status in (200, 201):
|
||||
return data["data"]["id"], True
|
||||
raise RuntimeError(f"Question creation failed ({status}): {data}")
|
||||
|
||||
|
||||
# ── Generate & Publish endpoints ──────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/object/<obj_id>/generate_questions", methods=["POST"])
|
||||
def generate_questions(obj_id: str):
|
||||
"""
|
||||
1. Holt Objekt + Elternobjekt aus Directus
|
||||
2. Füllt Prompt-Platzhalter
|
||||
3. Ruft Claude Haiku auf
|
||||
4. Speichert Wörter + Fragen als Entwurf in Directus
|
||||
"""
|
||||
token = request.headers.get("Authorization", "")
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
prompt_template = (body.get("prompt") or "").strip()
|
||||
|
||||
if not prompt_template:
|
||||
return jsonify({"error": "Missing prompt"}), 400
|
||||
if not ANTHROPIC_API_KEY:
|
||||
return jsonify({"error": "ANTHROPIC_API_KEY not configured on server"}), 500
|
||||
|
||||
# Objekt laden
|
||||
obj_resp, s = _directus("GET", f"/items/objects/{obj_id}?fields=id,user_notes,parent", token)
|
||||
if s != 200:
|
||||
return jsonify({"error": "Object not found"}), 404
|
||||
obj = obj_resp.get("data") or {}
|
||||
|
||||
# Elternobjekt laden
|
||||
parent_notes = ""
|
||||
if obj.get("parent"):
|
||||
p_resp, _ = _directus("GET", f"/items/objects/{obj['parent']}?fields=id,user_notes", token)
|
||||
parent_notes = ((p_resp.get("data") or {}).get("user_notes") or "")
|
||||
|
||||
# Platzhalter ersetzen
|
||||
prompt = (
|
||||
prompt_template
|
||||
.replace("{user-notes_object}", obj.get("user_notes") or "")
|
||||
.replace("{user-notes_parentobject}", parent_notes)
|
||||
)
|
||||
|
||||
# Claude Haiku aufrufen
|
||||
try:
|
||||
client = _anthropic_sdk.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
msg = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=4096,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
raw = msg.content[0].text
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Claude API error: {e}"}), 500
|
||||
|
||||
# JSON parsen
|
||||
try:
|
||||
ai = json.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
start = raw.index("{")
|
||||
end = raw.rindex("}") + 1
|
||||
ai = json.loads(raw[start:end])
|
||||
except Exception:
|
||||
return jsonify({"error": "Invalid JSON from AI", "raw": raw[:400]}), 500
|
||||
|
||||
levels = ai.get("levels", [])
|
||||
if not levels:
|
||||
return jsonify({"error": "No levels in AI response"}), 500
|
||||
|
||||
# Junction-Collections sicherstellen
|
||||
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,
|
||||
"questions_created": 0,
|
||||
"questions_linked": 0,
|
||||
}
|
||||
|
||||
for lvl in levels:
|
||||
level = int(lvl.get("level") or 1)
|
||||
q_de = (lvl.get("question") or "").strip()
|
||||
a_de = (lvl.get("answer") or "").strip()
|
||||
short_text = (lvl.get("short_answer") or "").strip()
|
||||
words_list = [str(w).strip() for w in lvl.get("words", []) if str(w).strip()]
|
||||
distractor_list = [str(w).strip() for w in lvl.get("distractor_words", []) if str(w).strip()]
|
||||
|
||||
if not q_de:
|
||||
continue
|
||||
|
||||
# Kurzantwort-Wort
|
||||
short_answer_id = None
|
||||
if short_text:
|
||||
try:
|
||||
wid, is_new = _find_or_create_word(short_text, level, token)
|
||||
short_answer_id = wid
|
||||
_ensure_link(
|
||||
"words_objects",
|
||||
{"words_id": wid, "objects_id": obj_id},
|
||||
{"words_id": wid, "objects_id": obj_id},
|
||||
token,
|
||||
)
|
||||
stats["words_created" if is_new else "words_linked"] += 1
|
||||
except Exception as e:
|
||||
print(f"[generate_questions] short_answer error: {e}")
|
||||
|
||||
# Alle einzigartigen Wörter verarbeiten
|
||||
all_unique = list(dict.fromkeys(words_list + distractor_list))
|
||||
word_map: dict[str, str] = {} # title_de → id
|
||||
|
||||
for w in all_unique:
|
||||
try:
|
||||
wid, is_new = _find_or_create_word(w, level, token)
|
||||
word_map[w] = wid
|
||||
_ensure_link(
|
||||
"words_objects",
|
||||
{"words_id": wid, "objects_id": obj_id},
|
||||
{"words_id": wid, "objects_id": obj_id},
|
||||
token,
|
||||
)
|
||||
stats["words_created" if is_new else "words_linked"] += 1
|
||||
except Exception as e:
|
||||
print(f"[generate_questions] word error '{w}': {e}")
|
||||
|
||||
# Frage anlegen / verknüpfen
|
||||
try:
|
||||
q_id, q_is_new = _find_or_create_question(q_de, a_de, level, short_answer_id, obj_id, token)
|
||||
except Exception as e:
|
||||
print(f"[generate_questions] question error level {level}: {e}")
|
||||
continue
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# related_words (bestehende Junction questions_words)
|
||||
for w in words_list:
|
||||
if w in word_map:
|
||||
_ensure_link(
|
||||
"questions_words",
|
||||
{"questions_id": q_id, "words_id": word_map[w]},
|
||||
{"questions_id": q_id, "words_id": word_map[w]},
|
||||
token,
|
||||
)
|
||||
|
||||
# distractor_words
|
||||
for w in distractor_list:
|
||||
if w in word_map:
|
||||
_ensure_link(
|
||||
"questions_distractor_words",
|
||||
{"questions_id": q_id, "words_id": word_map[w]},
|
||||
{"questions_id": q_id, "words_id": word_map[w]},
|
||||
token,
|
||||
)
|
||||
|
||||
print(f"[generate_questions] obj={obj_id} stats={stats}")
|
||||
return jsonify({"ok": True, "object_id": obj_id, "stats": stats})
|
||||
|
||||
|
||||
@app.route("/api/object/<obj_id>/publish_questions", methods=["POST"])
|
||||
def publish_questions(obj_id: str):
|
||||
"""Ändert Status aller verknüpften Entwurfs-Fragen und -Wörter auf 'published'."""
|
||||
token = request.headers.get("Authorization", "")
|
||||
|
||||
# Verknüpfte Fragen
|
||||
q_resp, _ = _directus(
|
||||
"GET",
|
||||
f"/items/questions_objects?filter[objects_id][_eq]={obj_id}&fields=questions_id&limit=200",
|
||||
token,
|
||||
)
|
||||
q_ids = [e["questions_id"] for e in (q_resp.get("data") or [])]
|
||||
|
||||
# Verknüpfte Wörter
|
||||
w_resp, _ = _directus(
|
||||
"GET",
|
||||
f"/items/words_objects?filter[objects_id][_eq]={obj_id}&fields=words_id&limit=2000",
|
||||
token,
|
||||
)
|
||||
w_ids = [e["words_id"] for e in (w_resp.get("data") or [])]
|
||||
|
||||
published_q = 0
|
||||
published_w = 0
|
||||
|
||||
for q_id in q_ids:
|
||||
_, s = _directus("PATCH", f"/items/questions/{q_id}", token, {"status": "published"})
|
||||
if s == 200:
|
||||
published_q += 1
|
||||
|
||||
for w_id in w_ids:
|
||||
_, s = _directus("PATCH", f"/items/words/{w_id}", token, {"status": "published"})
|
||||
if s == 200:
|
||||
published_w += 1
|
||||
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"published_questions": published_q,
|
||||
"published_words": published_w,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Du kannst den Port hier anpassen, z.B. 8000 oder 5000.
|
||||
# Für externen Zugriff später einfach in Nginx/Reverse Proxy weiterleiten.
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user