fix: the translation remains refreshed after switching another transl… · PDFMathTranslate/PDFMathReader@1a1e7f2 · GitHub
Skip to content

Commit 1a1e7f2

Browse files
committed
fix: the translation remains refreshed after switching another translation service when the using old translation option enabled; expected behavior is that, if the doc + kernel matches and the option is enabled, prioritize the translation cache of texts
1 parent fd384a7 commit 1a1e7f2

8 files changed

Lines changed: 227 additions & 78 deletions

File tree

‎server/http/translation-routes.mjs‎

Lines changed: 7 additions & 2 deletions

‎server/kernels/translation-runner.mjs‎

Lines changed: 81 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -124,65 +124,85 @@ export function createTranslationRunner({
124124
// This request index points to complete artifacts, so reopening does not
125125
// need Python discovery, option introspection, or the service catalog.
126126
// Only a digest is persisted; service values may contain credentials.
127-
const requestKey = sourceHash
128-
.copy()
129-
.update(
130-
JSON.stringify({
131-
cacheIndex: 1,
132-
layoutSchema: currentLayoutSchema,
133-
id,
134-
page,
135-
language,
136-
sourceLanguage: sourceLanguage || 'English',
137-
glossary,
138-
advancedOptions,
139-
translationService: cacheSelection,
140-
serviceIdentity,
141-
localTranslation,
142-
cacheScope,
143-
}),
144-
)
145-
.digest('hex');
146-
const requestPath = join(cacheDir, requestKey + '.page.json');
147-
const rememberPage = async (artifactKey, translationModel) => {
148-
const temporary = requestPath + '.' + crypto.randomUUID() + '.tmp';
127+
const requestDigest = (serviceBound) =>
128+
sourceHash
129+
.copy()
130+
.update(
131+
JSON.stringify({
132+
cacheIndex: 1,
133+
layoutSchema: currentLayoutSchema,
134+
id,
135+
page,
136+
language,
137+
sourceLanguage: sourceLanguage || 'English',
138+
glossary,
139+
advancedOptions,
140+
...(serviceBound
141+
? { translationService: cacheSelection, serviceIdentity, localTranslation }
142+
: { compatible: 1 }),
143+
cacheScope,
144+
}),
145+
)
146+
.digest('hex');
147+
const requestPath = join(cacheDir, requestDigest(true) + '.page.json');
148+
// The compatible index omits the service, so any service can reuse it.
149+
const compatibleRequestPath = join(cacheDir, requestDigest(false) + '.page.json');
150+
const writeIndex = async (path, value) => {
151+
const temporary = path + '.' + crypto.randomUUID() + '.tmp';
149152
try {
150153
await mkdir(cacheDir, { recursive: true });
151-
await writeFile(temporary, JSON.stringify({ key: artifactKey, model: translationModel }));
152-
await rename(temporary, requestPath);
154+
await writeFile(temporary, JSON.stringify(value));
155+
await rename(temporary, path);
153156
} finally {
154157
await rm(temporary, { force: true }).catch(() => {});
155158
}
156159
};
160+
const rememberPage = async (artifactKey, translationModel, reused = false) => {
161+
await writeIndex(requestPath, { key: artifactKey, model: translationModel, reused });
162+
await writeIndex(compatibleRequestPath, { key: artifactKey, model: translationModel });
163+
};
157164
if (!forceRetranslation) {
158-
try {
159-
const saved = JSON.parse(await readFile(requestPath, 'utf8'));
160-
if (!/^[0-9a-f]{64}$/.test(saved.key) || typeof saved.model !== 'string')
161-
throw Error('Invalid page cache index');
162-
// Without reuse, only the requested model's own artifact qualifies.
163-
if (!reuseTranslations && saved.model !== model) throw Error('Different cached model');
164-
const [result, metadata] = await Promise.all([
165-
readFile(join(cacheDir, saved.key + '.pdf')),
166-
readFile(join(cacheDir, saved.key + '.layout.json'), 'utf8'),
167-
]);
168-
if (
169-
!result.subarray(0, 1024).includes(Buffer.from('%PDF')) ||
170-
!Array.isArray(JSON.parse(metadata).paragraphs)
171-
)
172-
throw Error('Invalid cached page');
173-
result.layoutKey = cacheScope ? `${scopeHash}-${scopeGeneration}-${saved.key}` : saved.key;
174-
result.cached = true;
175-
result.translationModel = saved.model;
176-
step('cacheLookup');
177-
emitTiming({
178-
engine: id,
179-
cached: true,
180-
model: saved.model,
181-
totalMs: performance.now() - started,
182-
stages,
183-
});
184-
return result;
185-
} catch {}
165+
for (const indexPath of reuseTranslations
166+
? [requestPath, compatibleRequestPath]
167+
: [requestPath]) {
168+
try {
169+
const saved = JSON.parse(await readFile(indexPath, 'utf8'));
170+
if (!/^[0-9a-f]{64}$/.test(saved.key) || typeof saved.model !== 'string')
171+
throw Error('Invalid page cache index');
172+
// Without reuse, only the requested model's own artifact qualifies.
173+
if (!reuseTranslations && (saved.model !== model || saved.reused))
174+
throw Error('Different cached model');
175+
const [result, metadata] = await Promise.all([
176+
readFile(join(cacheDir, saved.key + '.pdf')),
177+
readFile(join(cacheDir, saved.key + '.layout.json'), 'utf8'),
178+
]);
179+
if (
180+
!result.subarray(0, 1024).includes(Buffer.from('%PDF')) ||
181+
!Array.isArray(JSON.parse(metadata).paragraphs)
182+
)
183+
throw Error('Invalid cached page');
184+
if (indexPath === compatibleRequestPath)
185+
await writeIndex(requestPath, {
186+
key: saved.key,
187+
model: saved.model,
188+
reused: true,
189+
}).catch(() => {});
190+
result.layoutKey = cacheScope
191+
? `${scopeHash}-${scopeGeneration}-${saved.key}`
192+
: saved.key;
193+
result.cached = true;
194+
result.translationModel = saved.model;
195+
step('cacheLookup');
196+
emitTiming({
197+
engine: id,
198+
cached: true,
199+
model: saved.model,
200+
totalMs: performance.now() - started,
201+
stages,
202+
});
203+
return result;
204+
} catch {}
205+
}
186206
}
187207
// A cache miss is not kernel work. The actual translation request can still
188208
// migrate legacy artifacts below, but this fast lookup must never probe Python.
@@ -200,7 +220,7 @@ export function createTranslationRunner({
200220
if (service) model = service.model;
201221
step('environmentAndOptions');
202222
const layoutKey = (key) => (cacheScope ? `${scopeHash}-${scopeGeneration}-${key}` : key);
203-
const keyFor = (cacheModel, layoutSchema = currentLayoutSchema) =>
223+
const keyFor = (cacheModel, layoutSchema = currentLayoutSchema, serviceBound = true) =>
204224
sourceHash
205225
.copy()
206226
.update(
@@ -211,9 +231,11 @@ export function createTranslationRunner({
211231
language,
212232
...(sourceLanguage && sourceLanguage !== 'English' ? { sourceLanguage } : {}),
213233
model: cacheModel,
214-
...(service || serviceIdentity
215-
? { service: service?.cacheIdentity || serviceIdentity }
216-
: {}),
234+
...(!serviceBound
235+
? { compatible: 1 }
236+
: service || serviceIdentity
237+
? { service: service?.cacheIdentity || serviceIdentity }
238+
: {}),
217239
prompt: 2,
218240
...(glossary.length ? { glossary } : {}),
219241
...(cacheScope ? { cacheScope } : {}),
@@ -226,6 +248,7 @@ export function createTranslationRunner({
226248
const cache = createTranslationCache({
227249
directory: cacheDir,
228250
keyFor,
251+
compatibleKey: keyFor(undefined, currentLayoutSchema, false),
229252
fallbackKeyFors:
230253
currentLayoutSchema >= 5
231254
? []
@@ -242,7 +265,7 @@ export function createTranslationRunner({
242265
const cached = join(cacheDir, `${key}.pdf`);
243266
const hit = await cache.lookup(model, { reuseTranslations, forceRetranslation });
244267
if (hit) {
245-
await rememberPage(hit.key, hit.model).catch(() => {});
268+
await rememberPage(hit.key, hit.model, hit.reused).catch(() => {});
246269
const result = hit.result;
247270
result.layoutKey = layoutKey(hit.key);
248271
result.cached = true;
@@ -267,7 +290,7 @@ export function createTranslationRunner({
267290
const queuedHit = await cache.lookup(model, { reuseTranslations, forceRetranslation });
268291
step('cacheRecheck');
269292
if (queuedHit) {
270-
await rememberPage(queuedHit.key, queuedHit.model).catch(() => {});
293+
await rememberPage(queuedHit.key, queuedHit.model, queuedHit.reused).catch(() => {});
271294
const result = queuedHit.result;
272295
result.layoutKey = layoutKey(queuedHit.key);
273296
result.cached = true;

‎server/translation/translation-cache.mjs‎

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,63 @@ import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
22
import { join } from 'node:path';
33
import { randomUUID } from 'node:crypto';
44

5+
const ARTIFACT_KEY = /^[0-9a-f]{64}$/;
6+
57
// The exact key retains the original on-disk format. A separate pointer records
68
// the last compatible result without making provider credentials part of it.
7-
export function createTranslationCache({ directory, keyFor, readResult, fallbackKeyFors = [] }) {
8-
const compatibleKey = keyFor(undefined),
9-
pointer = join(directory, `${compatibleKey}.translation.json`);
9+
// `compatibleKey` omits service and model, so a result produced by one service
10+
// can be reused by another when the document, kernel, language and options match.
11+
export function createTranslationCache({
12+
directory,
13+
keyFor,
14+
compatibleKey,
15+
readResult,
16+
fallbackKeyFors = [],
17+
}) {
18+
const legacyPointer = join(directory, `${keyFor(undefined)}.translation.json`),
19+
pointer = compatibleKey ? join(directory, `${compatibleKey}.translation.json`) : legacyPointer;
1020
async function remember(model, key) {
1121
await mkdir(directory, { recursive: true });
1222
const temporary = `${pointer}.${randomUUID()}.tmp`;
1323
await writeFile(temporary, JSON.stringify({ model, key }));
1424
await rename(temporary, pointer);
1525
}
26+
async function readPointer(path, valid) {
27+
try {
28+
const saved = JSON.parse(await readFile(path, 'utf8'));
29+
if (typeof saved.model === 'string' && valid(saved))
30+
return { model: saved.model, key: saved.key };
31+
} catch {}
32+
}
1633
async function lookup(model, { reuseTranslations = false, forceRetranslation = false } = {}) {
1734
if (forceRetranslation) return null;
18-
const candidates = [model];
35+
const candidates = [{ model, key: keyFor(model) }];
1936
if (reuseTranslations) {
20-
try {
21-
const saved = JSON.parse(await readFile(pointer, 'utf8'));
22-
if (typeof saved.model === 'string' && saved.key === keyFor(saved.model))
23-
candidates.push(saved.model);
24-
} catch {}
37+
if (compatibleKey)
38+
candidates.push(
39+
await readPointer(pointer, (saved) => ARTIFACT_KEY.test(String(saved.key))),
40+
);
41+
candidates.push(
42+
await readPointer(legacyPointer, (saved) => saved.key === keyFor(saved.model)),
43+
);
2544
// Existing caches predate the compatible-result pointer. Probe the shipped
2645
// providers using the unchanged exact key, then migrate successful hits.
27-
candidates.push('gpt-4.1-mini', 'siliconflow-free');
46+
for (const candidate of ['gpt-4.1-mini', 'siliconflow-free'])
47+
candidates.push({ model: candidate, key: keyFor(candidate) });
2848
}
29-
for (const candidate of new Set(candidates)) {
30-
const key = keyFor(candidate);
49+
const tried = new Set();
50+
for (const candidate of candidates) {
51+
if (!candidate || tried.has(candidate.key)) continue;
52+
tried.add(candidate.key);
3153
let result;
3254
try {
33-
result = await readResult(key);
55+
result = await readResult(candidate.key);
3456
} catch {
3557
continue;
3658
}
3759
// An optional index write must never turn a valid cache hit into a retranslation.
38-
await remember(candidate, key).catch(() => {});
39-
return { result, key, model: candidate };
60+
await remember(candidate.model, candidate.key).catch(() => {});
61+
return { result, ...candidate, reused: candidate.key !== candidates[0].key };
4062
}
4163
// Layout-only upgrades can retain prior translated PDFs when explicitly
4264
// allowed by the caller. Keep their original keys and layout metadata.
@@ -47,7 +69,7 @@ export function createTranslationCache({ directory, keyFor, readResult, fallback
4769
keyFor: fallbackKeyFor,
4870
readResult,
4971
}).lookup(model);
50-
if (hit) return hit;
72+
if (hit) return { ...hit, reused: true };
5173
}
5274
return null;
5375
}

‎src/app/useReaderWindow.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ export function useReaderWindow() {
715715
revealHeader,
716716
saveView: featureActions.saveView,
717717
resetTranslations: featureActions.resetTranslations,
718+
cancelTranslations: featureActions.cancel,
718719
settle: featureActions.settle,
719720
checkEngine: featureActions.checkEngine,
720721
currentKernelAdvancedOptions: featureActions.currentKernelAdvancedOptions,
@@ -939,6 +940,7 @@ export function useReaderWindow() {
939940
settle: featureActions.settle,
940941
pruneTranslationQueue: featureActions.pruneTranslationQueue,
941942
resetTranslations: featureActions.resetTranslations,
943+
cancelTranslations: featureActions.cancel,
942944
saveView: featureActions.saveView,
943945
settingsWindowMode,
944946
showTranslations,

‎src/features/settings/kernel-state.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export function createKernelState({ pages, engine }) {
8787
revealHeader,
8888
saveView,
8989
resetTranslations,
90+
cancelTranslations,
9091
settle,
9192
checkEngine,
9293
currentKernelAdvancedOptions,
@@ -154,7 +155,9 @@ export function createKernelState({ pages, engine }) {
154155
() => JSON.stringify(currentTranslationService.value),
155156
(next, previous) => {
156157
if (!previous || next === previous || preferences.loadingPreferences) return;
157-
resetTranslations(true);
158+
// Keep finished pages; compatible server caches serve the remaining ones.
159+
if (preferences.reuseTranslations?.value) cancelTranslations();
160+
else resetTranslations(true);
158161
settle();
159162
},
160163
);

‎src/features/settings/preference-observers.mjs‎

Lines changed: 2 additions & 1 deletion

0 commit comments

Comments
 (0)