Skip to content
2 changes: 1 addition & 1 deletion docs/translate-seed.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ output, and survives model, prompt and glossary changes. Every seeding run
rebuilds the file from scratch.

The seed is keyed by unit texts, so the options that shape the units must
match between the two commands: `--source`, `--target`, `--vars` and `--code`.
match between the two commands: `--source`, `--target`, `--vars`, `--presets`, `--vars-preset` and `--code`.
The seed takes `code` from the `translate` section of the config (or from its
own `translate.seed` section) and otherwise defaults to `adaptive`, the mode
of the LLM providers. A project that translates with the yandex provider,
Expand Down
4 changes: 3 additions & 1 deletion src/commands/translate/commands/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ export class Extract extends BaseProgram<ExtractConfig, ExtractArgs> {

this.logger.setup(this.config);

this.run = new Run(this.config);
// Extract feeds external tools: its variables come from `--vars` only,
// presets.yaml stays out of the XLIFF (see translate for presets).
this.run = new Run(this.config, {usePresets: false});

await getBaseHooks(this).BeforeAnyRun.promise(this.run);
await getHooks(this).BeforeRun.promise(this.run);
Expand Down
169 changes: 166 additions & 3 deletions src/commands/translate/commands/seed.command.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs';
import {mkdirSync, mkdtempSync, realpathSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {dirname, join} from 'node:path';
import {afterEach, describe, expect, it, vi} from 'vitest';
Expand All @@ -12,7 +12,12 @@ import {Run} from '../run';
import {Seed} from './seed';

function project(files: Record<string, string>) {
const dir = mkdtempSync(join(tmpdir(), 'yfm-seed-command-')) as AbsolutePath;
// The long form of the path: on Windows the temp dir comes as an 8.3 name
// (RUNNER~1), and files read through the run (presets.yaml) resolve out
// of a scope taken from the short one.
const dir = realpathSync.native(
mkdtempSync(join(tmpdir(), 'yfm-seed-command-')),
) as AbsolutePath;
for (const [path, content] of Object.entries(files)) {
mkdirSync(dirname(join(dir, path)), {recursive: true});
writeFileSync(join(dir, path), content);
Expand All @@ -23,7 +28,10 @@ function project(files: Record<string, string>) {
async function runSeed(argv: string, files: string[]) {
const seed = new Seed();

vi.spyOn(Run.prototype, 'prepareRun').mockImplementation(async () => undefined);
// Tocs stay out of these tests; presets load as in a real run.
vi.spyOn(Run.prototype, 'prepareRun').mockImplementation(async function (this: Run) {
await this.vars.init();
});
vi.spyOn(Run.prototype, 'getFiles').mockResolvedValue([files, []]);

const rawArgs = ['node', 'index'].concat(argv.split(' '));
Expand Down Expand Up @@ -64,6 +72,161 @@ describe('Translate.Seed command', () => {
expect(argument.config.code).toBe('precise');
});

it('should take the vars preset of the .yfm root, as build does', async () => {
const input = project({
'.yfm': 'varsPreset: public\ntranslate:\n seed:\n cacheDir: cache\n',
'ru/article.md': 'Раз.\n',
});

const seed = await runSeed(`-i ${input} --source ru --target en`, []);

expect(seed.config.varsPreset).toBe('public');
});

it('should take the vars preset of the .yfm root without a translate section', async () => {
const input = project({
'.yfm': 'varsPreset: public\n',
'ru/article.md': 'Раз.\n',
});
const cacheDir = mkdtempSync(join(tmpdir(), 'yfm-seed-command-cache-')) as AbsolutePath;

const seed = await runSeed(
`-i ${input} --source ru --target en --cache-dir ${cacheDir}`,
[],
);

expect(seed.config.varsPreset).toBe('public');
});

it('should prefer the translate section, the seed section and the argument for the vars preset', async () => {
const input = project({
'.yfm': 'varsPreset: public\ntranslate:\n varsPreset: internal\n seed:\n cacheDir: cache\n',
'ru/article.md': 'Раз.\n',
});

const translate = await runSeed(`-i ${input} --source ru --target en`, []);
expect(translate.config.varsPreset).toBe('internal');

const section = project({
'.yfm': 'varsPreset: public\ntranslate:\n varsPreset: internal\n seed:\n varsPreset: staging\n cacheDir: cache\n',
'ru/article.md': 'Раз.\n',
});

const seed = await runSeed(`-i ${section} --source ru --target en`, []);
expect(seed.config.varsPreset).toBe('staging');

const argument = await runSeed(
`-i ${section} --source ru --target en --vars-preset default`,
[],
);
expect(argument.config.varsPreset).toBe('default');
});

it('should let a section select the default preset over the root', async () => {
const input = project({
'.yfm': 'varsPreset: internal\ntranslate:\n varsPreset: default\n seed:\n cacheDir: cache\n',
'ru/article.md': 'Раз.\n',
});

const seed = await runSeed(`-i ${input} --source ru --target en`, []);

expect(seed.config.varsPreset).toBe('default');
});

it('should keep presets off unless the translate section or the argument turns them on', async () => {
const off = project({
'.yfm': 'varsPreset: public\ntranslate:\n seed:\n cacheDir: cache\n',
'ru/article.md': 'Раз.\n',
});
expect((await runSeed(`-i ${off} --source ru --target en`, [])).config.presets).toBe(false);

const section = project({
'.yfm': 'translate:\n presets: true\n seed:\n cacheDir: cache\n',
'ru/article.md': 'Раз.\n',
});
expect((await runSeed(`-i ${section} --source ru --target en`, [])).config.presets).toBe(
true,
);

const argument = await runSeed(`-i ${off} --source ru --target en --presets`, []);
expect(argument.config.presets).toBe(true);
});

it('should take the presets switch of the translate section without a seed section', async () => {
const input = project({
'.yfm': 'varsPreset: public\ntranslate:\n presets: true\n',
'ru/article.md': 'Раз.\n',
});
const cacheDir = mkdtempSync(join(tmpdir(), 'yfm-seed-command-cache-')) as AbsolutePath;

const seed = await runSeed(
`-i ${input} --source ru --target en --cache-dir ${cacheDir}`,
[],
);

expect(seed.config.presets).toBe(true);
});

it('should align both sides under the presets of the target language', async () => {
const input = project({
'.yfm': 'translate:\n presets: true\n',
'ru/presets.yaml': 'default:\n lang: ru\n',
'en/presets.yaml': 'default:\n lang: en\n',
'ru/article.md':
'Общее.\n\n{% if lang == "ru" %}\n\nРусское.\n\n{% else %}\n\nАнглийское.\n\n{% endif %}\n',
'en/article.md': 'Common.\n\nEnglish.\n',
});
const cacheDir = mkdtempSync(join(tmpdir(), 'yfm-seed-command-cache-')) as AbsolutePath;

await runSeed(`-i ${input} --source ru --target en --cache-dir ${cacheDir}`, [
'ru/article.md',
]);

const seeds = new SeedStore(seedFilePath(cacheDir, 'ru', 'en'));
seeds.load();

// The translate run sees the presets of en/article.md, where the
// English branch is the one that stays: the seed pairs it with the
// existing translation, not the Russian branch.
const {units} = await loadTranslationUnits({
inputPath: join(input, 'ru/article.md') as AbsolutePath,
path: 'ru/article.md',
sourceLanguage: 'ru',
targetLanguage: 'en',
vars: {lang: 'en'},
});

expect(units).toHaveLength(2);
expect(seeds.get(units[1])).toEqual(expect.stringContaining('English.'));
});

it('should require one target language with presets', async () => {
const input = project({'ru/article.md': 'Раз.\n'});
const cacheDir = mkdtempSync(join(tmpdir(), 'yfm-seed-command-cache-')) as AbsolutePath;

await expect(
runSeed(
`-i ${input} --source ru --target en --target kk --presets --cache-dir ${cacheDir}`,
[],
),
).rejects.toThrow('--presets takes one target language');
});

it('should take the code mode of the translate section without a seed section', async () => {
const input = project({
'.yfm': 'translate:\n code: precise\n',
'ru/article.md': 'Раз.\n',
});
const cacheDir = mkdtempSync(join(tmpdir(), 'yfm-seed-command-cache-')) as AbsolutePath;

const seed = await runSeed(
`-i ${input} --source ru --target en --cache-dir ${cacheDir}`,
[],
);

expect(seed.config.code).toBe('precise');
});

it('should default the code mode to adaptive', async () => {
const input = project({'ru/article.md': 'Раз.\n'});
const cacheDir = mkdtempSync(join(tmpdir(), 'yfm-seed-command-cache-')) as AbsolutePath;
Expand Down
75 changes: 55 additions & 20 deletions src/commands/translate/commands/seed.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {BaseArgs} from '~/core/program';
import type {Config} from '~/core/config';
import type {CodeMode, Locale} from '../utils';
import type {CodeMode, Locale, VarsResolver} from '../utils';
import type {ConfigDefaults} from '../utils/config';
import type {AlignedUnits} from '../providers/ai/utils';

Expand All @@ -10,7 +10,8 @@ import {pick} from 'lodash';
import {asyncify, eachLimit} from 'async';

import {YFM_CONFIG_FILENAME} from '~/constants';
import {Command, configPath, defined, resolveConfig, scope} from '~/core/config';
import {Command, defined} from '~/core/config';
import {normalizePath} from '~/core/utils';
import {
BaseProgram,
getHooks as getBaseHooks,
Expand All @@ -24,7 +25,15 @@ import {TranslateError, languageRepath, loadTranslationUnits, resolveCodeMode} f
import {SeedStore, alignTranslationUnits, seedFilePath} from '../providers/ai/utils';
import {options as aiOptions} from '../providers/ai/config';
import {Run} from '../run';
import {configDefaults, resolveSource, resolveTargets, resolveVars} from '../utils/config';
import {
checkPresetsTargets,
configDefaults,
resolveSource,
resolveTargets,
resolveVars,
resolveVarsPreset,
sectionValue,
} from '../utils/config';
import {Extension as ExtractOpenapiIncluderFakeExtension} from '../extract-openapi';

import {getHooks, withHooks} from './hooks';
Expand All @@ -37,7 +46,10 @@ export type SeedParams = {
files: string[];
sourceLanguage: string;
targetLanguage: string;
vars: Hash;
/** Flat vars for every file; `varsFor` takes precedence. */
vars?: Hash;
/** Vars of a source file as translated; the target file takes the same vars, or the units diverge. */
varsFor?: VarsResolver;
/** Must match the code mode of the translate run, or the cache keys diverge. LLM default when unset. */
code?: CodeMode;
cacheDir: AbsolutePath;
Expand Down Expand Up @@ -84,7 +96,8 @@ export async function seedTranslations(params: SeedParams): Promise<SeedStats> {
files,
sourceLanguage,
targetLanguage,
vars,
vars: flatVars = {},
varsFor = () => flatVars,
code = 'adaptive',
cacheDir,
} = params;
Expand Down Expand Up @@ -177,6 +190,10 @@ export async function seedTranslations(params: SeedParams): Promise<SeedStats> {
inputPath: AbsolutePath,
targetPath: AbsolutePath,
): Promise<(AlignedUnits & {units: number}) | undefined> {
// Both sides take the vars the translate run gives the source file,
// the presets of its translation: a different set on either side
// would keep or drop other conditional blocks and misalign the units.
const vars = varsFor(file);
const source = await loadTranslationUnits({
inputPath,
path: file,
Expand Down Expand Up @@ -204,19 +221,16 @@ export async function seedTranslations(params: SeedParams): Promise<SeedStats> {
}

/**
* The seed section is nested in `translate`, so a code mode set for the
* translate run one level up applies to seeding as well.
* The seed section is nested in `translate`, so the presets switch and the
* code mode set for the translate run one level up apply to seeding as well,
* also when the .yfm has no seed section of its own.
*/
async function inheritCodeMode(config: Config<Hash>): Promise<CodeMode | undefined> {
const path = config[configPath];

if (!path) {
return undefined;
}

const parent = await resolveConfig(path, {filter: scope('translate')});
async function inheritPresets(config: Config<Hash>, args: Hash): Promise<boolean> {
return Boolean(await sectionValue<boolean>(config, args, ['translate'], 'presets'));
}

return resolveCodeMode({}, parent);
async function inheritCodeMode(config: Config<Hash>, args: Hash): Promise<CodeMode | undefined> {
return resolveCodeMode({}, {code: await sectionValue(config, args, ['translate'], 'code')});
}

export type SeedArgs = BaseArgs & {
Expand All @@ -225,6 +239,8 @@ export type SeedArgs = BaseArgs & {
include?: string[];
exclude?: string[];
vars?: Hash;
presets?: boolean;
varsPreset?: string;
code?: CodeMode;
cacheDir: string;
};
Expand All @@ -239,6 +255,8 @@ export type SeedConfig = Pick<BaseArgs, 'input' | 'strict' | 'quiet'> & {
files: string[];
skipped: [string, string][];
vars: Hash;
/** Apply presets.yaml to conditions; must match the translate run. */
presets: boolean;
code: CodeMode;
cacheDir: AbsolutePath;
} & ConfigDefaults;
Expand All @@ -261,6 +279,8 @@ export class Seed extends BaseProgram<SeedConfig, SeedArgs> {
options.include,
options.exclude,
options.vars,
options.presets,
options.varsPreset,
options.code,
options.config(YFM_CONFIG_FILENAME),
aiOptions.cacheDir,
Expand All @@ -286,10 +306,23 @@ export class Seed extends BaseProgram<SeedConfig, SeedArgs> {
const exclude = defined('exclude', args, config) || [];
const files = defined('files', args, config) || [];
const vars = resolveVars(config, args);
// Seeds must split files exactly like the translate run, so the
// switch follows the translate section when the seed section is silent.
const presets =
defined('presets', args, config) ?? (await inheritPresets(config, args));
checkPresetsTargets(presets, target);
// The seed section, then the translate section, then the .yfm root.
const varsPreset = await resolveVarsPreset(config, args, [
'translate.seed',
'translate',
'',
]);
// Seeds feed the LLM cache, so they follow the translate section
// of the config and then the LLM default.
const code =
resolveCodeMode(args, config) ?? (await inheritCodeMode(config)) ?? 'adaptive';
resolveCodeMode(args, config) ??
(await inheritCodeMode(config, args)) ??
'adaptive';
const cacheDir = defined('cacheDir', args, config);

if (!cacheDir) {
Expand All @@ -307,18 +340,20 @@ export class Seed extends BaseProgram<SeedConfig, SeedArgs> {
include,
exclude,
vars,
presets,
varsPreset,
code,
cacheDir: resolve(cacheDir),
});
});
}

async action() {
const {input, source, target: targets, vars, code, cacheDir} = this.config;
const {input, source, target: targets, code, cacheDir} = this.config;

this.logger.setup(this.config);

this.run = new Run(this.config);
this.run = new Run(this.config, {usePresets: this.config.presets});

await getBaseHooks(this).BeforeAnyRun.promise(this.run);
await getHooks(this).BeforeRun.promise(this.run);
Expand All @@ -335,7 +370,7 @@ export class Seed extends BaseProgram<SeedConfig, SeedArgs> {
files: Array.from(files),
sourceLanguage: source.language,
targetLanguage: target.language,
vars,
varsFor: (path) => this.run.vars.for(normalizePath(path)),
code,
cacheDir,
});
Expand Down
Loading
Loading