From fb03204726364d19d6b7deb33abd2cf442256565 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:50:51 +0300 Subject: [PATCH] feat: machine translation saves lines as they finish and shows progress at once Total shown right away, lines saved per poll, retry reasons in the card; layout fix of the options row. Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/translator/run.ts | 47 +++++++++++++++++++------- frontend/src/translator/runner.tsx | 2 +- frontend/src/translator/translator.css | 2 ++ frontend/tests/translator-run.test.mjs | 22 ++++++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/frontend/src/translator/run.ts b/frontend/src/translator/run.ts index ca30703..8fcad2f 100644 --- a/frontend/src/translator/run.ts +++ b/frontend/src/translator/run.ts @@ -62,7 +62,8 @@ const sleep = (ms: number, signal: AbortSignal) => /** Polls a job until it is over; aborting cancels it and returns what was finished by then. */ export async function waitForJob(client: Pick, first: Job, - signal: AbortSignal, pollMs = 700): Promise { + signal: AbortSignal, pollMs = 700, + onPoll?: (job: Job) => Promise | void): Promise { const opts = { client, signal, pollMs }; let job = first; let errors = 0; @@ -75,6 +76,7 @@ export async function waitForJob(client: Pick= 4) throw e; @@ -121,19 +123,40 @@ export async function runTranslation(opts: RunOptions): Promise { opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize); checkpoint(job.id, items.map((it) => it.key)); } - job = await waitForJob(opts.client, job, opts.signal, opts.pollMs); - progress.model = job.model ?? progress.model; - - const ready: { key: string; text: string }[] = []; + // Lines are saved as the translator finishes them, not when the whole page is over: a slow + // model then shows progress at once, and a stop or a crash loses almost nothing. + const saved = new Set(); + // a retry note from the previous page is stale now; the reason of a line that really failed stays + if (!progress.failed) progress.lastError = ""; + const harvest = async (current: Job) => { + const ready: { key: string; text: string }[] = []; + for (const it of current.items) { + const index = Number(it.id); + const src = items[index]; + if (!saved.has(index) && it.status === "completed" && it.translation?.trim() && src) { + saved.add(index); + ready.push({ key: src.key, text: it.translation }); + } else if (it.error && it.status !== "completed") { + // the translator is retrying this line: say why, so a slow page is not a silent one + progress.lastError = it.error; + } + } + progress.model = current.model ?? progress.model; + if (ready.length) { + await opts.save(ready); + progress.done += ready.length; + } + emit(); + }; + emit(); + job = await waitForJob(opts.client, job, opts.signal, opts.pollMs, harvest); + await harvest(job); for (const it of job.items) { - const src = items[Number(it.id)]; - if (it.status === "completed" && it.translation?.trim() && src) ready.push({ key: src.key, text: it.translation }); - else if (job.status !== "cancelled") progress.lastError = it.error ?? "Модель вернула пустой перевод"; + if (!saved.has(Number(it.id)) && job.status !== "cancelled") progress.lastError = it.error ?? "Модель вернула пустой перевод"; } - if (ready.length) await opts.save(ready); - progress.done += ready.length; - if (job.status !== "cancelled") progress.failed += items.length - ready.length; - skip += items.length - ready.length; + const missed = items.length - saved.size; + if (job.status !== "cancelled") progress.failed += missed; + skip += missed; emit(); checkpoint(null, []); if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание"); diff --git a/frontend/src/translator/runner.tsx b/frontend/src/translator/runner.tsx index 610af42..cecabbe 100644 --- a/frontend/src/translator/runner.tsx +++ b/frontend/src/translator/runner.tsx @@ -290,7 +290,7 @@ export function TranslationProgress() { )}

{text}

{task.error && {task.error}} - {p.failed > 0 && p.lastError &&

Последняя ошибка: {p.lastError}

} + {p.lastError &&

{p.failed > 0 ? "Последняя ошибка" : "Модель повторяет попытку"}: {p.lastError}

}
{active && } { await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) }); assert.deepEqual(marks, [["sent", 2, 0], ["done", 0, 0], ["sent", 1, 0], ["done", 0, 0]]); }); + +test("a slow page is saved line by line while it is still running", async () => { + const server = fakeServer(["a", "b", "c"]); + const saves = []; + const save = server.save; + server.save = async (r) => { saves.push(r.map((x) => x.key)); await save(r); }; + // the translator finishes one more line on every poll + let polls = 0; + const lines = ["a", "b", "c"]; + const make = () => ({ + id: "j", status: polls >= 3 ? "completed" : "running", model: "m", error: null, + items: lines.map((text, i) => i < polls + ? { id: String(i), source: text, translation: `RU ${text}`, status: "completed", error: null } + : { id: String(i), source: text, translation: null, status: "pending", error: null }), + }); + const client = { createJob: async () => make(), job: async () => { polls++; return make(); }, cancel: async () => make() }; + const seen = []; + await run(client, server, { pageSize: 3, onProgress: (p) => seen.push(p.done) }); + assert.deepEqual(saves, [["k0"], ["k1"], ["k2"]]); + assert.ok(seen.includes(1) && seen.includes(2), "progress is reported before the page ends"); + assert.equal(seen.at(-1), 3); +});