fix: Übersetzungs-Retry + robuster Translate-Step + Nachhol-Endpoints
- callClaude: Retry mit Backoff bei Überlast/Rate-Limit/Netzfehler (429/500/503/529) — wahrscheinliche Ursache der fehlenden SV-Übersetzung - Translate-Step pro Pair gekapselt: ein Fehler reißt nicht mehr den ganzen Lauf ab, Fehlversuche werden gezählt (pipeline_progress.translateFailures) - translatePair als wiederverwendbarer Helfer extrahiert - POST /pipeline/picture/:id/translate-fill: fehlende Übersetzungen (Sätze + Antwort-Wörter) eines Bildes nachholen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -139,33 +139,19 @@ async function runPicture(pictureId) {
|
|||||||
} catch (err) { await setFailed(pictureId, 'pairs', err); return; }
|
} catch (err) { await setFailed(pictureId, 'pairs', err); return; }
|
||||||
|
|
||||||
// ── Step 2: Übersetzen (pro Pair, füllt nur fehlende Sprachen) ──────────────
|
// ── Step 2: Übersetzen (pro Pair, füllt nur fehlende Sprachen) ──────────────
|
||||||
|
// Pro Pair gekapselt: scheitert eine Übersetzung (z.B. transienter API-Fehler trotz
|
||||||
|
// Retry), verliert das nicht den ganzen Lauf — der Rest läuft weiter, Fehlende werden
|
||||||
|
// gezählt und können später über „Übersetzungen nachholen" ergänzt werden.
|
||||||
const pairs = await loadPairs(pictureId);
|
const pairs = await loadPairs(pictureId);
|
||||||
progress.pairsTotal = pairs.length;
|
progress.pairsTotal = pairs.length;
|
||||||
try {
|
progress.translateFailures = 0;
|
||||||
|
await setStep(pictureId, 'translate', progress);
|
||||||
|
for (const p of pairs) {
|
||||||
|
try { await translatePair(p); }
|
||||||
|
catch (err) { progress.translateFailures++; console.error(`Translate-Fehler bei Pair ${p.id}:`, err.message); }
|
||||||
|
progress.translatedPairs++;
|
||||||
await setStep(pictureId, 'translate', progress);
|
await setStep(pictureId, 'translate', progress);
|
||||||
for (const p of pairs) {
|
}
|
||||||
let questionRow = null;
|
|
||||||
if (p.question_id) {
|
|
||||||
questionRow = (await query(
|
|
||||||
`SELECT sentence_de, sentence_en, sentence_sv FROM questions WHERE id = $1`,
|
|
||||||
[p.question_id])).rows[0] || null;
|
|
||||||
await fillMissingRow('questions', p.question_id, ['sentence']);
|
|
||||||
}
|
|
||||||
if (p.answer_type === 'word') {
|
|
||||||
if (p.positive_statement_id)
|
|
||||||
await translateWordGroup(p.positive_statement_id, 'statement_positive_words', questionRow, false);
|
|
||||||
if (p.negative_statement_id)
|
|
||||||
await translateWordGroup(p.negative_statement_id, 'statement_negative_words', questionRow, false);
|
|
||||||
} else {
|
|
||||||
if ((p.answer_type === 'text' || p.answer_type === 'question') && p.positive_statement_id)
|
|
||||||
await fillMissingRow('statements', p.positive_statement_id, ['positive_sentence']);
|
|
||||||
if (p.answer_type === 'question' && p.negative_statement_id)
|
|
||||||
await fillMissingRow('statements', p.negative_statement_id, ['negative_sentence']);
|
|
||||||
}
|
|
||||||
progress.translatedPairs++;
|
|
||||||
await setStep(pictureId, 'translate', progress);
|
|
||||||
}
|
|
||||||
} catch (err) { await setFailed(pictureId, 'translate', err); return; }
|
|
||||||
|
|
||||||
// ── Step 3: Audio für alle Sätze + Wörter des Bildes in allen Sprachen ──────
|
// ── Step 3: Audio für alle Sätze + Wörter des Bildes in allen Sprachen ──────
|
||||||
try {
|
try {
|
||||||
@@ -214,6 +200,29 @@ async function runPicture(pictureId) {
|
|||||||
} catch (err) { await setFailed(pictureId, 'finish', err); return; }
|
} catch (err) { await setFailed(pictureId, 'finish', err); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Übersetzt die fehlenden Sprachen EINES Pairs (Frage + Sätze bzw. Wort-Gruppen).
|
||||||
|
// overwrite=true übersetzt auch bereits gefüllte Zielsprachen neu.
|
||||||
|
async function translatePair(p, overwrite = false) {
|
||||||
|
let questionRow = null;
|
||||||
|
if (p.question_id) {
|
||||||
|
questionRow = (await query(
|
||||||
|
`SELECT sentence_de, sentence_en, sentence_sv FROM questions WHERE id = $1`,
|
||||||
|
[p.question_id])).rows[0] || null;
|
||||||
|
await fillMissingRow('questions', p.question_id, ['sentence'], { overwrite });
|
||||||
|
}
|
||||||
|
if (p.answer_type === 'word') {
|
||||||
|
if (p.positive_statement_id)
|
||||||
|
await translateWordGroup(p.positive_statement_id, 'statement_positive_words', questionRow, overwrite);
|
||||||
|
if (p.negative_statement_id)
|
||||||
|
await translateWordGroup(p.negative_statement_id, 'statement_negative_words', questionRow, overwrite);
|
||||||
|
} else {
|
||||||
|
if ((p.answer_type === 'text' || p.answer_type === 'question') && p.positive_statement_id)
|
||||||
|
await fillMissingRow('statements', p.positive_statement_id, ['positive_sentence'], { overwrite });
|
||||||
|
if (p.answer_type === 'question' && p.negative_statement_id)
|
||||||
|
await fillMissingRow('statements', p.negative_statement_id, ['negative_sentence'], { overwrite });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Alle 3 Sprachen in den genutzten Feldern des Pairs gefüllt? (Spiegel des Review-Checks)
|
// Alle 3 Sprachen in den genutzten Feldern des Pairs gefüllt? (Spiegel des Review-Checks)
|
||||||
async function isPairComplete(p) {
|
async function isPairComplete(p) {
|
||||||
if (p.question_id) {
|
if (p.question_id) {
|
||||||
@@ -334,4 +343,4 @@ async function generateWithBackoff(u) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { enqueue, resumePending, loadPairs, collectAudioUnits, generateWithBackoff };
|
module.exports = { enqueue, resumePending, loadPairs, collectAudioUnits, generateWithBackoff, translatePair };
|
||||||
|
|||||||
@@ -57,20 +57,40 @@ function detokenize(translated, tokens, labelsFromClaude) {
|
|||||||
async function callClaude({ system, user, maxTokens = 2000 }) {
|
async function callClaude({ system, user, maxTokens = 2000 }) {
|
||||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
if (!apiKey) { const e = new Error('ANTHROPIC_API_KEY nicht konfiguriert'); e.status = 500; throw e; }
|
if (!apiKey) { const e = new Error('ANTHROPIC_API_KEY nicht konfiguriert'); e.status = 500; throw e; }
|
||||||
const res = await fetch(ANTHROPIC_API_URL, {
|
|
||||||
method: 'POST',
|
// Retry bei Überlast/Rate-Limit/Netzfehler — der große Pipeline-Durchlauf macht
|
||||||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
// viele Calls hintereinander; ohne Retry brach früher eine Sprache komplett weg.
|
||||||
body: JSON.stringify({
|
const RETRYABLE = new Set([429, 500, 503, 529]);
|
||||||
model: TRANSLATE_MODEL, max_tokens: maxTokens, system,
|
const delays = [1000, 4000, 12000];
|
||||||
messages: [{ role: 'user', content: user }],
|
let lastErr;
|
||||||
}),
|
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
||||||
});
|
try {
|
||||||
if (!res.ok) { const err = await res.json().catch(() => ({})); const e = new Error(err.error?.message || `Claude API ${res.status}`); e.status = res.status; throw e; }
|
const res = await fetch(ANTHROPIC_API_URL, {
|
||||||
const data = await res.json();
|
method: 'POST',
|
||||||
let raw = data.content[0].text.trim();
|
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||||||
const md = raw.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
|
body: JSON.stringify({
|
||||||
if (md) raw = md[1];
|
model: TRANSLATE_MODEL, max_tokens: maxTokens, system,
|
||||||
return JSON.parse(raw);
|
messages: [{ role: 'user', content: user }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
const e = new Error(err.error?.message || `Claude API ${res.status}`); e.status = res.status; throw e;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
let raw = data.content[0].text.trim();
|
||||||
|
const md = raw.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
|
||||||
|
if (md) raw = md[1];
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
// Netzfehler (kein status) oder retrybare HTTP-Codes → erneut versuchen
|
||||||
|
const retryable = err.status == null || RETRYABLE.has(err.status);
|
||||||
|
if (!retryable || attempt === delays.length) throw err;
|
||||||
|
await new Promise(r => setTimeout(r, delays[attempt]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Übersetzt einen Text inkl. Placeholder-Schutz.
|
// Übersetzt einen Text inkl. Placeholder-Schutz.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const router = require('express').Router();
|
|||||||
const { query } = require('../db');
|
const { query } = require('../db');
|
||||||
const { LANGS } = require('../lib/translate');
|
const { LANGS } = require('../lib/translate');
|
||||||
const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent');
|
const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent');
|
||||||
const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff } = require('../lib/pipeline');
|
const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePair } = require('../lib/pipeline');
|
||||||
const { PLACEHOLDER_RE } = require('../lib/placeholders');
|
const { PLACEHOLDER_RE } = require('../lib/placeholders');
|
||||||
|
|
||||||
// ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ──
|
// ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ──
|
||||||
@@ -207,6 +207,21 @@ router.post('/assign-object', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/pipeline/picture/:id/translate-fill — fehlende Übersetzungen dieses Bildes nachholen
|
||||||
|
// (z.B. wenn bei einem früheren Lauf eine Sprache durch einen API-Fehler weggebrochen ist)
|
||||||
|
router.post('/picture/:id/translate-fill', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const pairs = await loadPairs(req.params.id);
|
||||||
|
if (!pairs.length) return res.status(400).json({ error: 'Bild hat keine Pairs' });
|
||||||
|
const result = { translated: 0, failed: 0, errors: [] };
|
||||||
|
for (const p of pairs) {
|
||||||
|
try { await translatePair(p); result.translated++; }
|
||||||
|
catch (err) { result.failed++; result.errors.push({ pair_id: p.id, error: err.message }); }
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/pipeline/picture/:id/audio-fill — fehlende Audios dieses Bildes nachgenerieren
|
// POST /api/pipeline/picture/:id/audio-fill — fehlende Audios dieses Bildes nachgenerieren
|
||||||
router.post('/picture/:id/audio-fill', async (req, res, next) => {
|
router.post('/picture/:id/audio-fill', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user