diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e61ddb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# snakkimo-CMT — Development Rules + +## Stack +- React + Vite + Tailwind v4 +- API: snakkimo-API (Express/Node.js, PostgreSQL) at the URL in `src/lib/api.js` +- Deployed at cmt.snakkimo.com via Coolify (UUID: `wa3esdrwh7ib5i7vfhwrknb4`) + +## Key Rules + +### Pairs müssen immer mit object_pairs verknüpft sein +Jedes neu erstellte Pair **muss** direkt nach dem `POST /pairs` auch in `object_pairs` eingetragen werden: +```js +const pair = await apiPost('/pairs', { ... }); +await apiLink(`/objects/${objectId}/pairs/${pair.id}`); +``` +`object_pairs` ist die definitive Verknüpfung zwischen einem Pair und dem Bild/Objekt das angezeigt werden soll. Ohne diesen Eintrag ist das Pair für die App unsichtbar. + +### Placeholder-Format +Sätze in questions/statements speichern Wörter und Objekte als `{{uuid}}`: +- `{{wordId}}` — Verweis auf ein Wort aus der `words` Tabelle +- `{{objectId}}` — Verweis auf ein Objekt aus der `objects` Tabelle (wird mit `withPlaceholders()` gesetzt wenn der User ein Objekt zuweist) + +### answer_type Werte +Pairs haben einen einzigen `answer_type` TEXT: +- `yes_no` — Frage + Ja/Nein Antwort +- `text` — nur positives Statement +- `question` — Frage + Positiv + Negativ +- `word` — Frage + positive/negative Wörter (über `statement_positive_words` / `statement_negative_words`) + +## Deployment +Push auf `main` → Coolify baut automatisch. +Manueller Deploy via MCP: `mcp__coolify__deployments` mit `operation: deploy`, `query: {"uuid":"wa3esdrwh7ib5i7vfhwrknb4"}`. diff --git a/src/pages/ContentCreation.jsx b/src/pages/ContentCreation.jsx index 56b67a3..13b9240 100644 --- a/src/pages/ContentCreation.jsx +++ b/src/pages/ContentCreation.jsx @@ -867,9 +867,166 @@ function ObjectAddPanel({ currentPolygon, savedSelections, objectWords, onAddObj ); } +// ─── Auto Create Pairs ─────────────────────────────────────────────────────── + +async function callClaudeForPairs(picture, allObjects, selectedObjectId) { + const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY; + if (!apiKey) throw new Error('VITE_ANTHROPIC_API_KEY nicht gesetzt'); + if (!picture?.picture_link) throw new Error('Dieses Bild hat keinen Link für die KI-Analyse'); + + const objectsDesc = allObjects.map((obj, i) => { + const words = (obj._words || []).map(w => w.titel_de || w.titel_en).filter(Boolean).join(', '); + const isSelected = obj.id === selectedObjectId; + let posStr = ''; + if (obj.selections?.[0]?.points?.length) { + const pts = obj.selections[0].points; + const cx = (pts.reduce((s, p) => s + p.x, 0) / pts.length * 100).toFixed(0); + const cy = (pts.reduce((s, p) => s + p.y, 0) / pts.length * 100).toFixed(0); + posStr = ` (ca. ${cx}% von links, ${cy}% von oben)`; + } + return `- Objekt ${i + 1}${isSelected ? ' [DIESES OBJEKT]' : ''}: ${words || '(unbenannt)'}${posStr}`; + }).join('\n'); + + const userPrompt = `Analysiere das Bild. Folgende Objekte sind markiert:\n${objectsDesc}\n\nErstelle Sprachlernmaterial für das Objekt [DIESES OBJEKT] auf Deutsch — natürliche Sätze wie in einem echten Gespräch.\n\nAntworte NUR mit gültigem JSON ohne Markdown:\n{"pairs":[...]}\n\nJedes Pair braucht: "type" und "difficulty". Feldregeln:\n- type "text": {"type":"text","difficulty":"...","positive":"Aussage."}\n- type "yes_no": {"type":"yes_no","difficulty":"...","question":"Frage?","answer":true}\n- type "question": {"type":"question","difficulty":"...","question":"Frage?","positive":"Positive Aussage.","negative":"Negative Aussage."}\n\nErstelle genau 30 Pairs:\n- 10 × type "text": 5 × difficulty "easy" (max 8 Wörter, für Kinder), 5 × "medium" (8–15 Wörter, für Teenager)\n- 10 × type "yes_no": 5 × "easy", 5 × "medium" — mix aus answer:true und answer:false\n- 10 × type "question": 5 × "easy", 5 × "medium"\n\nRegeln: Alle Sätze auf Deutsch. Sätze müssen natürlich klingen. Keine Wiederholungen.`; + + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-request-browser': 'true', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 8000, + system: 'Du bist ein Deutsch-Sprachlernassistent. Antworte AUSSCHLIESSLICH mit gültigem JSON, ohne Markdown-Codeblöcke, ohne Erklärungen.', + messages: [{ role: 'user', content: [ + { type: 'image', source: { type: 'url', url: picture.picture_link } }, + { type: 'text', text: userPrompt }, + ]}], + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error?.message || `Claude API Fehler ${res.status}`); + } + + const data = await res.json(); + let rawText = data.content[0].text.trim(); + const mdMatch = rawText.match(/```(?:json)?\s*([\s\S]+?)\s*```/); + if (mdMatch) rawText = mdMatch[1]; + const parsed = JSON.parse(rawText); + if (!Array.isArray(parsed.pairs)) throw new Error('Ungültiges JSON-Format: "pairs" fehlt'); + return parsed.pairs; +} + +function AutoCreateButton({ currentPicture, allObjects, selectedObject, onPairSaved }) { + const lang = getUserLang(); + const [state, setState] = useState('idle'); // idle | loading | done | error + const [progress, setProgress] = useState({ done: 0, total: 0 }); + const [errorMsg, setErrorMsg] = useState(''); + + async function handleAutoCreate() { + setState('loading'); + setProgress({ done: 0, total: 0 }); + setErrorMsg(''); + try { + const pairs = await callClaudeForPairs(currentPicture, allObjects, selectedObject.id); + setProgress({ done: 0, total: pairs.length }); + + for (let i = 0; i < pairs.length; i++) { + const p = pairs[i]; + + let questionId = null; + if (p.type !== 'text' && p.question?.trim()) { + const q = await apiPost('/questions', { [`sentence_${lang}`]: p.question.trim(), status: 'draft' }); + questionId = q.id; + } + + let posStmtId = null; + if ((p.type === 'text' || p.type === 'question') && p.positive?.trim()) { + const s = await apiPost('/statements', { status: 'draft', [`positive_sentence_${lang}`]: p.positive.trim() }); + posStmtId = s.id; + } else if (p.type === 'yes_no') { + const s = await apiPost('/statements', { status: 'draft', answer: p.answer ?? null }); + posStmtId = s.id; + } + + let negStmtId = null; + if (p.type === 'question' && p.negative?.trim()) { + const s = await apiPost('/statements', { status: 'draft', [`negative_sentence_${lang}`]: p.negative.trim() }); + negStmtId = s.id; + } + + const created = await apiPost('/pairs', { + answer_type: p.type, + question_id: questionId, + positive_statement_id: posStmtId, + negative_statement_id: negStmtId, + status: 'draft', + }); + await apiLink(`/objects/${selectedObject.id}/pairs/${created.id}`); + onPairSaved(created); + setProgress({ done: i + 1, total: pairs.length }); + } + + setState('done'); + setTimeout(() => setState('idle'), 4000); + } catch (e) { + setErrorMsg(e.message); + setState('error'); + } + } + + if (state === 'loading') { + return ( +
+ + + + + {progress.total === 0 + ? 'KI analysiert Bild…' + : `Erstelle Pairs… ${progress.done}/${progress.total}`} +
+ ); + } + + if (state === 'done') { + return ( +
+ ✓ {progress.total} Pairs automatisch erstellt +
+ ); + } + + if (state === 'error') { + return ( +
+
+ Fehler: {errorMsg} +
+ +
+ ); + } + + return ( + + ); +} + // ─── Right panel: Pairs ─────────────────────────────────────────────────────── -function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onPairSaved, onPairsReload }) { +function PairsPanel({ selectedObject, allObjects, currentPicture, objectPairs, loadingPairs, onPairSaved, onPairsReload }) { const [editingId, setEditingId] = useState(null); if (!selectedObject) { @@ -890,7 +1047,13 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
-
+
+ onPairSaved(pair)} />
@@ -1284,6 +1447,7 @@ export default function ContentCreation() { setObjectPairs(prev => [pair, ...prev])}