feat: auto create pairs via Claude Haiku AI
Adds "✨ Auto Pairs erstellen" button to the PairsPanel. On click, sends the image and all object coordinates to Claude Haiku, which returns 30 structured pairs (10× text, 10× yes_no, 10× question, each split easy/medium difficulty) as JSON. Pairs are then created and linked via the API with a live progress indicator. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
32
CLAUDE.md
Normal file
32
CLAUDE.md
Normal file
@@ -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"}`.
|
||||||
@@ -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 (
|
||||||
|
<div className="w-full py-2 px-3 text-xs text-violet-700 bg-violet-50 border border-violet-200 rounded-lg flex items-center gap-2">
|
||||||
|
<svg className="animate-spin h-3 w-3 text-violet-500 shrink-0" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||||
|
</svg>
|
||||||
|
{progress.total === 0
|
||||||
|
? 'KI analysiert Bild…'
|
||||||
|
: `Erstelle Pairs… ${progress.done}/${progress.total}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'done') {
|
||||||
|
return (
|
||||||
|
<div className="w-full py-2 px-3 text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg font-medium text-center">
|
||||||
|
✓ {progress.total} Pairs automatisch erstellt
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="w-full py-2 px-3 text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
Fehler: {errorMsg}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setState('idle')}
|
||||||
|
className="w-full py-1.5 text-xs text-slate-500 underline hover:text-slate-700">
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handleAutoCreate}
|
||||||
|
className="w-full py-1.5 text-xs font-medium rounded-lg bg-violet-600 hover:bg-violet-700 text-white transition-colors flex items-center justify-center gap-1.5">
|
||||||
|
✨ Auto Pairs erstellen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Right panel: Pairs ───────────────────────────────────────────────────────
|
// ─── 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);
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
|
||||||
if (!selectedObject) {
|
if (!selectedObject) {
|
||||||
@@ -890,7 +1047,13 @@ function PairsPanel({ selectedObject, allObjects, objectPairs, loadingPairs, onP
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="p-3 border-b border-slate-100">
|
<div className="p-3 border-b border-slate-100 space-y-2">
|
||||||
|
<AutoCreateButton
|
||||||
|
currentPicture={currentPicture}
|
||||||
|
allObjects={allObjects}
|
||||||
|
selectedObject={selectedObject}
|
||||||
|
onPairSaved={onPairSaved}
|
||||||
|
/>
|
||||||
<PairForm objectId={selectedObject.id} allObjects={allObjects} onPairSaved={pair => onPairSaved(pair)} />
|
<PairForm objectId={selectedObject.id} allObjects={allObjects} onPairSaved={pair => onPairSaved(pair)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
@@ -1284,6 +1447,7 @@ export default function ContentCreation() {
|
|||||||
<PairsPanel
|
<PairsPanel
|
||||||
selectedObject={selectedObjectWithIndex}
|
selectedObject={selectedObjectWithIndex}
|
||||||
allObjects={objects}
|
allObjects={objects}
|
||||||
|
currentPicture={currentPicture}
|
||||||
objectPairs={objectPairs}
|
objectPairs={objectPairs}
|
||||||
loadingPairs={loadingPairs}
|
loadingPairs={loadingPairs}
|
||||||
onPairSaved={pair => setObjectPairs(prev => [pair, ...prev])}
|
onPairSaved={pair => setObjectPairs(prev => [pair, ...prev])}
|
||||||
|
|||||||
Reference in New Issue
Block a user