From c032726fde8738d0df0f3a0c5c56d4992dad7965 Mon Sep 17 00:00:00 2001 From: Ivor Berry <11559977+ivorb@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:11:04 -0500 Subject: [PATCH 1/4] Add translate CLI with provider interface, validation and tests --- README.md | 39 +++- tools/llm-provider.mjs | 174 ++++++++++++++++ tools/translate-core.mjs | 356 +++++++++++++++++++++++++++++++++ tools/translate.mjs | 213 ++++++++++++++++++++ tools/translate.test.mjs | 418 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1199 insertions(+), 1 deletion(-) create mode 100644 tools/llm-provider.mjs create mode 100644 tools/translate-core.mjs create mode 100644 tools/translate.mjs create mode 100644 tools/translate.test.mjs diff --git a/README.md b/README.md index 2318e4b..c76d5fa 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,10 @@ decks-as-code/ │ ├── build-manifest.mjs regenerates decks.json │ ├── build-site.mjs builds dist/: offline site plus single-file decks │ ├── import-pptx.mjs converts a .pptx into a Markdown deck folder -│ └── pptx-to-html.mjs converts a .pptx into an HTML deck folder (pptx-html.mjs) +│ ├── pptx-to-html.mjs converts a .pptx into an HTML deck folder (pptx-html.mjs) +│ ├── translate.mjs translates a deck's strings.json with an LLM +│ ├── translate-core.mjs hashing, validation, batching, retry (used by translate) +│ └── llm-provider.mjs provider interface: copilot, openai-compatible, mock └── decks/ └── / ├── slides.md the deck. The only file you edit day to day. @@ -185,6 +188,40 @@ table, so nothing translates them. --- +## Translate a deck + +`tools/translate.mjs` translates a deck's string table (`decks//strings.json`, +see [`docs/string-table.md`](docs/string-table.md)) into `strings..json` files. +Node 18+, no dependencies. + +```powershell +node tools\translate.mjs my-talk --to de,pt-BR,ar # translate +node tools\translate.mjs my-talk --to de --dry-run # show what would change +node tools\translate.mjs my-talk --to de --force # retranslate everything +node tools\translate.mjs my-talk --to de --provider mock # offline fake, no LLM +node --test # run the tests (no network) +``` + +- **Incremental.** Only strings that are missing or whose source text changed (the + `src` hash no longer matches) are sent to the model. `--force` redoes all of them. +- **Validated.** Every translation must keep the same inline tags (in matching pairs), + keep `href` values, and be non-empty. A failing batch is retried once with the error + fed back; strings that still fail are skipped and listed, the file stays valid, and + the viewer falls back to the source text for them. Exit code `2` means "done, but + some strings were skipped"; re-run to retry them. +- **Provider.** `--provider openai` (default) talks to any OpenAI-compatible + `/chat/completions` endpoint. Configure it with environment variables (the key is + never a flag, never logged, never written): `LLM_API_KEY` (or `OPENAI_API_KEY`), + `LLM_MODEL` (or `--model`), and optionally `LLM_BASE_URL` (or `--base-url`; default + `https://api.openai.com/v1`). For Azure OpenAI / Foundry use the + `https://.openai.azure.com/openai/v1` base URL, the deployment name as the + model, and `LLM_AUTH_HEADER=api-key`. Set `LLM_JSON_MODE=off` if a host rejects + `response_format`. 429s honour `retry-after`. Providers live in + `tools/llm-provider.mjs`; adding one means returning `{ name, complete({messages}) }`. + GitHub Models was retired on 2026-07-30, so it is not supported. + +--- + ## Writing slides `slides.md` is ordinary Markdown with three separator conventions: diff --git a/tools/llm-provider.mjs b/tools/llm-provider.mjs new file mode 100644 index 0000000..dc9506f --- /dev/null +++ b/tools/llm-provider.mjs @@ -0,0 +1,174 @@ +// LLM provider interface for tools/translate.mjs. +// +// A provider is any object shaped like: +// { name: string, complete({ messages, signal? }) => Promise } +// where messages are OpenAI-style { role: 'system' | 'user' | 'assistant', content }. +// Providers throw ProviderError; `fatal` means retrying or continuing is pointless +// (bad credentials, unknown model, ...), so the CLI aborts instead of skipping strings. + +export class ProviderError extends Error { + constructor(message, { status, fatal = false } = {}) { + super(message); + this.name = 'ProviderError'; + this.status = status; + this.fatal = fatal; + } +} + +export const PROVIDERS = ['openai', 'mock']; + +const DEFAULT_BASE_URL = 'https://api.openai.com/v1'; +const REQUEST_TIMEOUT_MS = 120_000; +const MAX_RATE_LIMIT_RETRIES = 5; +const MAX_TRANSIENT_RETRIES = 3; +const MAX_WAIT_MS = 120_000; + +const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function createProvider(name, options = {}) { + switch (name) { + case 'openai': + return createOpenAICompatibleProvider(options); + case 'mock': + return createMockProvider(); + default: + throw new Error(`Unknown provider "${name}". Available: ${PROVIDERS.join(', ')}`); + } +} + +// Parses a Retry-After header (delta-seconds or HTTP-date) into milliseconds. +export function parseRetryAfter(value, now = Date.now()) { + if (value == null || value === '') return null; + const seconds = Number(value); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(value); + return Number.isNaN(date) ? null : Math.max(0, date - now); +} + +function isLocalhost(hostname) { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'; +} + +// Any OpenAI-compatible /chat/completions endpoint: OpenAI, Azure OpenAI / Foundry +// (…/openai/v1 with LLM_AUTH_HEADER=api-key), Ollama, vLLM, LiteLLM, ... +// Config comes from options first, then environment: +// baseUrl LLM_BASE_URL | OPENAI_BASE_URL (default https://api.openai.com/v1) +// apiKey LLM_API_KEY | OPENAI_API_KEY (environment only, never a CLI flag) +// model LLM_MODEL (required) +// LLM_AUTH_HEADER "bearer" (default) or "api-key" (Azure) +// LLM_JSON_MODE "off" to omit response_format (for hosts that reject it) +function createOpenAICompatibleProvider(options) { + const env = options.env ?? process.env; + const fetchImpl = options.fetch ?? globalThis.fetch; + const sleep = options.sleep ?? defaultSleep; + + const baseUrl = (options.baseUrl || env.LLM_BASE_URL || env.OPENAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); + const apiKey = env.LLM_API_KEY || env.OPENAI_API_KEY || ''; + const model = options.model || env.LLM_MODEL || ''; + const authHeader = (env.LLM_AUTH_HEADER || 'bearer').toLowerCase(); + const jsonMode = (env.LLM_JSON_MODE || 'on').toLowerCase() !== 'off'; + + if (!apiKey) { + throw new ProviderError('No API key. Set LLM_API_KEY (or OPENAI_API_KEY) in the environment.', { fatal: true }); + } + if (!model) { + throw new ProviderError('No model. Pass --model or set LLM_MODEL.', { fatal: true }); + } + if (!['bearer', 'api-key'].includes(authHeader)) { + throw new ProviderError('LLM_AUTH_HEADER must be "bearer" or "api-key".', { fatal: true }); + } + let url; + try { + url = new URL(`${baseUrl}/chat/completions`); + } catch { + throw new ProviderError(`Invalid base URL: ${baseUrl}`, { fatal: true }); + } + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLocalhost(url.hostname))) { + throw new ProviderError('Base URL must be https (http is only allowed for localhost); refusing to send the API key in clear text.', { fatal: true }); + } + + const redact = (text) => (apiKey ? String(text).split(apiKey).join('[redacted]') : String(text)); + + const headers = { 'content-type': 'application/json' }; + if (authHeader === 'api-key') headers['api-key'] = apiKey; + else headers.authorization = `Bearer ${apiKey}`; + + async function post(body, signal) { + let rateLimited = 0; + let transient = 0; + for (;;) { + let res; + try { + res = await fetchImpl(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + if (signal?.aborted) throw err; + if (++transient > MAX_TRANSIENT_RETRIES) { + throw new ProviderError(`Network error: ${redact(err.message)}`); + } + await sleep(Math.min(1000 * 2 ** transient, MAX_WAIT_MS)); + continue; + } + + if (res.ok) return res.json(); + + const detail = redact((await res.text().catch(() => '')).slice(0, 500)); + if (res.status === 429) { + if (++rateLimited > MAX_RATE_LIMIT_RETRIES) { + throw new ProviderError(`Rate limited (429) after ${MAX_RATE_LIMIT_RETRIES} retries`, { status: 429 }); + } + const wait = parseRetryAfter(res.headers.get('retry-after')) ?? 1000 * 2 ** rateLimited; + await sleep(Math.min(wait, MAX_WAIT_MS)); + continue; + } + if (res.status >= 500) { + if (++transient > MAX_TRANSIENT_RETRIES) { + throw new ProviderError(`HTTP ${res.status}: ${detail}`, { status: res.status }); + } + await sleep(Math.min(1000 * 2 ** transient, MAX_WAIT_MS)); + continue; + } + const fatal = [401, 403, 404].includes(res.status); + throw new ProviderError(`HTTP ${res.status}: ${detail}`, { status: res.status, fatal }); + } + } + + return { + name: 'openai', + async complete({ messages, signal }) { + const body = { model, messages }; + if (jsonMode) body.response_format = { type: 'json_object' }; + const data = await post(body, signal); + const choice = data?.choices?.[0]; + if (!choice || typeof choice.message?.content !== 'string') { + throw new ProviderError('Unexpected response shape from provider (no choices[0].message.content)'); + } + if (choice.finish_reason === 'length') { + throw new ProviderError('Response was truncated (finish_reason=length); try a smaller --batch-size'); + } + return choice.message.content; + }, + }; +} + +// Deterministic offline fake: wraps each string as "[: ]", which keeps +// every tag and href intact. Understands the payload built by translate-core.mjs. +function createMockProvider() { + return { + name: 'mock', + async complete({ messages }) { + const last = messages[messages.length - 1]; + // The payload is always the last line of the last message, as compact JSON. + const payload = JSON.parse(last.content.slice(last.content.lastIndexOf('\n') + 1)); + const translations = {}; + for (const item of payload.strings) { + translations[item.id] = `[${payload.targetLang}: ${item.text}]`; + } + return JSON.stringify({ translations }); + }, + }; +} diff --git a/tools/translate-core.mjs b/tools/translate-core.mjs new file mode 100644 index 0000000..da57607 --- /dev/null +++ b/tools/translate-core.mjs @@ -0,0 +1,356 @@ +// Pure(ish) translation pipeline for decks//strings.json. See docs/string-table.md. +// No I/O except translateLanguage's optional logger; the CLI owns files and exit codes. + +const ALLOWED_TAGS = new Set(['b', 'i', 'u', 'sup', 'sub', 'br', 'a', 'code']); +const DEFAULT_MAX_ITEMS = 30; +const DEFAULT_MAX_CHARS = 6000; + +// FNV-1a 32-bit over UTF-8 bytes, lowercase hex, 8 chars. +export function fnv1a(text) { + let hash = 0x811c9dc5; + for (const byte of Buffer.from(String(text), 'utf8')) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +// ---- inline markup validation ------------------------------------------------- + +const TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)([^<>]*)>/g; +const HREF_RE = /^\s+href\s*=\s*(?:"([^"]*)"|'([^']*)')\s*$/; + +// Returns { tags, hrefs, errors } for an HTML fragment. `tags` entries look like +// "b", "/b" or "br", sorted, so two fragments can be compared as multisets. +export function analyzeMarkup(html) { + const tags = []; + const hrefs = []; + const errors = []; + const stack = []; + + for (const match of html.matchAll(TAG_RE)) { + const closing = match[1] === '/'; + const name = match[2].toLowerCase(); + const attrs = match[3]; + + if (!ALLOWED_TAGS.has(name)) { + errors.push(`disallowed tag <${closing ? '/' : ''}${match[2]}>`); + continue; + } + if (name === 'br') { + if (closing) errors.push('
is not valid'); + else if (!/^\s*\/?\s*$/.test(attrs)) errors.push('
must not have attributes'); + else tags.push('br'); + continue; + } + if (closing) { + if (attrs.trim()) errors.push(` must not have attributes`); + tags.push(`/${name}`); + if (stack.pop() !== name) errors.push(`unbalanced or misnested `); + continue; + } + if (name === 'a') { + const href = HREF_RE.exec(attrs); + if (!href) errors.push(' must have exactly one attribute: href="..."'); + else hrefs.push(href[1] ?? href[2]); + } else if (attrs.trim()) { + errors.push(`<${name}> must not have attributes`); + } + tags.push(name); + stack.push(name); + } + if (stack.length) errors.push(`unclosed tag(s): ${stack.map((t) => `<${t}>`).join('')}`); + + if (html.replace(TAG_RE, '').includes('<')) errors.push('contains an unescaped "<" (use <)'); + + return { tags: tags.sort(), hrefs: hrefs.sort(), errors }; +} + +function countBy(list) { + const counts = new Map(); + for (const item of list) counts.set(item, (counts.get(item) ?? 0) + 1); + return counts; +} + +function diffCounts(expected, actual, label) { + const errors = []; + const e = countBy(expected); + const a = countBy(actual); + for (const key of new Set([...e.keys(), ...a.keys()])) { + if ((e.get(key) ?? 0) !== (a.get(key) ?? 0)) { + errors.push(`${label} ${JSON.stringify(key)}: expected ${e.get(key) ?? 0}, got ${a.get(key) ?? 0}`); + } + } + return errors; +} + +// Returns a list of problems; empty means the translation is acceptable. +export function validateTranslation(sourceText, translated) { + if (typeof translated !== 'string' || !translated.trim()) return ['text is empty or not a string']; + + const source = analyzeMarkup(sourceText); + const result = analyzeMarkup(translated); + const errors = []; + + // Only enforce well-formedness when the source itself is well-formed. + if (!source.errors.length) errors.push(...result.errors); + errors.push(...diffCounts(source.tags, result.tags, 'tag')); + errors.push(...diffCounts(source.hrefs, result.hrefs, 'href')); + return errors; +} + +// ---- planning and batching ---------------------------------------------------- + +// Decides which source strings need translating. +// Returns { kept: Map, todo: SourceString[], dropped: string[] }. +export function planLanguage(table, existing, { force = false } = {}) { + const kept = new Map(); + const todo = []; + const prior = existing?.strings ?? {}; + + for (const item of table.strings) { + if (!item.text.trim()) continue; + const src = fnv1a(item.text); + const entry = prior[item.id]; + const reusable = + !force && + entry && + entry.src === src && + typeof entry.text === 'string' && + validateTranslation(item.text, entry.text).length === 0; + if (reusable) kept.set(item.id, { text: entry.text, src }); + else todo.push(item); + } + const sourceIds = new Set(table.strings.map((s) => s.id)); + const dropped = Object.keys(prior).filter((id) => !sourceIds.has(id)); + return { kept, todo, dropped }; +} + +// Groups consecutive strings (document order keeps a slide together) into batches. +export function makeBatches(items, { maxItems = DEFAULT_MAX_ITEMS, maxChars = DEFAULT_MAX_CHARS } = {}) { + const batches = []; + let current = []; + let chars = 0; + for (const item of items) { + const size = item.text.length + (item.context?.length ?? 0) + item.id.length; + if (current.length && (current.length >= maxItems || chars + size > maxChars)) { + batches.push(current); + current = []; + chars = 0; + } + current.push(item); + chars += size; + } + if (current.length) batches.push(current); + return batches; +} + +// ---- prompting ---------------------------------------------------------------- + +function languageName(code) { + try { + return new Intl.DisplayNames(['en'], { type: 'language' }).of(code) ?? code; + } catch { + return code; + } +} + +function payloadOf(items, sourceLang, targetLang) { + return { + sourceLang, + targetLang, + strings: items.map(({ id, slide, kind, context, text }) => { + const out = { id, slide, kind }; + if (context) out.context = context; + out.text = text; + return out; + }), + }; +} + +function systemPrompt(sourceLang, targetLang) { + return [ + `You are a professional translator localizing slide deck text from ${languageName(sourceLang)} (${sourceLang}) into ${languageName(targetLang)} (${targetLang}).`, + 'You receive a JSON object whose "strings" array holds slide text. Each item has: id, slide (1-based), kind, optional context (usually the slide title) and text.', + 'Use slide, context and neighbouring items to pick the right sense of short fragments (e.g. "Save", "Tool"), but translate only the "text" of each item.', + 'Kinds: "text" = slide text; "table" = table cell (keep it short, match the column style); "alt" = image alt text (a concise description for screen readers); "notes" = speaker notes (natural spoken style).', + 'The text is an HTML fragment. Keep every tag (, , , , , ,
,
) and close each one; move tags to follow the new word order if needed. Never translate the contents of .', + 'Never change, translate or drop an href value. Keep entities (< > &) escaped. Do not add tags, markdown or commentary.', + 'Leave product names, code, URLs and untranslatable proper nouns as they are. Do not leave a string empty.', + 'Reply with JSON only, exactly: {"translations": {"": "", ...}} with one entry for every requested id and no other ids.', + ].join('\n'); +} + +export function buildMessages(items, sourceLang, targetLang) { + return [ + { role: 'system', content: systemPrompt(sourceLang, targetLang) }, + { role: 'user', content: `Translate these strings.\n${JSON.stringify(payloadOf(items, sourceLang, targetLang))}` }, + ]; +} + +function buildRetryMessages(messages, rawReply, items, failures, sourceLang, targetLang) { + const problems = items.map((item) => `- ${item.id}: ${failures.get(item.id).join('; ')}`).join('\n'); + const out = [...messages]; + if (rawReply != null) out.push({ role: 'assistant', content: rawReply }); + out.push({ + role: 'user', + content: + `Your previous reply had problems:\n${problems}\n` + + 'Fix them and reply with JSON only, containing translations for exactly these strings.\n' + + JSON.stringify(payloadOf(items, sourceLang, targetLang)), + }); + return out; +} + +// Extracts {id: text} from a model reply. Throws Error with a message fit for feeding back. +export function parseResponse(raw) { + let body = String(raw).trim(); + const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(body); + if (fence) body = fence[1]; + let data; + try { + data = JSON.parse(body); + } catch { + const start = body.indexOf('{'); + const end = body.lastIndexOf('}'); + try { + data = JSON.parse(body.slice(start, end + 1)); + } catch { + throw new Error('reply is not valid JSON'); + } + } + const translations = data?.translations; + if (!translations || typeof translations !== 'object' || Array.isArray(translations)) { + throw new Error('reply must be {"translations": {"": ""}}'); + } + return translations; +} + +// Checks a parsed reply against the requested items. +// Returns { ok: Map, bad: Map, batchError?: string }. +function evaluate(translations, items) { + const requested = new Set(items.map((i) => i.id)); + const extras = Object.keys(translations).filter((id) => !requested.has(id)); + if (extras.length) { + const message = `unexpected id(s) not requested: ${extras.join(', ')}`; + return { ok: new Map(), bad: new Map(items.map((i) => [i.id, [message]])), batchError: message }; + } + const ok = new Map(); + const bad = new Map(); + for (const item of items) { + if (!Object.hasOwn(translations, item.id)) { + bad.set(item.id, ['missing from reply']); + continue; + } + const problems = validateTranslation(item.text, translations[item.id]); + if (problems.length) bad.set(item.id, problems); + else ok.set(item.id, translations[item.id]); + } + return { ok, bad }; +} + +// Translates one batch; failed strings get exactly one retry with the errors fed back. +// Returns { translated: Map, failed: Map }. Fatal provider errors propagate. +export async function translateBatch({ provider, items, sourceLang, targetLang, signal }) { + const messages = buildMessages(items, sourceLang, targetLang); + const translated = new Map(); + let pending = items; + let failures = new Map(); + let conversation = messages; + let rawReply = null; + + for (let attempt = 0; attempt < 2 && pending.length; attempt++) { + if (attempt === 1) { + conversation = buildRetryMessages(messages, rawReply, pending, failures, sourceLang, targetLang); + } + rawReply = null; + try { + rawReply = await provider.complete({ messages: conversation, signal }); + const result = evaluate(parseResponse(rawReply), pending); + for (const [id, text] of result.ok) translated.set(id, text); + failures = result.bad; + } catch (err) { + if (err?.fatal) throw err; + failures = new Map(pending.map((i) => [i.id, [err.message]])); + } + pending = pending.filter((i) => failures.has(i.id)); + } + return { translated, failed: new Map(pending.map((i) => [i.id, failures.get(i.id)])) }; +} + +// ---- one language end to end -------------------------------------------------- + +// Returns { file, stats } where `file` is the new strings..json content (object). +export async function translateLanguage({ table, existing, lang, provider, force = false, batchOptions, log = () => {} }) { + const { kept, todo, dropped } = planLanguage(table, existing, { force }); + const entries = new Map(kept); + const failed = new Map(); + const batches = makeBatches(todo, batchOptions); + + for (const [index, items] of batches.entries()) { + log(` batch ${index + 1}/${batches.length} (${items.length} strings)`); + const result = await translateBatch({ + provider, + items, + sourceLang: table.sourceLang, + targetLang: lang, + }); + for (const item of items) { + if (result.translated.has(item.id)) { + entries.set(item.id, { text: result.translated.get(item.id), src: fnv1a(item.text) }); + } + } + for (const [id, errors] of result.failed) failed.set(id, errors); + } + + // Source order, so diffs stay stable. + const strings = {}; + for (const item of table.strings) { + if (entries.has(item.id)) strings[item.id] = entries.get(item.id); + } + return { + file: { version: 1, lang, sourceLang: table.sourceLang, strings }, + stats: { + kept: kept.size, + translated: todo.length - failed.size, + failed, + dropped, + batches: batches.length, + }, + }; +} + +// One entry per line keeps diffs reviewable (matches docs/examples/strings.de.json). +export function serializeTranslation(file) { + const ids = Object.keys(file.strings); + const lines = ids.map( + (id, i) => + ` ${JSON.stringify(id)}: { "text": ${JSON.stringify(file.strings[id].text)}, "src": ${JSON.stringify(file.strings[id].src)} }${i < ids.length - 1 ? ',' : ''}`, + ); + return [ + '{', + ` "version": ${file.version},`, + ` "lang": ${JSON.stringify(file.lang)},`, + ` "sourceLang": ${JSON.stringify(file.sourceLang)},`, + ids.length ? ' "strings": {' : ' "strings": {}', + ...(ids.length ? [...lines, ' }'] : []), + '}', + '', + ].join('\n'); +} + +// Throws a descriptive Error if the source table breaks the v1 contract. +export function validateSourceTable(table) { + if (!table || typeof table !== 'object') throw new Error('strings.json is not an object'); + if (table.version !== 1) throw new Error(`Unsupported strings.json version: ${table.version} (expected 1)`); + if (typeof table.sourceLang !== 'string' || !table.sourceLang) throw new Error('strings.json: missing sourceLang'); + if (!Array.isArray(table.strings)) throw new Error('strings.json: "strings" must be an array'); + const seen = new Set(); + for (const item of table.strings) { + if (!item || typeof item.id !== 'string' || typeof item.text !== 'string') { + throw new Error('strings.json: every string needs a string "id" and "text"'); + } + if (seen.has(item.id)) throw new Error(`strings.json: duplicate id ${item.id}`); + seen.add(item.id); + } +} diff --git a/tools/translate.mjs b/tools/translate.mjs new file mode 100644 index 0000000..ce95584 --- /dev/null +++ b/tools/translate.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +// Translate a deck's string table with an LLM. See docs/string-table.md for the contract. +// +// node tools/translate.mjs --to de,fr [--force] [--dry-run] +// [--provider openai|mock] [--model ] [--base-url ] +// +// Exit codes: 0 ok, 1 error (nothing partial written for the failing language), +// 2 finished but some strings were skipped (they fall back to source in the viewer). + +import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createProvider, PROVIDERS } from './llm-provider.mjs'; +import { planLanguage, makeBatches, serializeTranslation, translateLanguage, validateSourceTable } from './translate-core.mjs'; + +const USAGE = `Usage: node tools/translate.mjs --to [,...] [options] + +Options: + --to Comma-separated BCP 47 tags, e.g. de,pt-BR,ar (required) + --force Retranslate every string, ignoring existing translations + --dry-run Show what would be translated; no LLM calls, nothing written + --provider ${PROVIDERS.join(' | ')} (default: openai) + --model Model / deployment name (or LLM_MODEL) + --base-url OpenAI-compatible endpoint (or LLM_BASE_URL) + --batch-size Max strings per request (default 30) + --decks-dir Decks directory (default: /decks) + -h, --help Show this help + +Environment (provider "openai"): LLM_API_KEY or OPENAI_API_KEY, LLM_MODEL, LLM_BASE_URL, +LLM_AUTH_HEADER=bearer|api-key, LLM_JSON_MODE=off.`; + +const LANG_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/; +const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +class UsageError extends Error {} + +export function parseArgs(argv) { + const opts = { force: false, dryRun: false, provider: 'openai', langs: [] }; + const positional = []; + const takeValue = (i, flag) => { + if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new UsageError(`${flag} needs a value`); + return argv[i + 1]; + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '-h': + case '--help': + opts.help = true; + break; + case '--force': + opts.force = true; + break; + case '--dry-run': + opts.dryRun = true; + break; + case '--to': + opts.langs.push(...takeValue(i++, arg).split(',').map((s) => s.trim()).filter(Boolean)); + break; + case '--provider': + opts.provider = takeValue(i++, arg); + break; + case '--model': + opts.model = takeValue(i++, arg); + break; + case '--base-url': + opts.baseUrl = takeValue(i++, arg); + break; + case '--batch-size': + opts.batchSize = Number(takeValue(i++, arg)); + break; + case '--decks-dir': + opts.decksDir = takeValue(i++, arg); + break; + default: + if (arg.startsWith('-')) throw new UsageError(`Unknown option ${arg}`); + positional.push(arg); + } + } + if (opts.help) return opts; + if (positional.length !== 1) throw new UsageError('Expected exactly one '); + opts.slug = positional[0]; + if (!SLUG_RE.test(opts.slug)) throw new UsageError(`Invalid slug "${opts.slug}"`); + if (!opts.langs.length) throw new UsageError('--to is required'); + for (const lang of opts.langs) { + if (!LANG_RE.test(lang)) throw new UsageError(`Invalid language tag "${lang}" (expected BCP 47, e.g. de, pt-BR)`); + } + opts.langs = [...new Set(opts.langs)]; + if (opts.batchSize !== undefined && !(Number.isInteger(opts.batchSize) && opts.batchSize > 0)) { + throw new UsageError('--batch-size must be a positive integer'); + } + return opts; +} + +async function readJson(file, what) { + let raw; + try { + raw = await readFile(file, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return { missing: true }; + throw err; + } + try { + return { raw, data: JSON.parse(raw) }; + } catch { + throw new Error(`${what} is not valid JSON: ${file}`); + } +} + +async function writeAtomic(file, content) { + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, content, 'utf8'); + await rename(tmp, file); +} + +// `deps` lets tests inject env, streams, a decks directory and a ready-made provider. +export async function run(argv, deps = {}) { + const out = deps.stdout ?? ((line) => console.log(line)); + const err = deps.stderr ?? ((line) => console.error(line)); + + let opts; + try { + opts = parseArgs(argv); + } catch (e) { + if (!(e instanceof UsageError)) throw e; + err(`${e.message}\n\n${USAGE}`); + return 1; + } + if (opts.help) { + out(USAGE); + return 0; + } + + try { + const decksDir = path.resolve(opts.decksDir ?? deps.decksDir ?? path.join(REPO_ROOT, 'decks')); + const sourceFile = path.join(decksDir, opts.slug, 'strings.json'); + const source = await readJson(sourceFile, 'strings.json'); + if (source.missing) throw new Error(`No source table at ${sourceFile}. Run the PPTX converter first.`); + const table = source.data; + validateSourceTable(table); + + const langs = opts.langs.filter((lang) => { + const same = lang.toLowerCase() === table.sourceLang.toLowerCase(); + if (same) err(`Skipping ${lang}: same as sourceLang`); + return !same; + }); + + let provider = null; + if (!opts.dryRun && langs.length) { + provider = deps.provider ?? createProvider(opts.provider, { env: deps.env, model: opts.model, baseUrl: opts.baseUrl, fetch: deps.fetch, sleep: deps.sleep }); + } + const batchOptions = opts.batchSize ? { maxItems: opts.batchSize } : undefined; + + let skipped = 0; + for (const lang of langs) { + const target = path.join(decksDir, opts.slug, `strings.${lang}.json`); + const prior = await readJson(target, path.basename(target)).catch((e) => { + if (!opts.force) throw e; + return { missing: false, raw: null, data: null }; + }); + if (!prior.missing && !opts.force) { + const d = prior.data; + if (d?.version !== 1 || typeof d.strings !== 'object' || d.strings === null) { + throw new Error(`${target} is not a v1 translation file; fix it or re-run with --force`); + } + } + // With --force the old file is ignored entirely. + const existing = prior.missing || opts.force ? null : prior.data; + const sameSource = existing && existing.sourceLang === table.sourceLang ? existing : null; + + if (opts.dryRun) { + const plan = planLanguage(table, sameSource, { force: opts.force }); + const batches = makeBatches(plan.todo, batchOptions); + out(`${lang}: ${plan.todo.length} to translate in ${batches.length} batch(es), ${plan.kept.size} up to date, ${plan.dropped.length} orphaned`); + for (const item of plan.todo) out(` ${item.id} [${item.kind}]`); + continue; + } + + out(`${lang}: translating with ${provider.name}...`); + const { file, stats } = await translateLanguage({ + table, + existing: sameSource, + lang, + provider, + force: opts.force, + batchOptions, + log: out, + }); + + const serialized = serializeTranslation(file); + if (prior.missing || prior.raw !== serialized) { + await mkdir(path.dirname(target), { recursive: true }); + await writeAtomic(target, serialized); + } + const shown = path.relative(process.cwd(), target); + out(`${lang}: ${stats.translated} translated, ${stats.kept} kept, ${stats.failed.size} skipped -> ${shown.startsWith('..') ? target : shown}`); + for (const [id, errors] of stats.failed) { + err(` skipped ${id}: ${errors.join('; ')}`); + skipped++; + } + } + if (skipped) err(`${skipped} string(s) skipped; they will fall back to the source text. Re-run to retry them.`); + return skipped ? 2 : 0; + } catch (e) { + err(`Error: ${e.message}`); + return 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + process.exitCode = await run(process.argv.slice(2)); +} diff --git a/tools/translate.test.mjs b/tools/translate.test.mjs new file mode 100644 index 0000000..7637c49 --- /dev/null +++ b/tools/translate.test.mjs @@ -0,0 +1,418 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, writeFile, copyFile, rm, access } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { fnv1a, validateTranslation, planLanguage, makeBatches, translateBatch, translateLanguage, parseResponse } from './translate-core.mjs'; +import { createProvider, parseRetryAfter, ProviderError } from './llm-provider.mjs'; +import { run } from './translate.mjs'; + +const EXAMPLES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'examples'); +const SOURCE_TABLE = JSON.parse(await readFile(path.join(EXAMPLES, 'strings.json'), 'utf8')); + +async function makeDecksDir({ withGerman = false } = {}) { + const decksDir = await mkdtemp(path.join(os.tmpdir(), 'translate-test-')); + const deck = path.join(decksDir, 'demo'); + await mkdir(deck); + await copyFile(path.join(EXAMPLES, 'strings.json'), path.join(deck, 'strings.json')); + if (withGerman) await copyFile(path.join(EXAMPLES, 'strings.de.json'), path.join(deck, 'strings.de.json')); + return { decksDir, deck, cleanup: () => rm(decksDir, { recursive: true, force: true }) }; +} + +function cli(argv, deps) { + const stdout = []; + const stderr = []; + return run(argv, { ...deps, stdout: (l) => stdout.push(l), stderr: (l) => stderr.push(l) }).then((code) => ({ code, stdout, stderr })); +} + +// Provider stub that answers from a script of functions(items) => reply string. +function scripted(...replies) { + const calls = []; + return { + name: 'scripted', + calls, + async complete({ messages }) { + calls.push(messages); + const reply = replies[Math.min(calls.length - 1, replies.length - 1)]; + return typeof reply === 'function' ? reply(messages) : reply; + }, + }; +} +const payloadOf = (messages) => JSON.parse(messages.at(-1).content.split('\n').at(-1)); +const reply = (map) => JSON.stringify({ translations: map }); + +// ---- hash --------------------------------------------------------------------- + +test('fnv1a matches reference vectors and the docs/examples pair', () => { + assert.equal(fnv1a(''), '811c9dc5'); + assert.equal(fnv1a('a'), 'e40c292c'); + assert.equal(fnv1a('foobar'), 'bf9cf968'); + assert.equal(fnv1a('Decks as Code'), '2218a577'); + assert.equal(fnv1a('Replacing PPTX with plain-text presentations'), '2463d0d5'); +}); + +test('fnv1a hashes UTF-8 bytes, not UTF-16 code units', () => { + // "é" is bytes C3 A9: fnv1a = (((811c9dc5 ^ c3) * 01000193) ^ a9) * 01000193 + let h = 0x811c9dc5; + for (const b of [0xc3, 0xa9]) h = Math.imul(h ^ b, 0x01000193) >>> 0; + assert.equal(fnv1a('é'), h.toString(16).padStart(8, '0')); + assert.equal(fnv1a('x').length, 8); +}); + +// ---- validation --------------------------------------------------------------- + +test('validation accepts reordered tags and rejects broken ones', () => { + const src = 'Read the docs first.
Then try it.'; + assert.deepEqual(validateTranslation(src, 'Zuerst die Doku lesen.
Dann ausprobieren.'), []); + assert.deepEqual(validateTranslation(src, 'Zuerst die Doku lesen.
Dann.'), []); + + assert.ok(validateTranslation(src, 'Lesen. Dann.').length, 'dropped tags'); + assert.ok(validateTranslation('x', 'x').length, 'unclosed'); + assert.ok(validateTranslation('x y', 'x y').length, 'misnested'); + assert.ok(validateTranslation('x', 'xy').length, 'extra tag'); + assert.ok(validateTranslation('x', 'x').length, 'attributes'); + assert.ok(validateTranslation('x', '').length, 'disallowed tag'); + assert.ok(validateTranslation('1 < 2', '1 < 2').length, 'unescaped <'); +}); + +test(' is an allowed inline tag and must be kept; new-style ids are opaque', async () => { + assert.deepEqual(validateTranslation('Run npm test now', 'Jetzt npm test ausführen'), []); + assert.ok(validateTranslation('Run npm test now', 'Jetzt npm test ausführen').length); + + const table = { version: 1, sourceLang: 'en', strings: [{ id: 's3.L.sh7.p2', slide: 3, kind: 'text', text: 'Use x' }] }; + const { file } = await translateLanguage({ table, existing: null, lang: 'de', provider: createProvider('mock') }); + assert.equal(file.strings['s3.L.sh7.p2'].text, '[de: Use x]'); +}); + +test('validation requires href values to be unchanged', () => { + const src = 'docs'; + assert.ok(validateTranslation(src, 'Doku').length); + assert.ok(validateTranslation(src, 'Doku').length); + assert.ok(validateTranslation(src, 'Doku').length); + assert.deepEqual(validateTranslation(src, 'Doku'), []); +}); + +test('validation rejects empty and non-string text', () => { + for (const bad of ['', ' ', null, undefined, 42]) { + assert.ok(validateTranslation('Save', bad).length, String(bad)); + } +}); + +// ---- incremental planning ----------------------------------------------------- + +test('plan skips fresh translations and re-queues stale or missing ones', () => { + const existing = JSON.parse(JSON.stringify({ + version: 1, lang: 'de', sourceLang: 'en', + strings: { + 's1.sh2.p1': { text: 'Decks als Code', src: '2218a577' }, + 's1.sh3.p1': { text: 'alt', src: 'deadbeef' }, + 'gone.p1': { text: 'orphan', src: '00000000' }, + }, + })); + const plan = planLanguage(SOURCE_TABLE, existing); + assert.deepEqual([...plan.kept.keys()], ['s1.sh2.p1']); + assert.equal(plan.todo.length, SOURCE_TABLE.strings.length - 1); + assert.ok(plan.todo.some((s) => s.id === 's1.sh3.p1'), 'stale hash re-queued'); + assert.deepEqual(plan.dropped, ['gone.p1']); + + assert.equal(planLanguage(SOURCE_TABLE, existing, { force: true }).todo.length, SOURCE_TABLE.strings.length); +}); + +test('plan re-queues a matching hash whose stored text is invalid', () => { + const existing = { strings: { 's1.sh2.p1': { text: 'Decks als Code', src: '2218a577' } } }; + assert.ok(planLanguage(SOURCE_TABLE, existing).todo.some((s) => s.id === 's1.sh2.p1')); +}); + +test('batches stay in document order and respect the size limit', () => { + const batches = makeBatches(SOURCE_TABLE.strings, { maxItems: 3 }); + assert.deepEqual(batches.map((b) => b.length), [3, 3, 2]); + assert.deepEqual(batches.flat().map((s) => s.id), SOURCE_TABLE.strings.map((s) => s.id)); +}); + +test('CLI only translates what is missing or stale, then is a no-op', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir({ withGerman: true }); + try { + const provider = scripted((m) => reply(Object.fromEntries(payloadOf(m).strings.map((s) => [s.id, `DE ${s.text}`])))); + const first = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(first.code, 0); + const requested = provider.calls.flatMap((m) => payloadOf(m).strings.map((s) => s.id)); + assert.equal(requested.length, SOURCE_TABLE.strings.length - 2); + assert.ok(!requested.includes('s1.sh2.p1') && !requested.includes('s1.sh3.p1')); + + const written = JSON.parse(await readFile(path.join(deck, 'strings.de.json'), 'utf8')); + assert.equal(written.strings['s1.sh2.p1'].text, 'Decks als Code'); + assert.equal(Object.keys(written.strings).length, SOURCE_TABLE.strings.length); + + const before = provider.calls.length; + const second = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(second.code, 0); + assert.equal(provider.calls.length, before, 'nothing left to translate, no LLM call'); + + // Edit one source string: only it is retranslated. + const table = JSON.parse(await readFile(path.join(deck, 'strings.json'), 'utf8')); + table.strings[2].text = 'Why bother?'; + await writeFile(path.join(deck, 'strings.json'), JSON.stringify(table)); + await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.deepEqual(payloadOf(provider.calls.at(-1)).strings.map((s) => s.id), ['s2.sh2.p1']); + + await cli(['demo', '--to', 'de', '--force'], { decksDir, provider }); + const forced = provider.calls.slice(before + 1).flatMap((m) => payloadOf(m).strings); + assert.equal(forced.length, SOURCE_TABLE.strings.length); + } finally { + await cleanup(); + } +}); + +// ---- retry then skip ---------------------------------------------------------- + +test('a failed string is retried once with the error fed back, then succeeds', async () => { + const items = SOURCE_TABLE.strings.slice(0, 2); + const provider = scripted( + reply({ 's1.sh2.p1': 'Decks als Code', 's1.sh3.p1': 'Klartext' }), // first drops the tags + (m) => reply({ 's1.sh2.p1': 'Decks als Code' }), + ); + const result = await translateBatch({ provider, items, sourceLang: 'en', targetLang: 'de' }); + assert.equal(provider.calls.length, 2); + assert.deepEqual([...result.translated.keys()].sort(), ['s1.sh2.p1', 's1.sh3.p1']); + assert.equal(result.failed.size, 0); + + const retry = provider.calls[1]; + const feedback = retry.at(-1).content; + assert.match(feedback, /s1\.sh2\.p1/); + assert.match(feedback, /tag "b"/); + assert.equal(retry.at(-2).role, 'assistant'); + assert.deepEqual(payloadOf(retry).strings.map((s) => s.id), ['s1.sh2.p1'], 'only the failed string is retried'); +}); + +test('strings that still fail after one retry are skipped, reported, and the file stays valid', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const provider = scripted((m) => { + const map = Object.fromEntries(payloadOf(m).strings.map((s) => [s.id, `X ${s.text}`])); + map['s1.sh2.p1'] = 'Decks als Code'; // always loses the tags + return reply(map); + }); + const result = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(result.code, 2); + assert.match(result.stderr.join('\n'), /skipped s1\.sh2\.p1/); + + const file = JSON.parse(await readFile(path.join(deck, 'strings.de.json'), 'utf8')); + assert.equal(file.version, 1); + assert.ok(!('s1.sh2.p1' in file.strings)); + assert.equal(Object.keys(file.strings).length, SOURCE_TABLE.strings.length - 1); + for (const [id, entry] of Object.entries(file.strings)) { + const src = SOURCE_TABLE.strings.find((s) => s.id === id); + assert.equal(entry.src, fnv1a(src.text)); + assert.deepEqual(validateTranslation(src.text, entry.text), []); + } + } finally { + await cleanup(); + } +}); + +test('extra ids, invalid JSON and provider errors fail the batch and then retry', async () => { + const items = SOURCE_TABLE.strings.slice(1, 2); + const ok = reply({ 's1.sh3.p1': 'Klartext' }); + + const extras = scripted(reply({ 's1.sh3.p1': 'Klartext', bogus: 'x' }), ok); + assert.equal((await translateBatch({ provider: extras, items, sourceLang: 'en', targetLang: 'de' })).failed.size, 0); + + const junk = scripted('not json at all', ok); + assert.equal((await translateBatch({ provider: junk, items, sourceLang: 'en', targetLang: 'de' })).failed.size, 0); + + let n = 0; + const flaky = { name: 'flaky', complete: async () => { if (n++ === 0) throw new ProviderError('HTTP 500'); return ok; } }; + assert.equal((await translateBatch({ provider: flaky, items, sourceLang: 'en', targetLang: 'de' })).failed.size, 0); + + const dead = scripted('nope', 'still nope'); + const result = await translateBatch({ provider: dead, items, sourceLang: 'en', targetLang: 'de' }); + assert.equal(dead.calls.length, 2, 'exactly one retry'); + assert.equal(result.failed.size, 1); +}); + +test('fatal provider errors abort without writing a file', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const provider = { name: 'bad-auth', complete: async () => { throw new ProviderError('HTTP 401', { status: 401, fatal: true }); } }; + const result = await cli(['demo', '--to', 'de'], { decksDir, provider }); + assert.equal(result.code, 1); + await assert.rejects(access(path.join(deck, 'strings.de.json'))); + } finally { + await cleanup(); + } +}); + +test('parseResponse tolerates code fences and surrounding prose', () => { + assert.deepEqual(parseResponse('```json\n{"translations":{"a":"b"}}\n```'), { a: 'b' }); + assert.deepEqual(parseResponse('Here you go: {"translations":{"a":"b"}} Done.'), { a: 'b' }); + assert.throws(() => parseResponse('{"a":"b"}')); +}); + +// ---- RTL / fallback ----------------------------------------------------------- + +test('RTL languages use the same file shape; skipped strings are simply absent (viewer falls back to source)', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const provider = scripted((m) => { + const map = Object.fromEntries(payloadOf(m).strings.map((s) => [s.id, `ع ${s.text}`])); + delete map['s2.notes.p1']; + return reply(map); + }); + const result = await cli(['demo', '--to', 'ar'], { decksDir, provider }); + assert.equal(result.code, 2); + const file = JSON.parse(await readFile(path.join(deck, 'strings.ar.json'), 'utf8')); + assert.deepEqual(Object.keys(file).sort(), ['lang', 'sourceLang', 'strings', 'version'], 'no dir/rtl field; the viewer derives it from lang'); + assert.equal(file.lang, 'ar'); + assert.ok(!('s2.notes.p1' in file.strings)); + assert.ok('s2.sh3.p2' in file.strings); + } finally { + await cleanup(); + } +}); + +// ---- mock provider end to end ------------------------------------------------- + +test('mock provider end to end against docs/examples/strings.json', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir(); + try { + const result = await cli(['demo', '--to', 'de,pt-BR', '--provider', 'mock'], { decksDir }); + assert.equal(result.code, 0, result.stderr.join('\n')); + + for (const lang of ['de', 'pt-BR']) { + const file = JSON.parse(await readFile(path.join(deck, `strings.${lang}.json`), 'utf8')); + assert.equal(file.version, 1); + assert.equal(file.lang, lang); + assert.equal(file.sourceLang, 'en'); + assert.deepEqual(Object.keys(file.strings), SOURCE_TABLE.strings.map((s) => s.id), 'source order'); + for (const s of SOURCE_TABLE.strings) { + const entry = file.strings[s.id]; + assert.equal(entry.text, `[${lang}: ${s.text}]`); + assert.equal(entry.src, fnv1a(s.text)); + assert.match(entry.src, /^[0-9a-f]{8}$/); + assert.deepEqual(validateTranslation(s.text, entry.text), []); + } + } + + const again = await cli(['demo', '--to', 'de', '--provider', 'mock'], { decksDir }); + assert.match(again.stdout.join('\n'), /0 translated, 8 kept/); + } finally { + await cleanup(); + } +}); + +test('mock translations also pass through the provider via the retry conversation', async () => { + const mock = createProvider('mock'); + const items = SOURCE_TABLE.strings.slice(0, 2); + const result = await translateBatch({ provider: mock, items, sourceLang: 'en', targetLang: 'fr' }); + assert.equal(result.translated.get('s1.sh2.p1'), '[fr: Decks as Code]'); +}); + +test('--dry-run makes no provider calls and writes nothing', async () => { + const { decksDir, deck, cleanup } = await makeDecksDir({ withGerman: true }); + try { + const result = await cli(['demo', '--to', 'de,fr', '--dry-run'], { decksDir, env: {} }); + assert.equal(result.code, 0); + assert.match(result.stdout.join('\n'), /de: 6 to translate/); + assert.match(result.stdout.join('\n'), /fr: 8 to translate/); + await assert.rejects(access(path.join(deck, 'strings.fr.json'))); + assert.equal(await readFile(path.join(deck, 'strings.de.json'), 'utf8'), await readFile(path.join(EXAMPLES, 'strings.de.json'), 'utf8')); + } finally { + await cleanup(); + } +}); + +test('CLI rejects bad arguments', async () => { + const { decksDir, cleanup } = await makeDecksDir(); + try { + for (const argv of [[], ['demo'], ['demo', '--to', 'de/../x'], ['../demo', '--to', 'de'], ['demo', '--to', 'de', '--bogus']]) { + assert.equal((await cli(argv, { decksDir })).code, 1, argv.join(' ')); + } + assert.equal((await cli(['missing', '--to', 'de', '--provider', 'mock'], { decksDir })).code, 1); + } finally { + await cleanup(); + } +}); + +// ---- openai-compatible provider (fake fetch, no network) ---------------------- + +const KEY = 'sk-test-secret-123'; +const okResponse = (content) => new Response(JSON.stringify({ choices: [{ message: { content }, finish_reason: 'stop' }] }), { status: 200 }); + +test('openai provider honours retry-after on 429 and never leaks the key', async () => { + const sleeps = []; + const requests = []; + const responses = [ + new Response('slow down', { status: 429, headers: { 'retry-after': '7' } }), + new Response('slow down', { status: 429, headers: { 'retry-after': '2' } }), + okResponse('hello'), + ]; + const provider = createProvider('openai', { + env: { LLM_API_KEY: KEY, LLM_MODEL: 'm' }, + baseUrl: 'https://llm.example.com/v1/', + sleep: async (ms) => sleeps.push(ms), + fetch: async (url, init) => { requests.push({ url: String(url), init }); return responses.shift(); }, + }); + assert.equal(await provider.complete({ messages: [{ role: 'user', content: 'hi' }] }), 'hello'); + assert.deepEqual(sleeps, [7000, 2000]); + assert.equal(requests[0].url, 'https://llm.example.com/v1/chat/completions'); + assert.equal(requests[0].init.headers.authorization, `Bearer ${KEY}`); + const body = JSON.parse(requests[0].init.body); + assert.equal(body.model, 'm'); + assert.deepEqual(body.response_format, { type: 'json_object' }); +}); + +test('openai provider: api-key header, 401 is fatal, error text is redacted', async () => { + const seen = []; + const provider = createProvider('openai', { + env: { LLM_API_KEY: KEY, LLM_MODEL: 'm', LLM_AUTH_HEADER: 'api-key', LLM_JSON_MODE: 'off' }, + baseUrl: 'https://x.openai.azure.com/openai/v1', + sleep: async () => {}, + fetch: async (url, init) => { seen.push(init); return new Response(`bad key ${KEY}`, { status: 401 }); }, + }); + await assert.rejects( + provider.complete({ messages: [] }), + (err) => err instanceof ProviderError && err.fatal && !err.message.includes(KEY) && err.message.includes('[redacted]'), + ); + assert.equal(seen[0].headers['api-key'], KEY); + assert.equal(seen[0].headers.authorization, undefined); + assert.equal(JSON.parse(seen[0].body).response_format, undefined); +}); + +test('openai provider gives up on persistent 429 and retries 5xx', async () => { + let calls = 0; + const provider = createProvider('openai', { + env: { LLM_API_KEY: KEY, LLM_MODEL: 'm' }, + sleep: async () => {}, + fetch: async () => { calls++; return new Response('', { status: 429 }); }, + }); + await assert.rejects(provider.complete({ messages: [] }), /429/); + assert.equal(calls, 6); + + const flaky = [new Response('', { status: 503 }), okResponse('fine')]; + const p2 = createProvider('openai', { env: { LLM_API_KEY: KEY, LLM_MODEL: 'm' }, sleep: async () => {}, fetch: async () => flaky.shift() }); + assert.equal(await p2.complete({ messages: [] }), 'fine'); +}); + +test('openai provider config errors are explicit and fatal', () => { + const base = { LLM_API_KEY: KEY, LLM_MODEL: 'm' }; + assert.throws(() => createProvider('openai', { env: { LLM_MODEL: 'm' } }), /API key/); + assert.throws(() => createProvider('openai', { env: { LLM_API_KEY: KEY } }), /model/i); + assert.throws(() => createProvider('openai', { env: base, baseUrl: 'http://example.com/v1' }), /https/); + assert.doesNotThrow(() => createProvider('openai', { env: base, baseUrl: 'http://localhost:11434/v1' })); + assert.throws(() => createProvider('nope'), /Unknown provider/); +}); + +test('retry-after parsing handles seconds and HTTP dates', () => { + assert.equal(parseRetryAfter('3'), 3000); + assert.equal(parseRetryAfter('Wed, 21 Oct 2015 07:28:05 GMT', Date.parse('Wed, 21 Oct 2015 07:28:00 GMT')), 5000); + assert.equal(parseRetryAfter(null), null); + assert.equal(parseRetryAfter('soon'), null); +}); + +test('translateLanguage output is stable for a ready-made provider', async () => { + const { file, stats } = await translateLanguage({ table: SOURCE_TABLE, existing: null, lang: 'de', provider: createProvider('mock') }); + assert.equal(stats.translated, SOURCE_TABLE.strings.length); + assert.equal(file.strings['s2.sh4.r1c1.p1'].text, '[de: Tool]'); +}); From ec6baac9c37c2c1873172c8b4234879eaa5a093f Mon Sep 17 00:00:00 2001 From: Ivor Berry <11559977+ivorb@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:51:29 -0500 Subject: [PATCH 2/4] Translate CLI: Copilot CLI provider (default, no API key); German sample translation --- README.md | 9 +- decks/sample-ai-apps/strings.de.json | 127 +++++++++++++++++++++++++++ tools/llm-provider.mjs | 77 +++++++++++++++- tools/translate.mjs | 14 +-- tools/translate.test.mjs | 42 +++++++++ 5 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 decks/sample-ai-apps/strings.de.json diff --git a/README.md b/README.md index c76d5fa..40ffc0d 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,14 @@ node --test # run the tests (no fed back; strings that still fail are skipped and listed, the file stays valid, and the viewer falls back to the source text for them. Exit code `2` means "done, but some strings were skipped"; re-run to retry them. -- **Provider.** `--provider openai` (default) talks to any OpenAI-compatible +- **Provider.** `--provider copilot` (default) runs the GitHub Copilot CLI in + non-interactive mode, so it uses the models in your Copilot subscription: no API key, + nothing to configure beyond signing in once by running `copilot`. Tools, MCP servers and + custom instructions are switched off, so the model can only answer. Optional: + `--model ` (otherwise the CLI's default), `LLM_REASONING_EFFORT` (default `low`), + `COPILOT_BIN` if `copilot` is not on your PATH. The five-slide sample takes under a + minute per language; a 50-slide deck takes a few minutes. + `--provider openai` talks to any OpenAI-compatible `/chat/completions` endpoint. Configure it with environment variables (the key is never a flag, never logged, never written): `LLM_API_KEY` (or `OPENAI_API_KEY`), `LLM_MODEL` (or `--model`), and optionally `LLM_BASE_URL` (or `--base-url`; default diff --git a/decks/sample-ai-apps/strings.de.json b/decks/sample-ai-apps/strings.de.json new file mode 100644 index 0000000..6d3a7b2 --- /dev/null +++ b/decks/sample-ai-apps/strings.de.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "lang": "de", + "sourceLang": "en-US", + "strings": { + "s1.sh2.p1": { "text": "Generative KI-Apps in Azure entwickeln", "src": "7e7c01f9" }, + "s1.sh3.p1": { "text": "Juni 2025", "src": "67047020" }, + "s1.sh5.p1": { "text": "https://aka.ms/mslearn-gen-ai-app-dev", "src": "124da598" }, + "s1.notes.p1": { "text": "Über den Link auf der Folie gelangen Sie zum Microsoft Learn-Lernpfad, auf dem diese Präsentation basiert.", "src": "a9b54f42" }, + "s2.sh5.p1": { "text": "Workloads für künstliche Intelligenz", "src": "783619a6" }, + "s2.sh3.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s2.sh32.alt": { "text": "Ausgefülltes Chatsymbol", "src": "ec9a46d3" }, + "s2.sh29.alt": { "text": "Ausgefülltes Robotersymbol", "src": "28889807" }, + "s2.sh25.p1": { "text": "Generative KI und Agenten", "src": "4e78ef44" }, + "s2.sh44.alt": { "text": "Dokumentsymbol als Umriss", "src": "27286e9a" }, + "s2.sh40.p1": { "text": "Text und Sprache", "src": "a9eadd37" }, + "s2.sh49.alt": { "text": "Callcenter-Symbol als Umriss", "src": "a781af24" }, + "s2.sh47.p1": { "text": "Sprachverarbeitung", "src": "771f42be" }, + "s2.sh54.alt": { "text": "Ausgefülltes Augenscan-Symbol", "src": "06bb4243" }, + "s2.sh52.p1": { "text": "Computer Vision", "src": "23c85bd0" }, + "s2.sh60.alt": { "text": "Ausgefülltes Lupensymbol", "src": "d243ec84" }, + "s2.sh61.alt": { "text": "Vertragssymbol als Umriss", "src": "31cc5d99" }, + "s2.sh57.p1": { "text": "Informationsextraktion", "src": "7c60c39a" }, + "s2.sh67.alt": { "text": "Ausgefülltes Zahnradsymbol", "src": "8e4ad78f" }, + "s2.sh64.p1": { "text": "Maschinelles Lernen", "src": "9d25e72c" }, + "s2.notes.p1": { "text": "Geben Sie den Lernenden eine Orientierung: „Azure AI umfasst mehrere Workload-Kategorien – heute konzentrieren wir uns auf generative KI und Agenten.“", "src": "7c63c722" }, + "s2.notes.p2": { "text": "Kurzer Vergleich: klassisches maschinelles Lernen (Vorhersagen, f(x)=y) gegenüber generativer KI (Sprache + Schlussfolgern + Inhaltserstellung).", "src": "c70d5d0b" }, + "s3.sh5.p1": { "text": "Unser heutiger Schwerpunkt", "src": "ebfd8520" }, + "s3.sh44.alt": { "text": "Dokumentsymbol als Umriss", "src": "27286e9a" }, + "s3.sh40.p1": { "text": "Text und Sprache", "src": "a9eadd37" }, + "s3.sh49.alt": { "text": "Callcenter-Symbol als Umriss", "src": "a781af24" }, + "s3.sh47.p1": { "text": "Sprachverarbeitung", "src": "771f42be" }, + "s3.sh54.alt": { "text": "Ausgefülltes Augenscan-Symbol", "src": "06bb4243" }, + "s3.sh52.p1": { "text": "Computer Vision", "src": "23c85bd0" }, + "s3.sh60.alt": { "text": "Ausgefülltes Lupensymbol", "src": "d243ec84" }, + "s3.sh61.alt": { "text": "Vertrag als Umrisssymbol", "src": "31cc5d99" }, + "s3.sh57.p1": { "text": "Informationsextraktion", "src": "7c60c39a" }, + "s3.sh67.alt": { "text": "Zahnräder mit Volltonfüllung", "src": "8e4ad78f" }, + "s3.sh64.p1": { "text": "Maschinelles Lernen", "src": "9d25e72c" }, + "s3.sh8.alt": { "text": "Chat mit Volltonfüllung", "src": "ec9a46d3" }, + "s3.sh6.alt": { "text": "Roboter mit Volltonfüllung", "src": "28889807" }, + "s3.sh9.p1": { "text": "Generative KI und Agenten", "src": "4e78ef44" }, + "s3.sh3.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s3.notes.p1": { "text": "Betonen Sie die thematische Eingrenzung: „Wenn Sie wegen Bildverarbeitung, Sprachverarbeitung oder Übersetzung hier sind: Diese Möglichkeiten gibt es auch – aber heute konzentrieren wir uns auf generative KI-Modelle und die Entwicklung von Chat-Anwendungen.“", "src": "3774d615" }, + "s3.notes.p2": { "text": "Geben Sie einen Überblick über den Tagesablauf: Plattformgrundlagen → Modelle → Endpunkte/SDKs → Entwicklung von Chat-Anwendungen → Optimierung → verantwortungsvolle KI.", "src": "aa2c688c" }, + "s4.sh2.p1": { "text": "Die Entwicklung von KI-Lösungen auf Azure planen und vorbereiten", "src": "b7da702c" }, + "s4.sh4.p1": { "text": "https://aka.ms/mslearn-prepare-ai-dev", "src": "71f607d1" }, + "s4.notes.p1": { "text": "Über den Link auf der Folie gelangen Sie zum Microsoft Learn-Modul, auf dem diese Lektion basiert.", "src": "073c9933" }, + "s5.M.sh37.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh38.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh48.p1": { "text": "73262F", "src": "0182554f" }, + "s5.M.sh50.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh51.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh52.p1": { "text": "FFB3BB", "src": "4155e0ae" }, + "s5.M.sh55.p1": { "text": "FFFFFF", "src": "4414127d" }, + "s5.M.sh57.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh58.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh59.p1": { "text": "FF5C39", "src": "64f573ad" }, + "s5.M.sh61.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh62.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh63.p1": { "text": "73391D", "src": "26a09c72" }, + "s5.M.sh65.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh66.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh67.p1": { "text": "FFA38B", "src": "be00c8cf" }, + "s5.M.sh70.p1": { "text": "F4F3F5", "src": "4f0282d5" }, + "s5.M.sh72.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh73.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh74.p1": { "text": "0078D4", "src": "3d9ce272" }, + "s5.M.sh76.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh77.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh78.p1": { "text": "2A446F", "src": "87444a50" }, + "s5.M.sh80.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh81.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh82.p1": { "text": "8DC8E8", "src": "93ca13c7" }, + "s5.M.sh87.p1": { "text": "Blauschwarz", "src": "921a7b2e" }, + "s5.M.sh88.p1": { "text": "091F2C", "src": "e2f1513a" }, + "s5.M.sh93.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh94.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh95.p1": { "text": "702573", "src": "c11cc7e1" }, + "s5.M.sh97.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh98.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh99.p1": { "text": "CD9BCF", "src": "55b6be38" }, + "s5.M.sh102.p1": { "text": "000000", "src": "68d6565d" }, + "s5.M.sh105.p1": { "text": "Neutralgrau", "src": "84317097" }, + "s5.M.sh106.p1": { "text": "E8E6DF", "src": "573f15a9" }, + "s5.M.sh34.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh35.p1": { "text": "F4364C", "src": "689e5837" }, + "s5.M.sh107.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh90.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh91.p1": { "text": "C73ECC", "src": "685cf649" }, + "s5.M.sh108.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh149.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s5.sh5.p1": { "text": "Microsoft Foundry", "src": "ccb80b4a" }, + "s5.sh131.p1": { "text": "Eine einheitliche Plattform für KI-Apps und -Agents", "src": "04c0dca1" }, + "s5.sh130.p1": { "text": "Foundry-Ressourcen in Azure bieten Rechenleistung, Speicher, Modellbereitstellung und weitere Dienste", "src": "def6826b" }, + "s5.sh130.p2": { "text": "Foundry-Projekte sind isolierte Arbeitsumgebungen für die App- und Agentenentwicklung", "src": "7d9928ca" }, + "s5.sh130.p3": { "text": "In einem Projekt arbeiten Sie mit:", "src": "63a267fe" }, + "s5.sh130.p4": { "text": "Modellen – wählen Sie Modelle aus einem umfangreichen Modellkatalog aus und stellen Sie sie bereit", "src": "4c53e53a" }, + "s5.sh130.p5": { "text": "Agenten – erstellen und verwalten Sie Agenten, die Modelle für Sprachverarbeitung und logisches Denken nutzen", "src": "0ff8d083" }, + "s5.sh130.p6": { "text": "Tools – verbinden Sie Agenten mit Tools, damit sie Aktionen ausführen und auf Informationen zugreifen können", "src": "7eac46a1" }, + "s5.sh130.p7": { "text": "Wissen – verwalten Sie den Zugriff von Agenten auf Datenquellen, damit sie die Informationen erhalten, die sie zum Handeln benötigen", "src": "f1e79bfc" }, + "s5.sh83.p1": { "text": "", "src": "d642d845" }, + "s5.sh92.p1": { "text": "Modelle", "src": "01cd0fab" }, + "s5.sh95.p1": { "text": "Agenten", "src": "e7af956f" }, + "s5.sh97.alt": { "text": "Roboter mit einfarbiger Füllung", "src": "28889807" }, + "s5.sh98.alt": { "text": "Roboter mit einfarbiger Füllung", "src": "28889807" }, + "s5.sh99.alt": { "text": "Roboter mit einfarbiger Füllung", "src": "28889807" }, + "s5.sh102.p1": { "text": "Tools", "src": "950196fc" }, + "s5.sh104.alt": { "text": "Schraubenschlüssel mit einfarbiger Füllung", "src": "2fef1b14" }, + "s5.sh109.alt": { "text": "Schraubendreher mit einfarbiger Füllung", "src": "85a1be65" }, + "s5.sh110.alt": { "text": "Schraubendreher mit einfarbiger Füllung", "src": "85a1be65" }, + "s5.sh107.alt": { "text": "Hammer mit einfarbiger Füllung", "src": "9b482cef" }, + "s5.sh108.alt": { "text": "Hammer mit einfarbiger Füllung", "src": "9b482cef" }, + "s5.sh113.p1": { "text": "Wissen", "src": "ab55c08b" }, + "s5.sh115.alt": { "text": "Dokumentumriss", "src": "27286e9a" }, + "s5.sh117.alt": { "text": "Lupe mit einfarbiger Füllung", "src": "d243ec84" }, + "s5.sh118.alt": { "text": "Lupe mit einfarbiger Füllung", "src": "d243ec84" }, + "s5.sh126.p1": { "text": "Microsoft Foundry-Ressource", "src": "cb67db2f" }, + "s5.sh127.p1": { "text": "Microsoft Foundry-Projekt", "src": "c5276194" }, + "s5.sh3.p1": { "text": "© Copyright Microsoft Corporation. Alle Rechte vorbehalten.", "src": "eb8425d5" }, + "s5.notes.p1": { "text": "Definieren Sie Foundry in einem Satz: „Foundry bietet Ihnen eine projektbasierte Arbeitsumgebung, in der Sie KI-Apps und -Agenten mit Modellen, Tools und Wissen an einem zentralen Ort entwickeln können.“", "src": "d82646c3" }, + "s5.notes.p2": { "text": "Betonen Sie das zugrunde liegende Konzept: Ressourcen stellen Dienste für Rechenleistung, Speicher und die Bereitstellung von Modellen bereit.", "src": "55564b5c" }, + "s5.notes.p3": { "text": "Projekte sind isolierte Arbeitsumgebungen für die App- & Agentenentwicklung.", "src": "8e58c919" }, + "s5.notes.p4": { "text": "Innerhalb von Projekten: Modelle, Agenten, Tools, Wissen.", "src": "4d6ec164" }, + "s5.notes.p5": { "text": "Praktischer Hinweis: „Wenn die Lernenden den Überblick verlieren, verweisen Sie wieder auf diese Abbildung – die meisten Aufgaben lassen sich einem dieser vier Bereiche zuordnen.“", "src": "54027b8d" } + } +} diff --git a/tools/llm-provider.mjs b/tools/llm-provider.mjs index dc9506f..ae84d9a 100644 --- a/tools/llm-provider.mjs +++ b/tools/llm-provider.mjs @@ -6,6 +6,8 @@ // Providers throw ProviderError; `fatal` means retrying or continuing is pointless // (bad credentials, unknown model, ...), so the CLI aborts instead of skipping strings. +import { spawn } from 'node:child_process'; + export class ProviderError extends Error { constructor(message, { status, fatal = false } = {}) { super(message); @@ -15,10 +17,11 @@ export class ProviderError extends Error { } } -export const PROVIDERS = ['openai', 'mock']; +export const PROVIDERS = ['copilot', 'openai', 'mock']; const DEFAULT_BASE_URL = 'https://api.openai.com/v1'; const REQUEST_TIMEOUT_MS = 120_000; +const COPILOT_TIMEOUT_MS = 300_000; const MAX_RATE_LIMIT_RETRIES = 5; const MAX_TRANSIENT_RETRIES = 3; const MAX_WAIT_MS = 120_000; @@ -27,6 +30,8 @@ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export function createProvider(name, options = {}) { switch (name) { + case 'copilot': + return createCopilotProvider(options); case 'openai': return createOpenAICompatibleProvider(options); case 'mock': @@ -36,6 +41,76 @@ export function createProvider(name, options = {}) { } } +// Uses the GitHub Copilot CLI in non-interactive mode, so translation runs on the +// models included with your Copilot subscription: no API key, nothing to configure +// beyond being signed in to `copilot`. Tools, MCP servers and custom instructions are +// all turned off, so the model can only answer; it cannot touch files or the shell. +// The prompt goes in on stdin (no command-line length limit, nothing in the process list). +// --model optional; otherwise the CLI's default model +// LLM_REASONING_EFFORT low (default) | medium | high ... +// COPILOT_BIN path to the copilot executable if it is not on PATH +export function flattenMessages(messages) { + const parts = messages.map(({ role, content }) => `[${role}]\n${content}`); + parts.push('Reply with only the JSON object, no commentary.'); + return parts.join('\n\n'); +} + +function createCopilotProvider(options) { + const env = options.env ?? process.env; + const spawnImpl = options.spawn ?? spawn; + const command = env.COPILOT_BIN || 'copilot'; + const model = options.model || env.LLM_MODEL || ''; + const effort = env.LLM_REASONING_EFFORT || 'low'; + + const args = [ + '-s', '--no-ask-user', '--no-custom-instructions', '--disable-builtin-mcps', '--no-color', + '--available-tools', '', '--reasoning-effort', effort, + ]; + if (model) args.push('--model', model); + + return { + name: 'copilot', + complete({ messages, signal }) { + return new Promise((resolve, reject) => { + let child; + try { + child = spawnImpl(command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + } catch (err) { + reject(new ProviderError(`Could not start "${command}": ${err.message}`, { fatal: true })); + return; + } + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill(), COPILOT_TIMEOUT_MS); + signal?.addEventListener('abort', () => child.kill(), { once: true }); + + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', (err) => { + clearTimeout(timer); + const missing = err.code === 'ENOENT'; + reject(new ProviderError( + missing ? `GitHub Copilot CLI not found ("${command}"). Install it and run "copilot" once to sign in, or set COPILOT_BIN.` : `Copilot CLI failed: ${err.message}`, + { fatal: missing }, + )); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code === 0 && stdout.trim()) { + resolve(stdout.trim()); + return; + } + const detail = (stderr || stdout).trim().slice(0, 500); + const auth = /log ?in|auth|token|sign ?in|subscription|not (?:available|enabled)|unknown model|invalid model/i.test(detail); + reject(new ProviderError(`Copilot CLI exited with code ${code}: ${detail || 'no output'}`, { fatal: auth })); + }); + child.stdin.on('error', () => {}); + child.stdin.end(flattenMessages(messages)); + }); + }, + }; +} + // Parses a Retry-After header (delta-seconds or HTTP-date) into milliseconds. export function parseRetryAfter(value, now = Date.now()) { if (value == null || value === '') return null; diff --git a/tools/translate.mjs b/tools/translate.mjs index ce95584..5099243 100644 --- a/tools/translate.mjs +++ b/tools/translate.mjs @@ -2,7 +2,7 @@ // Translate a deck's string table with an LLM. See docs/string-table.md for the contract. // // node tools/translate.mjs --to de,fr [--force] [--dry-run] -// [--provider openai|mock] [--model ] [--base-url ] +// [--provider copilot|openai|mock] [--model ] [--base-url ] // // Exit codes: 0 ok, 1 error (nothing partial written for the failing language), // 2 finished but some strings were skipped (they fall back to source in the viewer). @@ -19,14 +19,16 @@ Options: --to Comma-separated BCP 47 tags, e.g. de,pt-BR,ar (required) --force Retranslate every string, ignoring existing translations --dry-run Show what would be translated; no LLM calls, nothing written - --provider ${PROVIDERS.join(' | ')} (default: openai) - --model Model / deployment name (or LLM_MODEL) - --base-url OpenAI-compatible endpoint (or LLM_BASE_URL) + --provider ${PROVIDERS.join(' | ')} (default: copilot) + --model Model name (or LLM_MODEL); copilot defaults to the CLI's own model + --base-url OpenAI-compatible endpoint (or LLM_BASE_URL); openai provider only --batch-size Max strings per request (default 30) --decks-dir Decks directory (default: /decks) -h, --help Show this help -Environment (provider "openai"): LLM_API_KEY or OPENAI_API_KEY, LLM_MODEL, LLM_BASE_URL, +Provider "copilot" runs the GitHub Copilot CLI (sign in once with \`copilot\`); it uses the +models in your Copilot subscription and needs no key. Optional: LLM_REASONING_EFFORT, COPILOT_BIN. +Provider "openai": LLM_API_KEY or OPENAI_API_KEY, LLM_MODEL, LLM_BASE_URL, LLM_AUTH_HEADER=bearer|api-key, LLM_JSON_MODE=off.`; const LANG_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/; @@ -36,7 +38,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. class UsageError extends Error {} export function parseArgs(argv) { - const opts = { force: false, dryRun: false, provider: 'openai', langs: [] }; + const opts = { force: false, dryRun: false, provider: 'copilot', langs: [] }; const positional = []; const takeValue = (i, flag) => { if (i + 1 >= argv.length || argv[i + 1].startsWith('--')) throw new UsageError(`${flag} needs a value`); diff --git a/tools/translate.test.mjs b/tools/translate.test.mjs index 7637c49..25f8c59 100644 --- a/tools/translate.test.mjs +++ b/tools/translate.test.mjs @@ -1,5 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; import { mkdtemp, mkdir, readFile, writeFile, copyFile, rm, access } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -404,6 +405,47 @@ test('openai provider config errors are explicit and fatal', () => { assert.throws(() => createProvider('nope'), /Unknown provider/); }); +test('copilot provider sends the prompt on stdin with tools disabled and returns stdout', async () => { + const calls = []; + const spawn = (command, args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => {}; + child.stdin = { on() {}, end(text) { calls.push({ command, args, stdin: text }); setImmediate(() => { child.stdout.emit('data', '{"translations":{}}\n'); child.emit('close', 0); }); } }; + return child; + }; + const provider = createProvider('copilot', { env: {}, model: 'some-model', spawn }); + const out = await provider.complete({ messages: [{ role: 'system', content: 'SYS' }, { role: 'user', content: 'USER' }] }); + assert.equal(out, '{"translations":{}}'); + assert.equal(calls[0].command, 'copilot'); + assert.deepEqual(calls[0].args.slice(calls[0].args.indexOf('--available-tools'), calls[0].args.indexOf('--available-tools') + 2), ['--available-tools', '']); + assert.ok(calls[0].args.includes('--no-custom-instructions')); + assert.deepEqual(calls[0].args.slice(-2), ['--model', 'some-model']); + assert.ok(calls[0].stdin.includes('[system]\nSYS') && calls[0].stdin.includes('[user]\nUSER')); + assert.ok(!calls[0].args.some((a) => a.includes('USER')), 'prompt must not be on the command line'); +}); + +test('copilot provider failures: missing CLI and sign-in problems are fatal, others are not', async () => { + const failing = (emit) => () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => {}; + child.stdin = { on() {}, end() { setImmediate(() => emit(child)); } }; + return child; + }; + const messages = [{ role: 'user', content: 'x' }]; + const missing = createProvider('copilot', { env: {}, spawn: failing((c) => c.emit('error', Object.assign(new Error('spawn copilot ENOENT'), { code: 'ENOENT' }))) }); + await assert.rejects(missing.complete({ messages }), (e) => e instanceof ProviderError && e.fatal && /not found/.test(e.message)); + + const signedOut = createProvider('copilot', { env: {}, spawn: failing((c) => { c.stderr.emit('data', 'Please log in with copilot login'); c.emit('close', 1); }) }); + await assert.rejects(signedOut.complete({ messages }), (e) => e.fatal === true); + + const flaky = createProvider('copilot', { env: {}, spawn: failing((c) => { c.stderr.emit('data', 'something odd'); c.emit('close', 1); }) }); + await assert.rejects(flaky.complete({ messages }), (e) => e.fatal === false); +}); + test('retry-after parsing handles seconds and HTTP dates', () => { assert.equal(parseRetryAfter('3'), 3000); assert.equal(parseRetryAfter('Wed, 21 Oct 2015 07:28:05 GMT', Date.parse('Wed, 21 Oct 2015 07:28:00 GMT')), 5000); From c3900d1393f681f53f40c83f79c394fec88adf5d Mon Sep 17 00:00:00 2001 From: Ivor Berry <11559977+ivorb@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:57:15 -0500 Subject: [PATCH 3/4] Add Spanish and French sample translations --- decks/sample-ai-apps/strings.es.json | 127 +++++++++++++++++++++++++++ decks/sample-ai-apps/strings.fr.json | 127 +++++++++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 decks/sample-ai-apps/strings.es.json create mode 100644 decks/sample-ai-apps/strings.fr.json diff --git a/decks/sample-ai-apps/strings.es.json b/decks/sample-ai-apps/strings.es.json new file mode 100644 index 0000000..6218010 --- /dev/null +++ b/decks/sample-ai-apps/strings.es.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "lang": "es", + "sourceLang": "en-US", + "strings": { + "s1.sh2.p1": { "text": "Desarrolle aplicaciones de IA generativa en Azure", "src": "7e7c01f9" }, + "s1.sh3.p1": { "text": "Junio de 2025", "src": "67047020" }, + "s1.sh5.p1": { "text": "https://aka.ms/mslearn-gen-ai-app-dev", "src": "124da598" }, + "s1.notes.p1": { "text": "Utilice el enlace de la diapositiva para consultar la ruta de aprendizaje de Microsoft Learn en la que se basa esta presentación.", "src": "a9b54f42" }, + "s2.sh5.p1": { "text": "Cargas de trabajo de inteligencia artificial", "src": "783619a6" }, + "s2.sh3.p1": { "text": "© Copyright Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s2.sh32.alt": { "text": "Chat con relleno sólido", "src": "ec9a46d3" }, + "s2.sh29.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s2.sh25.p1": { "text": "IA generativa y agentes", "src": "4e78ef44" }, + "s2.sh44.alt": { "text": "Contorno de documento", "src": "27286e9a" }, + "s2.sh40.p1": { "text": "Texto y lenguaje", "src": "a9eadd37" }, + "s2.sh49.alt": { "text": "Contorno de centro de llamadas", "src": "a781af24" }, + "s2.sh47.p1": { "text": "Tecnologías del habla", "src": "771f42be" }, + "s2.sh54.alt": { "text": "Escaneo ocular con relleno sólido", "src": "06bb4243" }, + "s2.sh52.p1": { "text": "Visión artificial", "src": "23c85bd0" }, + "s2.sh60.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s2.sh61.alt": { "text": "Contorno de contrato", "src": "31cc5d99" }, + "s2.sh57.p1": { "text": "Extracción de información", "src": "7c60c39a" }, + "s2.sh67.alt": { "text": "Engranajes con relleno sólido", "src": "8e4ad78f" }, + "s2.sh64.p1": { "text": "Aprendizaje automático", "src": "9d25e72c" }, + "s2.notes.p1": { "text": "Oriente a los participantes: «Azure AI abarca varias familias de cargas de trabajo; hoy nos centraremos en la IA generativa y los agentes».", "src": "7c63c722" }, + "s2.notes.p2": { "text": "Comparación rápida: aprendizaje automático clásico (predictivo, f(x)=y) frente a IA generativa (lenguaje + razonamiento + generación de contenido).", "src": "c70d5d0b" }, + "s3.sh5.p1": { "text": "Nuestro enfoque de hoy", "src": "ebfd8520" }, + "s3.sh44.alt": { "text": "Contorno de documento", "src": "27286e9a" }, + "s3.sh40.p1": { "text": "Texto y lenguaje", "src": "a9eadd37" }, + "s3.sh49.alt": { "text": "Contorno de centro de llamadas", "src": "a781af24" }, + "s3.sh47.p1": { "text": "Tecnologías del habla", "src": "771f42be" }, + "s3.sh54.alt": { "text": "Escaneo ocular con relleno sólido", "src": "06bb4243" }, + "s3.sh52.p1": { "text": "Visión artificial", "src": "23c85bd0" }, + "s3.sh60.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s3.sh61.alt": { "text": "Contorno de un contrato", "src": "31cc5d99" }, + "s3.sh57.p1": { "text": "Extracción de información", "src": "7c60c39a" }, + "s3.sh67.alt": { "text": "Engranajes con relleno sólido", "src": "8e4ad78f" }, + "s3.sh64.p1": { "text": "Aprendizaje automático", "src": "9d25e72c" }, + "s3.sh8.alt": { "text": "Burbuja de chat con relleno sólido", "src": "ec9a46d3" }, + "s3.sh6.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s3.sh9.p1": { "text": "IA generativa y agentes", "src": "4e78ef44" }, + "s3.sh3.p1": { "text": "© Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s3.notes.p1": { "text": "Recalque los límites del contenido: «Si han venido por la visión, el habla o la traducción, esas capacidades existen, pero hoy nos centraremos en los modelos de IA generativa y en el desarrollo de aplicaciones de chat».", "src": "3774d615" }, + "s3.notes.p2": { "text": "Anticipe la estructura de la jornada: conceptos básicos de la plataforma → modelos → puntos de conexión y SDK → desarrollo de aplicaciones de chat → optimización → IA responsable.", "src": "aa2c688c" }, + "s4.sh2.p1": { "text": "Planificar y preparar el desarrollo de soluciones de IA en Azure", "src": "b7da702c" }, + "s4.sh4.p1": { "text": "https://aka.ms/mslearn-prepare-ai-dev", "src": "71f607d1" }, + "s4.notes.p1": { "text": "Utilice el enlace de la diapositiva para consultar el módulo de Microsoft Learn en el que se basa esta lección.", "src": "073c9933" }, + "s5.M.sh37.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh38.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh48.p1": { "text": "73262F", "src": "0182554f" }, + "s5.M.sh50.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh51.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh52.p1": { "text": "FFB3BB", "src": "4155e0ae" }, + "s5.M.sh55.p1": { "text": "FFFFFF", "src": "4414127d" }, + "s5.M.sh57.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh58.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh59.p1": { "text": "FF5C39", "src": "64f573ad" }, + "s5.M.sh61.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh62.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh63.p1": { "text": "73391D", "src": "26a09c72" }, + "s5.M.sh65.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh66.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh67.p1": { "text": "FFA38B", "src": "be00c8cf" }, + "s5.M.sh70.p1": { "text": "F4F3F5", "src": "4f0282d5" }, + "s5.M.sh72.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh73.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh74.p1": { "text": "0078D4", "src": "3d9ce272" }, + "s5.M.sh76.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh77.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh78.p1": { "text": "2A446F", "src": "87444a50" }, + "s5.M.sh80.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh81.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh82.p1": { "text": "8DC8E8", "src": "93ca13c7" }, + "s5.M.sh87.p1": { "text": "Negro azulado", "src": "921a7b2e" }, + "s5.M.sh88.p1": { "text": "091F2C", "src": "e2f1513a" }, + "s5.M.sh93.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh94.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh95.p1": { "text": "702573", "src": "c11cc7e1" }, + "s5.M.sh97.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh98.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh99.p1": { "text": "CD9BCF", "src": "55b6be38" }, + "s5.M.sh102.p1": { "text": "000000", "src": "68d6565d" }, + "s5.M.sh105.p1": { "text": "Gris neutro", "src": "84317097" }, + "s5.M.sh106.p1": { "text": "E8E6DF", "src": "573f15a9" }, + "s5.M.sh34.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh35.p1": { "text": "F4364C", "src": "689e5837" }, + "s5.M.sh107.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh90.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh91.p1": { "text": "C73ECC", "src": "685cf649" }, + "s5.M.sh108.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh149.p1": { "text": "© Copyright Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s5.sh5.p1": { "text": "Microsoft Foundry", "src": "ccb80b4a" }, + "s5.sh131.p1": { "text": "Una plataforma unificada para aplicaciones y agentes de IA", "src": "04c0dca1" }, + "s5.sh130.p1": { "text": "Los recursos de Foundry en Azure proporcionan capacidad de procesamiento, almacenamiento, distribución de modelos y otros servicios", "src": "def6826b" }, + "s5.sh130.p2": { "text": "Los proyectos de Foundry son entornos de trabajo aislados para desarrollar aplicaciones y agentes", "src": "7d9928ca" }, + "s5.sh130.p3": { "text": "En un proyecto, trabajas con:", "src": "63a267fe" }, + "s5.sh130.p4": { "text": "Modelos – elige e implementa modelos de un amplio catálogo", "src": "4c53e53a" }, + "s5.sh130.p5": { "text": "Agentes – crea y administra agentes que utilizan modelos para tareas de lenguaje y razonamiento", "src": "0ff8d083" }, + "s5.sh130.p6": { "text": "Herramientas – conecta los agentes a herramientas para que puedan realizar acciones y acceder a información", "src": "7eac46a1" }, + "s5.sh130.p7": { "text": "Conocimiento – administra el acceso de los agentes a las fuentes de datos para que dispongan de la información que necesitan para actuar", "src": "f1e79bfc" }, + "s5.sh83.p1": { "text": "", "src": "d642d845" }, + "s5.sh92.p1": { "text": "Modelos", "src": "01cd0fab" }, + "s5.sh95.p1": { "text": "Agentes", "src": "e7af956f" }, + "s5.sh97.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s5.sh98.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s5.sh99.alt": { "text": "Robot con relleno sólido", "src": "28889807" }, + "s5.sh102.p1": { "text": "Herramientas", "src": "950196fc" }, + "s5.sh104.alt": { "text": "Llave inglesa con relleno sólido", "src": "2fef1b14" }, + "s5.sh109.alt": { "text": "Destornillador con relleno sólido", "src": "85a1be65" }, + "s5.sh110.alt": { "text": "Destornillador con relleno sólido", "src": "85a1be65" }, + "s5.sh107.alt": { "text": "Martillo con relleno sólido", "src": "9b482cef" }, + "s5.sh108.alt": { "text": "Martillo con relleno sólido", "src": "9b482cef" }, + "s5.sh113.p1": { "text": "Conocimiento", "src": "ab55c08b" }, + "s5.sh115.alt": { "text": "Contorno de un documento", "src": "27286e9a" }, + "s5.sh117.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s5.sh118.alt": { "text": "Lupa con relleno sólido", "src": "d243ec84" }, + "s5.sh126.p1": { "text": "Recurso de Microsoft Foundry", "src": "cb67db2f" }, + "s5.sh127.p1": { "text": "Proyecto de Microsoft Foundry", "src": "c5276194" }, + "s5.sh3.p1": { "text": "© Copyright Microsoft Corporation. Todos los derechos reservados.", "src": "eb8425d5" }, + "s5.notes.p1": { "text": "Define Foundry en una frase: «Foundry te ofrece un espacio de trabajo basado en proyectos para crear aplicaciones y agentes de IA con modelos, herramientas y conocimiento en un solo lugar».", "src": "d82646c3" }, + "s5.notes.p2": { "text": "Destaca el modelo conceptual: los recursos proporcionan servicios de cómputo, almacenamiento y suministro de modelos.", "src": "55564b5c" }, + "s5.notes.p3": { "text": "Los proyectos son entornos de trabajo aislados para desarrollar aplicaciones y agentes.", "src": "8e58c919" }, + "s5.notes.p4": { "text": "Dentro de los proyectos: Modelos, Agentes, Herramientas, Conocimiento.", "src": "4d6ec164" }, + "s5.notes.p5": { "text": "Enfoque práctico: «Cuando los participantes se sientan perdidos, vuelve a mostrarles esta imagen: la mayoría de las tareas corresponden a una de estas cuatro categorías».", "src": "54027b8d" } + } +} diff --git a/decks/sample-ai-apps/strings.fr.json b/decks/sample-ai-apps/strings.fr.json new file mode 100644 index 0000000..ce2e25d --- /dev/null +++ b/decks/sample-ai-apps/strings.fr.json @@ -0,0 +1,127 @@ +{ + "version": 1, + "lang": "fr", + "sourceLang": "en-US", + "strings": { + "s1.sh2.p1": { "text": "Développer des applications d’IA générative dans Azure", "src": "7e7c01f9" }, + "s1.sh3.p1": { "text": "Juin 2025", "src": "67047020" }, + "s1.sh5.p1": { "text": "https://aka.ms/mslearn-gen-ai-app-dev", "src": "124da598" }, + "s1.notes.p1": { "text": "Utilisez le lien sur la diapositive pour consulter le parcours de formation Microsoft Learn dont cette présentation est issue.", "src": "a9b54f42" }, + "s2.sh5.p1": { "text": "Charges de travail d’intelligence artificielle", "src": "783619a6" }, + "s2.sh3.p1": { "text": "© Copyright Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s2.sh32.alt": { "text": "Bulle de discussion pleine", "src": "ec9a46d3" }, + "s2.sh29.alt": { "text": "Robot plein", "src": "28889807" }, + "s2.sh25.p1": { "text": "IA générative et agents", "src": "4e78ef44" }, + "s2.sh44.alt": { "text": "Contour de document", "src": "27286e9a" }, + "s2.sh40.p1": { "text": "Texte et langage", "src": "a9eadd37" }, + "s2.sh49.alt": { "text": "Contour de centre d’appels", "src": "a781af24" }, + "s2.sh47.p1": { "text": "Traitement de la parole", "src": "771f42be" }, + "s2.sh54.alt": { "text": "Icône pleine de balayage oculaire", "src": "06bb4243" }, + "s2.sh52.p1": { "text": "Vision par ordinateur", "src": "23c85bd0" }, + "s2.sh60.alt": { "text": "Loupe pleine", "src": "d243ec84" }, + "s2.sh61.alt": { "text": "Contour de contrat", "src": "31cc5d99" }, + "s2.sh57.p1": { "text": "Extraction d’informations", "src": "7c60c39a" }, + "s2.sh67.alt": { "text": "Engrenages pleins", "src": "8e4ad78f" }, + "s2.sh64.p1": { "text": "Apprentissage automatique", "src": "9d25e72c" }, + "s2.notes.p1": { "text": "Situez le sujet pour les apprenants : « Azure AI couvre plusieurs familles de charges de travail. Aujourd’hui, nous nous concentrons sur l’IA générative et les agents. »", "src": "7c63c722" }, + "s2.notes.p2": { "text": "Comparaison rapide : apprentissage automatique classique (prédictif, f(x)=y) et IA générative (langage + raisonnement + génération de contenu).", "src": "c70d5d0b" }, + "s3.sh5.p1": { "text": "Notre sujet aujourd’hui", "src": "ebfd8520" }, + "s3.sh44.alt": { "text": "Contour de document", "src": "27286e9a" }, + "s3.sh40.p1": { "text": "Texte et langage", "src": "a9eadd37" }, + "s3.sh49.alt": { "text": "Contour de centre d’appels", "src": "a781af24" }, + "s3.sh47.p1": { "text": "Traitement de la parole", "src": "771f42be" }, + "s3.sh54.alt": { "text": "Icône pleine de balayage oculaire", "src": "06bb4243" }, + "s3.sh52.p1": { "text": "Vision par ordinateur", "src": "23c85bd0" }, + "s3.sh60.alt": { "text": "Loupe pleine", "src": "d243ec84" }, + "s3.sh61.alt": { "text": "Icône de contrat en contour", "src": "31cc5d99" }, + "s3.sh57.p1": { "text": "Extraction d’informations", "src": "7c60c39a" }, + "s3.sh67.alt": { "text": "Engrenages en aplat", "src": "8e4ad78f" }, + "s3.sh64.p1": { "text": "Apprentissage automatique", "src": "9d25e72c" }, + "s3.sh8.alt": { "text": "Bulle de conversation en aplat", "src": "ec9a46d3" }, + "s3.sh6.alt": { "text": "Robot en aplat", "src": "28889807" }, + "s3.sh9.p1": { "text": "IA générative et agents", "src": "4e78ef44" }, + "s3.sh3.p1": { "text": "© Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s3.notes.p1": { "text": "Rappelez les limites du périmètre : « Si vous êtes venus pour la vision, la reconnaissance vocale ou la traduction, ces fonctionnalités existent, mais aujourd’hui, nous nous concentrons sur les modèles d’IA générative et le développement d’applications de conversation. »", "src": "3774d615" }, + "s3.notes.p2": { "text": "Présentez le déroulement de la journée : bases de la plateforme → modèles → points de terminaison/SDK → développement d’applications de conversation → optimisation → IA responsable.", "src": "aa2c688c" }, + "s4.sh2.p1": { "text": "Planifier et préparer le développement de solutions d’IA sur Azure", "src": "b7da702c" }, + "s4.sh4.p1": { "text": "https://aka.ms/mslearn-prepare-ai-dev", "src": "71f607d1" }, + "s4.notes.p1": { "text": "Utilisez le lien sur la diapositive pour consulter le module Microsoft Learn dont cette leçon est issue.", "src": "073c9933" }, + "s5.M.sh37.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh38.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh48.p1": { "text": "73262F", "src": "0182554f" }, + "s5.M.sh50.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh51.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh52.p1": { "text": "FFB3BB", "src": "4155e0ae" }, + "s5.M.sh55.p1": { "text": "FFFFFF", "src": "4414127d" }, + "s5.M.sh57.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh58.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh59.p1": { "text": "FF5C39", "src": "64f573ad" }, + "s5.M.sh61.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh62.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh63.p1": { "text": "73391D", "src": "26a09c72" }, + "s5.M.sh65.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh66.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh67.p1": { "text": "FFA38B", "src": "be00c8cf" }, + "s5.M.sh70.p1": { "text": "F4F3F5", "src": "4f0282d5" }, + "s5.M.sh72.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh73.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh74.p1": { "text": "0078D4", "src": "3d9ce272" }, + "s5.M.sh76.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh77.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh78.p1": { "text": "2A446F", "src": "87444a50" }, + "s5.M.sh80.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh81.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh82.p1": { "text": "8DC8E8", "src": "93ca13c7" }, + "s5.M.sh87.p1": { "text": "Bleu-noir", "src": "921a7b2e" }, + "s5.M.sh88.p1": { "text": "091F2C", "src": "e2f1513a" }, + "s5.M.sh93.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh94.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh95.p1": { "text": "702573", "src": "c11cc7e1" }, + "s5.M.sh97.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh98.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh99.p1": { "text": "CD9BCF", "src": "55b6be38" }, + "s5.M.sh102.p1": { "text": "000000", "src": "68d6565d" }, + "s5.M.sh105.p1": { "text": "Gris neutre", "src": "84317097" }, + "s5.M.sh106.p1": { "text": "E8E6DF", "src": "573f15a9" }, + "s5.M.sh34.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh35.p1": { "text": "F4364C", "src": "689e5837" }, + "s5.M.sh107.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh90.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh91.p1": { "text": "C73ECC", "src": "685cf649" }, + "s5.M.sh108.p1": { "text": "A", "src": "c40bf6cc" }, + "s5.M.sh149.p1": { "text": "© Copyright Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s5.sh5.p1": { "text": "Microsoft Foundry", "src": "ccb80b4a" }, + "s5.sh131.p1": { "text": "Une plateforme unifiée pour les applications et les agents d’IA", "src": "04c0dca1" }, + "s5.sh130.p1": { "text": "Les ressources Foundry dans Azure fournissent des services de calcul, de stockage, de mise à disposition de modèles et d’autres services", "src": "def6826b" }, + "s5.sh130.p2": { "text": "Les projets Foundry sont des environnements de travail isolés pour le développement d’applications et d’agents", "src": "7d9928ca" }, + "s5.sh130.p3": { "text": "Dans un projet, vous travaillez avec :", "src": "63a267fe" }, + "s5.sh130.p4": { "text": "Modèles – choisissez et déployez des modèles à partir d’un vaste catalogue", "src": "4c53e53a" }, + "s5.sh130.p5": { "text": "Agents – créez et gérez des agents qui utilisent des modèles pour le traitement du langage et le raisonnement", "src": "0ff8d083" }, + "s5.sh130.p6": { "text": "Outils – connectez les agents à des outils pour leur permettre d’effectuer des actions et d’accéder à des informations", "src": "7eac46a1" }, + "s5.sh130.p7": { "text": "Connaissances – gérez l’accès des agents aux sources de données afin qu’ils disposent des informations nécessaires pour agir", "src": "f1e79bfc" }, + "s5.sh83.p1": { "text": "", "src": "d642d845" }, + "s5.sh92.p1": { "text": "Modèles", "src": "01cd0fab" }, + "s5.sh95.p1": { "text": "Agents", "src": "e7af956f" }, + "s5.sh97.alt": { "text": "Robot en silhouette pleine", "src": "28889807" }, + "s5.sh98.alt": { "text": "Robot en silhouette pleine", "src": "28889807" }, + "s5.sh99.alt": { "text": "Robot en silhouette pleine", "src": "28889807" }, + "s5.sh102.p1": { "text": "Outils", "src": "950196fc" }, + "s5.sh104.alt": { "text": "Clé plate en silhouette pleine", "src": "2fef1b14" }, + "s5.sh109.alt": { "text": "Tournevis en silhouette pleine", "src": "85a1be65" }, + "s5.sh110.alt": { "text": "Tournevis en silhouette pleine", "src": "85a1be65" }, + "s5.sh107.alt": { "text": "Marteau en silhouette pleine", "src": "9b482cef" }, + "s5.sh108.alt": { "text": "Marteau en silhouette pleine", "src": "9b482cef" }, + "s5.sh113.p1": { "text": "Connaissances", "src": "ab55c08b" }, + "s5.sh115.alt": { "text": "Contour d’un document", "src": "27286e9a" }, + "s5.sh117.alt": { "text": "Loupe en silhouette pleine", "src": "d243ec84" }, + "s5.sh118.alt": { "text": "Loupe en silhouette pleine", "src": "d243ec84" }, + "s5.sh126.p1": { "text": "Ressource Microsoft Foundry", "src": "cb67db2f" }, + "s5.sh127.p1": { "text": "Projet Microsoft Foundry", "src": "c5276194" }, + "s5.sh3.p1": { "text": "© Copyright Microsoft Corporation. Tous droits réservés.", "src": "eb8425d5" }, + "s5.notes.p1": { "text": "Définissez Foundry en une phrase : « Foundry vous offre un espace de travail organisé en projets pour créer des applications et des agents d’IA, avec des modèles, des outils et des connaissances réunis au même endroit. »", "src": "d82646c3" }, + "s5.notes.p2": { "text": "Insistez sur le modèle conceptuel : les ressources fournissent des services de calcul, de stockage et de mise à disposition de modèles.", "src": "55564b5c" }, + "s5.notes.p3": { "text": "Les projets sont des environnements de travail isolés pour le développement d’applications & d’agents.", "src": "8e58c919" }, + "s5.notes.p4": { "text": "Au sein des projets : modèles, agents, outils, connaissances.", "src": "4d6ec164" }, + "s5.notes.p5": { "text": "Repère pratique : « Lorsque les apprenants se sentent perdus, revenez à ce schéma : la plupart des tâches se rattachent à l’une de ces quatre catégories. »", "src": "54027b8d" } + } +} From f9b86335f51aec1e7ae2eb5d00751c6626a40465 Mon Sep 17 00:00:00 2001 From: Ivor Berry <11559977+ivorb@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:59:38 -0500 Subject: [PATCH 4/4] Language picker for HTML decks (served and single-file); list translations in decks.json --- README.md | 3 ++- assets/deck-i18n.js | 57 ++++++++++++++++++++++++++++++++++++++- assets/pptx-slide.css | 28 +++++++++++++++++++ deck.html | 23 ++++++++++++++++ decks.json | 7 ++++- tools/build-manifest.mjs | 6 +++++ tools/standalone-html.mjs | 6 +++++ 7 files changed, 127 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 40ffc0d..93d0fcd 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,8 @@ Open `convert.html` on the served site (or `node tools/pptx-to-html.mjs talk.ppt The folder holds `slides.html`, `strings.json` and `images/`. `strings.json` lists every paragraph, speaker note and alt text under a stable ID, in the format described in [`docs/string-table.md`](docs/string-table.md); translations are `strings..json` -files in the same folder, and `?lang=` on the deck or standalone file shows one. +files in the same folder. A language picker appears in the corner of the deck (and the +single-file version) when translations exist; `?lang=` opens one directly. Longer translated text shrinks to fit its box (down to 60%); add `&review` to the address to outline boxes that still overflow. diff --git a/assets/deck-i18n.js b/assets/deck-i18n.js index 21ea18b..574ae88 100644 --- a/assets/deck-i18n.js +++ b/assets/deck-i18n.js @@ -114,7 +114,62 @@ if (reveal) fitSlide(reveal.getCurrentSlide()); } + function languageName(code) { + try { + var label = new Intl.DisplayNames([code], { type: 'language' }).of(code); + return label ? label.charAt(0).toUpperCase() + label.slice(1) : code; + } catch (e) { + return code; + } + } + + /** + * A language