Files
learnharness/src/core/i18n.ts
T

78 lines
5.7 KiB
TypeScript

/** Pure localization shared by browser, server, exports, and agent configuration. */
import en from "./locales/en.json";
import ru from "./locales/ru.json";
export type Language = "en" | "ru";
export type SttLanguage = Language | "multi";
export type TranslationParams = Record<string, string | number | null | undefined>;
export type Translator = (message: string | null | undefined, params?: TranslationParams) => string;
export function isLanguage(value: unknown): value is Language { return value === "en" || value === "ru"; }
export function isSttLanguage(value: unknown): value is SttLanguage { return isLanguage(value) || value === "multi"; }
/** The preferred system language wins; unsupported preferences resolve to English. */
export function systemLanguage(languages: string | readonly string[] | null | undefined): Language {
const first = typeof languages === "string"
? languages.split(",").map((part, index) => {
const [tag = "", ...options] = part.trim().split(";");
const q = options.find((option) => option.trim().startsWith("q="));
return { tag, q: q ? Number(q.trim().slice(2)) : 1, index };
}).filter((item) => Number.isFinite(item.q) && item.q > 0).sort((a, b) => b.q - a.q || a.index - b.index)[0]?.tag
: languages?.[0];
return /^ru(?:-|_|$)/i.test(first ?? "") ? "ru" : "en";
}
export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language {
return isLanguage(preference) ? preference : systemLanguage(system);
}
export function resolveSttLanguage(preference: unknown, system?: string | readonly string[] | null): SttLanguage {
return isSttLanguage(preference) ? preference : systemLanguage(system);
}
export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; }
type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>;
const catalog: Record<string, Entry> = ru;
// Some protocol errors arrive as preformatted English. Match only cataloged
// app-authored templates; arbitrary provider diagnostics remain untouched.
const canonicalTemplates = Object.keys(catalog).filter((key) => /\{v\d+\}/.test(key) && key.replace(/\{\w+\}/g, "").replace(/[^a-zA-Z]/g, "").length >= 8).map((key) => {
const names: string[] = [];
const pattern = key.split(/(\{v\d+\})/g).map((part) => {
if (/^\{v\d+\}$/.test(part)) { names.push(part.slice(1, -1)); return "([\\s\\S]*?)"; }
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}).join("");
return { key, names, regex: new RegExp(`^${pattern}$`) };
});
export function translate(language: Language, message: string | null | undefined, params: TranslationParams = {}): string {
if (message == null) return "";
if (language === "ru" && message.startsWith("{")) {
try { const body = JSON.parse(message) as { error?: unknown }; if (typeof body.error === "string") return translate(language, body.error, params); } catch {}
}
const entry = language === "ru" ? catalog[message] : (en as Record<string, Entry>)[message];
if (language === "ru" && entry === undefined && Object.keys(params).length === 0) {
for (const template of canonicalTemplates) {
const match = template.regex.exec(message);
if (match) return translate(language, template.key, Object.fromEntries(template.names.map((name, index) => [name, match[index + 1] ?? ""])));
}
}
const text = typeof entry === "string" ? entry : entry
? entry[new Intl.PluralRules(localeTag(language)).select(Number(params.count ?? 0))] ?? entry.other ?? message
: message;
return text.replace(/\{(\w+)\}/g, (match, key: string) => Object.hasOwn(params, key) ? String(params[key] ?? "") : match);
}
export function translator(language: Language): Translator { return (message, params) => translate(language, message, params); }
export function learningLanguageInstruction(language: Language): string {
return language === "ru"
? "LANGUAGE: Use Russian for all learner-facing content: teaching, questions and answer choices, plans and titles, practice statements, hints, verdicts and solutions, research summaries, knowledge-graph propositions, and all text in generated diagrams, figures and interactive demos. Use natural Russian and address the learner as вы. Preserve code, mathematical notation, tool names, schema keys, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary."
: "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
}
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
export function dictationUrl(language: SttLanguage, preferredLanguages: readonly Language[] = []): string {
const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
if (language === "multi") {
for (const preferred of new Set(preferredLanguages)) if (isLanguage(preferred)) params.append("language_hint", preferred);
}
return `wss://api.deepgram.com/v1/listen?${params}`;
}
/** Count labels with natural Russian inflections; English keeps its original singular/plural wording. */
export function countLabel(language: Language, count: number, singular: string, plural: string): string {
return translate(language, `{count} ${language === "ru" ? plural : count === 1 ? singular : plural}`, { count });
}