diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9f5c599..fcebb8e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,28 @@ "Bash(docker build *)", "mcp__coolify__private-keys", "Bash(pkill -f \"python app.py\")", - "Bash(curl -s http://localhost:8000/)" + "Bash(curl -s http://localhost:8000/)", + "mcp__coolify__applications", + "Bash(ssh-keygen -t ed25519 -C \"coolify-content-mentor\" -f /tmp/coolify_content_mentor -N \"\" -q)", + "Bash(ssh-keygen -lf /tmp/coolify_content_mentor.pub)", + "Bash(python3 -c ' *)", + "Bash(ssh -i /tmp/coolify_content_mentor -o StrictHostKeyChecking=no -o ConnectTimeout=10 -T git@git.hyggecraftery.com)", + "Bash(ssh -i /tmp/coolify_content_mentor -o StrictHostKeyChecking=no -o ConnectTimeout=10 -vvv git@git.hyggecraftery.com)", + "Bash(nc -zv git.hyggecraftery.com 2222)", + "Bash(nc -zv git.hyggecraftery.com 3000)", + "mcp__coolify__deployments", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "mcp__directus__read-items", + "mcp__directus__system-prompt", + "Read(//Users/tim/.claude/**)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers',{}\\).get\\('directus',{}\\), indent=2\\)\\)\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); mcps=d.get\\('mcpServers',{}\\); print\\(json.dumps\\(mcps.get\\('directus', mcps.get\\('mcp__directus',{}\\)\\), indent=2\\)\\)\")", + "mcp__directus__read-fields", + "mcp__directus__create-field", + "mcp__directus__update-field", + "mcp__directus__read-collections" ] } } diff --git a/app.py b/app.py index 56c2d67..1b7dd5c 100644 --- a/app.py +++ b/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//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//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) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f9ae0cb..99f071e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -195,3 +195,38 @@ export async function generateSentence( if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Sentence') return data } + +export interface GenerateStats { + words_created: number + words_linked: number + questions_created: number + questions_linked: number +} + +export async function generateQuestions( + objId: string, + prompt: string, + token: string +): Promise<{ ok: boolean; stats: GenerateStats }> { + const res = await fetch(`/api/object/${encodeURIComponent(objId)}/generate_questions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ prompt }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Fehler bei Generate') + return data +} + +export async function publishQuestions( + objId: string, + token: string +): Promise<{ ok: boolean; published_questions: number; published_words: number }> { + const res = await fetch(`/api/object/${encodeURIComponent(objId)}/publish_questions`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Fehler bei Publish') + return data +} diff --git a/frontend/src/pages/GenerateIt.tsx b/frontend/src/pages/GenerateIt.tsx index 658b533..ae407ea 100644 --- a/frontend/src/pages/GenerateIt.tsx +++ b/frontend/src/pages/GenerateIt.tsx @@ -1,20 +1,19 @@ import { useState, useEffect, useRef } from 'react' import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas' import GenerateObjectsList from '../components/GenerateObjectsList' -import SentencesList from '../components/SentencesList' + import Topbar from '../components/Topbar' import { getDirectusPictures, directusAssetUrl, type DirectusPicture, getDirectusObjects, - generateDetails, - generateSentence, - getSentences, + generateQuestions, + publishQuestions, + type GenerateStats, } from '../api' import { useAuth } from '../context/AuthContext' import type { DirectusObject, CanvasObject } from '../types' -import type { Sentence } from '../types' const ChevronLeftIcon = () => ( @@ -28,15 +27,15 @@ const ChevronRightIcon = () => ( ) -const SparkleIcon = () => ( +const GenerateIcon = () => ( ) -const ChatIcon = () => ( +const PublishIcon = () => ( - + ) @@ -106,18 +105,6 @@ function persistLayouts(layouts: PromptLayout[]): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts)) } -// ── Word extraction ─────────────────────────────────────────────────────────── - -function extractWords(sentences: Sentence[]): string[] { - const allText = sentences - .flatMap(s => [s.question_simple_en, s.answer_simple_en, s.question_advanced_en, s.answer_advanced_en]) - .join(' ') - const words = allText - .split(/\s+/) - .map(w => w.replace(/[^a-zA-ZäöüÄÖÜßéèêàâùûîô'-]/g, '')) - .filter(w => w.length > 1) - return [...new Set(words)].sort((a, b) => a.localeCompare(b)) -} // ── Component ───────────────────────────────────────────────────────────────── @@ -129,9 +116,11 @@ export default function GenerateIt() { const [currentIndex, setCurrentIndex] = useState(-1) const [directusObjects, setDirectusObjects] = useState([]) const [selectedObjId, setSelectedObjId] = useState(null) - const [sentences, setSentences] = useState([]) - const [isGeneratingDetails, setIsGeneratingDetails] = useState(false) - const [isGeneratingSentence, setIsGeneratingSentence] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + const [isPublishing, setIsPublishing] = useState(false) + const [generateResult, setGenerateResult] = useState(null) + const [generateError, setGenerateError] = useState(null) + const [publishResult, setPublishResult] = useState<{ q: number; w: number } | null>(null) // Prompt layouts const [layouts, setLayouts] = useState(loadLayouts) @@ -144,8 +133,6 @@ export default function GenerateIt() { const currentPicture: DirectusPicture | null = currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null - const words = extractWords(sentences) - const canvasObjects: CanvasObject[] = directusObjects.map((obj, i) => ({ id: obj.id, visible: true, @@ -163,44 +150,46 @@ export default function GenerateIt() { useEffect(() => { if (!currentPicture || !token) { - setDirectusObjects([]); setSelectedObjId(null); setSentences([]) + setDirectusObjects([]); setSelectedObjId(null) + setGenerateResult(null); setPublishResult(null); setGenerateError(null) return } getDirectusObjects(currentPicture.id, token) .then(objs => { setDirectusObjects(objs) - if (objs.length > 0) { setSelectedObjId(objs[0].id); loadSentences(objs[0].id) } - else { setSelectedObjId(null); setSentences([]) } + if (objs.length > 0) setSelectedObjId(objs[0].id) + else setSelectedObjId(null) }) .catch(console.error) }, [currentPicture?.id, token]) - const loadSentences = async (objId: string) => { - try { setSentences(await getSentences(objId)) } catch (e) { console.error(e) } - } - - const handleGenerateDetails = async () => { - if (!selectedObjId) return - setIsGeneratingDetails(true) + const handleGenerate = async () => { + if (!selectedObjId || !token) return + setIsGenerating(true) + setGenerateResult(null) + setGenerateError(null) + setPublishResult(null) try { - await generateDetails(selectedObjId) + const res = await generateQuestions(selectedObjId, promptText, token) + setGenerateResult(res.stats) } catch (e) { - alert(e instanceof Error ? e.message : 'Fehler bei KI-Details') + setGenerateError(e instanceof Error ? e.message : 'Fehler beim Generieren') } finally { - setIsGeneratingDetails(false) + setIsGenerating(false) } } - const handleGenerateSentence = async () => { - if (!selectedObjId) return - setIsGeneratingSentence(true) + const handlePublish = async () => { + if (!selectedObjId || !token) return + setIsPublishing(true) + setPublishResult(null) try { - const data = await generateSentence(selectedObjId) - setSentences(prev => [...prev, data.sentence]) + const res = await publishQuestions(selectedObjId, token) + setPublishResult({ q: res.published_questions, w: res.published_words }) } catch (e) { - alert(e instanceof Error ? e.message : 'Fehler bei KI-Sentence') + setGenerateError(e instanceof Error ? e.message : 'Fehler beim Veröffentlichen') } finally { - setIsGeneratingSentence(false) + setIsPublishing(false) } } @@ -258,14 +247,40 @@ export default function GenerateIt() {
- - + + {generateResult && !isGenerating && ( + + ✓ {generateResult.questions_created}F +{generateResult.words_created}W neu + + )} + {publishResult && ( + + ↑ {publishResult.q}F {publishResult.w}W + + )} + {generateError && ( + + {generateError} + + )}
) @@ -284,7 +299,7 @@ export default function GenerateIt() { { setSelectedObjId(id); loadSentences(id) }} + onSelect={id => setSelectedObjId(id)} /> @@ -367,33 +382,26 @@ export default function GenerateIt() { - {/* Words */} - - - {/* Right: Sentences */} + {/* Right: Generate results */} diff --git a/requirements.txt b/requirements.txt index cf61398..615df28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ Flask-Cors==4.0.1 gunicorn==22.0.0 Pillow==11.0.0 ollama==0.3.0 +anthropic==0.40.0