Skip to content

Commit 153cf73

Browse files
committed
feat(story): localize place names, currency and cultural notes per language in the Stories generator
1 parent 3cbb076 commit 153cf73

6 files changed

Lines changed: 1469 additions & 229 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ The tool reads `server/.env`, lets you select a supported target language and ca
5858

5959
The wizard first asks **what to generate** — course categories, the **practice word** pool, or **Story Reader stories**. Choosing practice words translates the English word list in `server/content/practice_words/_template.json` into the target language, writing `server/content/practice_words/<language>.json`. Translations are batched (`TRANSLATE_BATCH_SIZE` words per request), so a ~1000-word pool costs roughly 20 API calls rather than one per word.
6060

61-
Choosing **Stories** reads the hand-authored `server/content/stories/english.json`, translates each sentence and title English → target, then runs a reverse target → English pass to build a per-word glossary (part-of-speech left blank for machine glosses), writing `server/content/stories/<language>.json`. As with all jobs it never overwrites an existing file, so delete a language's short-seed story file first if you want to regenerate it from the longer English source.
61+
Choosing **Stories** reads the hand-authored `server/content/stories/english.json`, translates each sentence and title English → target, then runs a reverse target → English pass to build a per-word glossary. Each glossary entry borrows its part-of-speech (and an exact-match grammar note) from the English source glossary, and the file is written in the same compact one-line-per-entry layout as the hand-authored stories. Output goes to `server/content/stories/<language>.json`. As with all jobs it never overwrites an existing file, so delete a language's existing story file first if you want to regenerate it from the longer English source.
62+
63+
To stop MT from carrying English proper nouns straight through (e.g. a Spanish story still set in London), `scripts/libretranslate/story-localization.json` provides per-culture overrides keyed by source story id. `terms` swap culturally-specific words — place names and currency — so each language gets a native setting (the B1 letter becomes Barcelona / Paris / Berlin / Rome / Stockholm), and `culturalNote` replaces the explanatory note with one describing the target culture. The native spelling (e.g. `Roma`) is fed to MT and kept out of the glossary, while the English exonym (`Rome`) is shown as the reference; anything omitted from the map falls back to the English source.
6264

6365
The generator code is split for clarity under `scripts/libretranslate/`: `terminal-menu.ts` (generic interactive prompts), `content-generator.ts` (translation + JSON file IO), and `index.ts` (the wizard that wires them together).
6466

TODO.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,14 @@ a tooling path to scale authored content across languages.
404404
English-course story set and is the canonical translation source.
405405
- [x] **LibreTranslate Stories job.** `scripts/libretranslate` gained a "Stories" content type:
406406
forward (English → target) for sentences/titles plus a reverse (target → English) glossary pass
407-
(`translateStories` in `content-generator.ts`). Never overwrites existing files; `pos` left blank
408-
for machine glosses.
407+
(`translateStories` in `content-generator.ts`). Generated glossaries borrow `pos` (and exact-match
408+
`note`) from the English source glossary, and are written in the compact one-line-per-entry layout of
409+
the hand-authored files. Never overwrites existing files.
410+
- [x] **Per-culture localization.** `scripts/libretranslate/story-localization.json` swaps
411+
culturally-specific terms (place names, currency) and overrides the cultural note per target culture,
412+
so each language gets a native setting (the B1 letter is set in Barcelona/Paris/Berlin/Rome/Stockholm,
413+
not London) instead of carrying English proper nouns straight through MT. The native spelling is fed
414+
to MT and shielded from the glossary; the English exonym is kept as the reference.
409415
- [x] Tests: completion endpoint (idempotent, per-user, 404), loader `break`/optional-`pos`, updated
410416
content-stats counts; client Finish→complete + Read next flow.
411417

scripts/libretranslate/content-generator.ts

