feat: Objekt-Zuweisung + Audio-Nachholen in der Veröffentlichen-Ansicht

- Pro Pair: erkannte Objekt-Wörter als 🔗-Chips, ein Klick weist das Wort
  in allen Sprachen dem Objekt zu ({{wort.o:objectId}})
- '🔊 Fehlende Audios generieren' bei unvollständigen Pairs
- Bundle-Refresh nach Zuweisung/Audio-Fill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:48:13 +02:00
parent 86145941eb
commit ae25dc9428

View File

@@ -75,8 +75,11 @@ function ProgressCard({ pic, onRetry }) {
// ─── Pair-Zeile (flach, 3 Sprachspalten, Flag-Toggle) ────────────────────────
function PairRow({ pair, flagged, onToggleFlag }) {
const FIELD_LABELS = { sentence: 'Frage', positive_sentence: 'Positiv', negative_sentence: 'Negativ' };
function PairRow({ pair, flagged, onToggleFlag, objIndexById, onAssign, assigning }) {
const rows = buildRows(pair.content);
const candidates = pair.candidates || [];
return (
<div className={`rounded-lg border p-2.5 transition-colors ${
flagged ? 'border-red-300 bg-red-50/60 opacity-70' : 'border-slate-200 bg-white'}`}>
@@ -133,18 +136,37 @@ function PairRow({ pair, flagged, onToggleFlag }) {
🚩
</button>
</div>
{/* Objekt-Zuweisung: erkannte Objekt-Wörter im Satz, ein Klick pro Fund */}
{!flagged && candidates.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-slate-100">
{candidates.map((c, i) => {
const key = `${c.source_id}|${c.source_field}|${c.word_id}|${c.object_id}`;
const objNo = (objIndexById[c.object_id] ?? 0) + 1;
return (
<button key={i} onClick={() => onAssign(c)} disabled={assigning === key}
title={`${c.label}" im ${FIELD_LABELS[c.source_field] || c.source_field}-Satz mit Objekt #${objNo} verknüpfen (alle Sprachen)`}
className="text-[11px] px-2 py-0.5 rounded-full border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 disabled:opacity-50 transition-colors">
{assigning === key ? '…' : `🔗 „${c.label}" → Objekt #${objNo} · ${FIELD_LABELS[c.source_field] || ''}`}
</button>
);
})}
</div>
)}
</div>
);
}
// ─── Bereit-Karte: ganzes Bild-Bundle ─────────────────────────────────────────
function ReadyCard({ bundle, onPublished }) {
function ReadyCard({ bundle, onPublished, onRefresh }) {
const [excluded, setExcluded] = useState(() => new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [notReady, setNotReady] = useState(null);
const [done, setDone] = useState(null);
const [assigning, setAssigning] = useState(null);
const [audioFilling, setAudioFilling] = useState(false);
if (!bundle) {
return (
@@ -170,6 +192,33 @@ function ReadyCard({ bundle, onPublished }) {
});
}
const objIndexById = Object.fromEntries(bundle.objects.map((o, i) => [o.id, i]));
async function assign(c) {
const key = `${c.source_id}|${c.source_field}|${c.word_id}|${c.object_id}`;
setAssigning(key); setError(null);
try {
const res = await apiPost('/pipeline/assign-object', {
source_table: c.source_table, source_id: c.source_id, source_field: c.source_field,
object_id: c.object_id, word_id: c.word_id,
});
if (res.skipped_langs?.length)
setError(`Zugewiesen, aber in ${res.skipped_langs.join(', ')} nicht gefunden (abweichende Wortform) — ggf. dort manuell prüfen.`);
await onRefresh?.();
} catch (e) { setError(e.payload?.error || e.message); }
finally { setAssigning(null); }
}
async function fillAudio() {
setAudioFilling(true); setError(null);
try {
const res = await apiPost(`/pipeline/picture/${bundle.picture.id}/audio-fill`, {});
if (res.failed) setError(`${res.generated} Audios erzeugt, ${res.failed} fehlgeschlagen: ${res.errors?.[0]?.error || ''}`);
await onRefresh?.();
} catch (e) { setError(e.payload?.error || e.message); }
finally { setAudioFilling(false); }
}
async function publish() {
setBusy(true); setError(null); setNotReady(null);
try {
@@ -212,8 +261,14 @@ function ReadyCard({ bundle, onPublished }) {
{bundle.objects.length} Objekt{bundle.objects.length !== 1 ? 'e' : ''} · {allPairs.length} Pairs · {wordCount} Wörter
</div>
{incompletePairs.length > 0 && (
<div className="text-xs text-amber-600 mt-1">
{incompletePairs.length} Pair{incompletePairs.length !== 1 ? 's' : ''} unvollständig (Text/Audio) flaggen oder erst vervollständigen
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-amber-600">
{incompletePairs.length} Pair{incompletePairs.length !== 1 ? 's' : ''} unvollständig (Text/Audio)
</span>
<button onClick={fillAudio} disabled={audioFilling}
className="text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:opacity-50">
{audioFilling ? 'Generiere…' : '🔊 Fehlende Audios generieren'}
</button>
</div>
)}
</div>
@@ -230,7 +285,8 @@ function ReadyCard({ bundle, onPublished }) {
</div>
<div className="space-y-1.5">
{obj.pairs.map(p => (
<PairRow key={p.id} pair={p} flagged={excluded.has(p.id)} onToggleFlag={() => toggleFlag(p.id)} />
<PairRow key={p.id} pair={p} flagged={excluded.has(p.id)} onToggleFlag={() => toggleFlag(p.id)}
objIndexById={objIndexById} onAssign={assign} assigning={assigning} />
))}
{obj.pairs.length === 0 && <div className="text-xs text-slate-400 italic">Keine Pairs</div>}
</div>
@@ -300,6 +356,11 @@ export default function Publish() {
catch (e) { setError(e.payload?.error || e.message); }
}
async function refreshBundle(id) {
const b = await apiFetch(`/pipeline/picture/${id}/bundle`);
setBundles(prev => ({ ...prev, [id]: b }));
}
const rows = overview || [];
const inFlight = rows.filter(p => ['queued', 'running'].includes(p.pipeline_status));
const failed = rows.filter(p => p.pipeline_status === 'failed');
@@ -334,7 +395,8 @@ export default function Publish() {
<ProgressCard key={pic.id} pic={pic} onRetry={retry} />
))}
{ready.map(pic => (
<ReadyCard key={pic.id} bundle={bundles[pic.id]} onPublished={() => load()} />
<ReadyCard key={pic.id} bundle={bundles[pic.id]} onPublished={() => load()}
onRefresh={() => refreshBundle(pic.id)} />
))}
</div>
)}