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 <noreply@anthropic.com>
This commit is contained in:
1 parent
68b66a1d94
commit
fb03204726
4 files changed
+60
-13
No files matched your search
@@ -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. */
|
/** Polls a job until it is over; aborting cancels it and returns what was finished by then. */
|
||||||
export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel">, first: Job,
|
export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel">, first: Job,
|
||||||
signal: AbortSignal, pollMs = 700): Promise<Job> {
|
signal: AbortSignal, pollMs = 700,
|
||||||
|
onPoll?: (job: Job) => Promise<void> | void): Promise<Job> {
|
||||||
const opts = { client, signal, pollMs };
|
const opts = { client, signal, pollMs };
|
||||||
let job = first;
|
let job = first;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
@@ -75,6 +76,7 @@ export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel"
|
|||||||
try {
|
try {
|
||||||
job = await opts.client.job(job.id, opts.signal);
|
job = await opts.client.job(job.id, opts.signal);
|
||||||
errors = 0;
|
errors = 0;
|
||||||
|
await onPoll?.(job);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (opts.signal.aborted) return stop();
|
if (opts.signal.aborted) return stop();
|
||||||
if (++errors >= 4) throw e;
|
if (++errors >= 4) throw e;
|
||||||
@@ -121,19 +123,40 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
|||||||
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
|
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
|
||||||
checkpoint(job.id, items.map((it) => it.key));
|
checkpoint(job.id, items.map((it) => it.key));
|
||||||
}
|
}
|
||||||
job = await waitForJob(opts.client, job, opts.signal, opts.pollMs);
|
// Lines are saved as the translator finishes them, not when the whole page is over: a slow
|
||||||
progress.model = job.model ?? progress.model;
|
// model then shows progress at once, and a stop or a crash loses almost nothing.
|
||||||
|
const saved = new Set<number>();
|
||||||
const ready: { key: string; text: string }[] = [];
|
// 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) {
|
for (const it of job.items) {
|
||||||
const src = items[Number(it.id)];
|
if (!saved.has(Number(it.id)) && job.status !== "cancelled") progress.lastError = it.error ?? "Модель вернула пустой перевод";
|
||||||
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 (ready.length) await opts.save(ready);
|
const missed = items.length - saved.size;
|
||||||
progress.done += ready.length;
|
if (job.status !== "cancelled") progress.failed += missed;
|
||||||
if (job.status !== "cancelled") progress.failed += items.length - ready.length;
|
skip += missed;
|
||||||
skip += items.length - ready.length;
|
|
||||||
emit();
|
emit();
|
||||||
checkpoint(null, []);
|
checkpoint(null, []);
|
||||||
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
|
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export function TranslationProgress() {
|
|||||||
)}
|
)}
|
||||||
<p className="small" role="status">{text}</p>
|
<p className="small" role="status">{text}</p>
|
||||||
{task.error && <ErrorNote>{task.error}</ErrorNote>}
|
{task.error && <ErrorNote>{task.error}</ErrorNote>}
|
||||||
{p.failed > 0 && p.lastError && <p className="muted small">Последняя ошибка: {p.lastError}</p>}
|
{p.lastError && <p className="muted small">{p.failed > 0 ? "Последняя ошибка" : "Модель повторяет попытку"}: {p.lastError}</p>}
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{active && <button className="btn btn-sm" disabled={task.status === "stopping"} onClick={stop}><Icon name="x" size={16} />Остановить</button>}
|
{active && <button className="btn btn-sm" disabled={task.status === "stopping"} onClick={stop}><Icon name="x" size={16} />Остановить</button>}
|
||||||
<Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
|
<Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
|
||||||
|
|||||||
@@ -57,3 +57,5 @@
|
|||||||
.mt-options { display: flex; gap: var(--space-4); flex-wrap: wrap; }
|
.mt-options { display: flex; gap: var(--space-4); flex-wrap: wrap; }
|
||||||
.check { display: inline-flex; align-items: center; gap: 0.5rem; cursor: pointer; }
|
.check { display: inline-flex; align-items: center; gap: 0.5rem; cursor: pointer; }
|
||||||
.mt-progress { display: flex; flex-direction: column; gap: 0.35rem; }
|
.mt-progress { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.mt-panel .form-row { align-items: flex-start; }
|
||||||
|
.mt-panel .form-row select, .mt-panel .form-row input { min-height: 2.9rem; }
|
||||||
@@ -174,3 +174,25 @@ test("checkpoints say which page is out and when it is handled", async () => {
|
|||||||
await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) });
|
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]]);
|
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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user