Lines changed: 212 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ const PRACTICE_WORDS_DIR = path.join(REPO_ROOT, "server", "content", "practice_w
1919
const PRACTICE_WORDS_TEMPLATE = path.join(PRACTICE_WORDS_DIR, "_template.json");
2020
const STORIES_DIR = path.join(REPO_ROOT, "server", "content", "stories");
2121
const STORIES_SOURCE = path.join(STORIES_DIR, "english.json");
22+
// Lives beside the tooling (not in STORIES_DIR — the loader validates every file there).
23+
const STORIES_LOCALIZATION = path.join(MODULE_DIR, "story-localization.json");
2224
export const PRACTICE_WORDS_ID = "practice_words";
2325
export const STORIES_ID = "stories";
2426
const SOURCE_LANGUAGE_ID = "english";
2527

2628
// Number of texts sent to LibreTranslate per /translate request. Batching keeps the
2729
// request count (and the rate-limit budget) low — e.g. ~1000 words become ~20 calls.
28-
export const TRANSLATE_BATCH_SIZE = 25;
30+
export const TRANSLATE_BATCH_SIZE = 50;
2931

3032
export interface TargetLanguage extends Choice {
3133
id: string;
@@ -593,6 +595,62 @@ function readStorySource(): StorySource[] {
593595
return parsed as StorySource[];
594596
}
595597

598+
// --- Per-language localization map -------------------------------------------
599+
//
600+
// MT preserves proper nouns and the English source is set in the anglosphere
601+
// (London, pounds, UK museums). To give each language a native setting we read a
602+
// small map keyed by source story id: `terms` swap culturally-specific words and
603+
// `culturalNote` overrides the explanatory note per target culture.
604+
605+
type LocalizedTerm = string | { en: string; target: string };
606+
607+
interface StoryLocalization {
608+
terms?: Record<string, Record<string, LocalizedTerm>>;
609+
culturalNote?: Record<string, { term: string; body: string }>;
610+
}
611+
612+
type LocalizationMap = Record<string, StoryLocalization>;
613+
614+
function readLocalizationMap(): LocalizationMap {
615+
if (!fs.existsSync(STORIES_LOCALIZATION)) return {};
616+
let parsed: unknown;
617+
try {
618+
parsed = JSON.parse(fs.readFileSync(STORIES_LOCALIZATION, "utf8"));
619+
} catch {
620+
throw new Error(`Story localization map is invalid JSON: ${relPath(STORIES_LOCALIZATION)}`);
621+
}
622+
return parsed && typeof parsed === "object" ? (parsed as LocalizationMap) : {};
623+
}
624+
625+
// `target` is the native spelling fed to MT (Roma); `en` is the exonym shown as the
626+
// English reference (Rome). Plain strings use the same value for both.
627+
const termEn = (value: LocalizedTerm): string => (typeof value === "string" ? value : value.en);
628+
const termTarget = (value: LocalizedTerm): string =>
629+
typeof value === "string" ? value : value.target;
630+
631+
function escapeRegExp(text: string): string {
632+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
633+
}
634+
635+
// Replace whole-word occurrences of each source term with its per-language value.
636+
// `useTarget` picks the native spelling (MT input) over the English exonym (reference).
637+
function applyStoryTerms(
638+
text: string,
639+
terms: StoryLocalization["terms"],
640+
libreCode: string,
641+
useTarget: boolean
642+
): string {
643+
if (!terms) return text;
644+
let out = text;
645+
for (const [source, perLang] of Object.entries(terms)) {
646+
const value = perLang[libreCode];
647+
if (value === undefined) continue;
648+
const replacement = useTarget ? termTarget(value) : termEn(value);
649+
out = out.replace(new RegExp(`\\b${escapeRegExp(source)}\\b`, "g"), replacement);
650+
}
651+
return out;
652+
}
653+
596654
// Forward (English) text count, used for the wizard's pre-run estimate. The
597655
// glossary pass adds more calls that can only be counted after translation.
598656
export function countStorySourceTexts(): number {
@@ -648,6 +706,108 @@ async function translateUnique(
648706
return map;
649707
}
650708

709+
interface WordMeta {
710+
pos: string;
711+
note?: string;
712+
}
713+
714+
interface GeneratedStory {
715+
id: string;
716+
level: string;
717+
title: string;
718+
titleEn: string;
719+
theme: string;
720+
category: string;
721+
sentences: { target: string; en: string; break?: boolean }[];
722+
glossary: Record<string, { g: string; pos: string; note?: string }>;
723+
culturalNote: { term: string; body: string };
724+
}
725+
726+
// The hand-authored English glossary carries rich part-of-speech labels and grammar
727+
// notes. Since generated stories are translations of the *same* English sentences,
728+
// a reverse-translated word's English gloss usually matches an English glossary key —
729+
// so we borrow its pos/note rather than leaving pos blank.
730+
function buildEnglishLexicon(stories: StorySource[]): Map<string, WordMeta> {
731+
const lexicon = new Map<string, WordMeta>();
732+
for (const story of stories) {
733+
for (const [word, entry] of Object.entries(story.glossary || {})) {
734+
const key = word.toLowerCase().trim();
735+
if (!key || lexicon.has(key)) continue;
736+
lexicon.set(key, { pos: String(entry.pos || ""), note: entry.note });
737+
}
738+
}
739+
return lexicon;
740+
}
741+
742+
const LEADING_ARTICLE_RE = /^(?:to|a|an|the)\s+/;
743+
744+
// Resolve an English gloss to a part-of-speech (and, on an exact match, a note).
745+
// Falls back through comma/slash alternatives and article-stripped forms for pos.
746+
function lookupEnglishMeta(
747+
lexicon: Map<string, WordMeta>,
748+
gloss: string
749+
): { pos: string; note?: string } | null {
750+
const full = gloss.toLowerCase().trim();
751+
const exact = lexicon.get(full);
752+
if (exact) return { pos: exact.pos, note: exact.note };
753+
const pieces = full.split(/\s*(?:,|\/|\bor\b)\s*/).map((piece) => piece.trim()).filter(Boolean);
754+
for (const piece of pieces) {
755+
for (const candidate of [piece, piece.replace(LEADING_ARTICLE_RE, "").trim()]) {
756+
const meta = lexicon.get(candidate);
757+
if (meta) return { pos: meta.pos }; // pos only — a note for one sense may not fit a partial match
758+
}
759+
}
760+
return null;
761+
}
762+
763+
// Serialise stories to match the hand-authored layout: each sentence and glossary
764+
// entry stays compact on a single line, while the surrounding structure is indented.
765+
export function formatStoriesJson(stories: GeneratedStory[]): string {
766+
const jstr = (value: unknown): string => JSON.stringify(value);
767+
const lines: string[] = ["["];
768+
stories.forEach((story, storyIndex) => {
769+
const storyComma = storyIndex < stories.length - 1 ? "," : "";
770+
lines.push(" {");
771+
lines.push(` "id": ${jstr(story.id)},`);
772+
lines.push(` "level": ${jstr(story.level)},`);
773+
lines.push(` "title": ${jstr(story.title)},`);
774+
lines.push(` "titleEn": ${jstr(story.titleEn)},`);
775+
lines.push(` "theme": ${jstr(story.theme)},`);
776+
lines.push(` "category": ${jstr(story.category)},`);
777+
778+
lines.push(` "sentences": [`);
779+
story.sentences.forEach((sentence, index) => {
780+
const comma = index < story.sentences.length - 1 ? "," : "";
781+
const parts = [`"target": ${jstr(sentence.target)}`, `"en": ${jstr(sentence.en)}`];
782+
if (sentence.break) parts.push(`"break": true`);
783+
lines.push(` { ${parts.join(", ")} }${comma}`);
784+
});
785+
lines.push(` ],`);
786+
787+
const glossEntries = Object.entries(story.glossary);
788+
if (glossEntries.length === 0) {
789+
lines.push(` "glossary": {},`);
790+
} else {
791+
lines.push(` "glossary": {`);
792+
glossEntries.forEach(([word, entry], index) => {
793+
const comma = index < glossEntries.length - 1 ? "," : "";
794+
const parts = [`"g": ${jstr(entry.g)}`, `"pos": ${jstr(entry.pos)}`];
795+
if (entry.note) parts.push(`"note": ${jstr(entry.note)}`);
796+
lines.push(` ${jstr(word)}: { ${parts.join(", ")} }${comma}`);
797+
});
798+
lines.push(` },`);
799+
}
800+
801+
lines.push(` "culturalNote": {`);
802+
lines.push(` "term": ${jstr(story.culturalNote.term)},`);
803+
lines.push(` "body": ${jstr(story.culturalNote.body)}`);
804+
lines.push(` }`);
805+
lines.push(` }${storyComma}`);
806+
});
807+
lines.push("]");
808+
return lines.join("\n");
809+
}
810+
651811
export interface TranslateStoriesResult {
652812
outputPath: string;
653813
elapsed: number;
@@ -664,14 +824,43 @@ export async function translateStories(
664824
onProgress?: (progress: TranslateProgress) => void
665825
): Promise<TranslateStoriesResult> {
666826
const stories = readStorySource();
827+
const localization = readLocalizationMap();
828+
const libreCode = config.target;
667829
const stats: CategoryStats = { callCount: 0, callDurations: [], startedAt: Date.now() };
668830
const unresolved: string[] = [];
669831

670-
// Pass 1 — English → target for every sentence and title.
832+
// Pre-localize each story for this culture: `src` is the native-spelled English
833+
// fed to MT, `en` is the exonym shown as the reference, cultural notes are swapped,
834+
// and native place spellings are collected so they never become glossary entries.
835+
const prepared = stories.map((story) => {
836+
const loc = localization[story.id] || {};
837+
const toSource = (text: string) => applyStoryTerms(text, loc.terms, libreCode, true);
838+
const toRef = (text: string) => applyStoryTerms(text, loc.terms, libreCode, false);
839+
const sentences = story.sentences.map((sentence) => ({
840+
src: toSource(sentence.en),
841+
en: toRef(sentence.en),
842+
break: sentence.break
843+
}));
844+
const skip = new Set<string>();
845+
for (const perLang of Object.values(loc.terms || {})) {
846+
const value = perLang[libreCode];
847+
if (value !== undefined) for (const token of storyTokens(termTarget(value))) skip.add(token);
848+
}
849+
return {
850+
story,
851+
titleSource: toSource(story.titleEn),
852+
titleRef: toRef(story.titleEn),
853+
sentences,
854+
culturalNote: loc.culturalNote?.[libreCode] || story.culturalNote,
855+
skip
856+
};
857+
});
858+
859+
// Pass 1 — English → target for every (localized) sentence and title.
671860
const forwardTexts = new Set<string>();
672-
for (const story of stories) {
673-
forwardTexts.add(story.titleEn);
674-
for (const sentence of story.sentences) forwardTexts.add(sentence.en);
861+
for (const item of prepared) {
862+
forwardTexts.add(item.titleSource);
863+
for (const sentence of item.sentences) forwardTexts.add(sentence.src);
675864
}
676865
const forwardList = [...forwardTexts];
677866
const reportForward = (done: number) =>
@@ -689,15 +878,15 @@ export async function translateStories(
689878

690879
// Build target sentences and collect the unique target tokens to gloss.
691880
const targetTokens = new Set<string>();
692-
const builtStories = stories.map((story) => {
693-
const sentences = story.sentences.map((sentence) => {
694-
const target = forwardMap.get(sentence.en) || sentence.en;
881+
const builtStories = prepared.map((item) => {
882+
const sentences = item.sentences.map((sentence) => {
883+
const target = forwardMap.get(sentence.src) || sentence.src;
695884
for (const token of storyTokens(target)) targetTokens.add(token);
696885
const out: { target: string; en: string; break?: boolean } = { target, en: sentence.en };
697886
if (sentence.break) out.break = true;
698887
return out;
699888
});
700-
return { story, sentences };
889+
return { item, sentences };
701890
});
702891

703892
// Pass 2 — target → English for each glossary token.
@@ -715,33 +904,39 @@ export async function translateStories(
715904
tokenList, config, pacer, stats, config.target, "en", unresolved, reportReverse
716905
);
717906

718-
const result = builtStories.map(({ story, sentences }) => {
719-
const glossary: Record<string, { g: string; pos: string }> = {};
907+
const englishLexicon = buildEnglishLexicon(stories);
908+
const result: GeneratedStory[] = builtStories.map(({ item, sentences }) => {
909+
const { story } = item;
910+
const glossary: Record<string, { g: string; pos: string; note?: string }> = {};
720911
for (const sentence of sentences) {
721912
for (const token of storyTokens(sentence.target)) {
722-
if (glossary[token]) continue;
913+
if (glossary[token] || item.skip.has(token)) continue;
723914
const gloss = glossMap.get(token) || "";
724915
// Skip words that translated to themselves (proper nouns, unresolved).
725916
if (!gloss || gloss.toLowerCase() === token) continue;
726-
glossary[token] = { g: gloss, pos: "" };
917+
// Borrow pos (and an exact-match note) from the English source glossary.
918+
const meta = lookupEnglishMeta(englishLexicon, gloss);
919+
const entry: { g: string; pos: string; note?: string } = { g: gloss, pos: meta?.pos ?? "" };
920+
if (meta?.note) entry.note = meta.note;
921+
glossary[token] = entry;
727922
}
728923
}
729924
return {
730925
id: rewriteId(story.id, language),
731926
level: story.level,
732-
title: forwardMap.get(story.titleEn) || story.titleEn,
733-
titleEn: story.titleEn,
927+
title: forwardMap.get(item.titleSource) || item.titleSource,
928+
titleEn: item.titleRef,
734929
theme: story.theme,
735930
category: story.category,
736931
sentences,
737932
glossary,
738-
culturalNote: story.culturalNote
933+
culturalNote: item.culturalNote
739934
};
740935
});
741936

742937
const outputPath = getOutputPath(language, STORIES_ID);
743938
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
744-
fs.writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
939+
fs.writeFileSync(outputPath, `${formatStoriesJson(result)}\n`, "utf8");
745940

746941
return {
747942
outputPath,

0 commit comments

Comments
 (0)