From 5e2eb9608260a6209d4176094ca3834dd6fb085e Mon Sep 17 00:00:00 2001 From: Howie Young Date: Sat, 8 Aug 2026 19:44:52 +0800 Subject: [PATCH 1/2] Content language follows the reader's language, with an override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make generated content global-ready. A new src/locale.ts resolves the language for LLM-generated content (topic labels, article summaries, social context, the weekly reading digest, the cover concept): 1. explicit override — USER_CONFIG.contentLanguage, when set and not "auto" 2. dominant pages.lang, when enough pages carry a real language tag 3. a zero-dep script heuristic (kana/hangul/CJK/Latin) over recent reading, since Chrome/extension language tags are frequently empty 4. English as the ultimate fallback The generator prompts (enrich, digest, cover) are now English templates that emit their output in the resolved language; image-generation prompts stay English regardless (image models expect English). userConfig gains contentLanguage: "auto" as the per-user override channel (a BCP-47 code or a plain language name). No behavior change for existing readers whose content is already in one language (the resolver returns that language); new content simply follows what they read. Co-Authored-By: Claude Opus 4.8 --- src/locale.ts | 100 +++++++++++++++++++++++++++++++ src/pipeline/enrich.ts | 32 +++++++--- src/render/cover.ts | 40 +++++++------ src/render/digest.ts | 24 +++++--- src/shared/userConfig.example.ts | 13 ++-- 5 files changed, 168 insertions(+), 41 deletions(-) create mode 100644 src/locale.ts diff --git a/src/locale.ts b/src/locale.ts new file mode 100644 index 0000000..83b8323 --- /dev/null +++ b/src/locale.ts @@ -0,0 +1,100 @@ +import { getDb } from "./db.js"; +import { USER_CONFIG } from "./shared/userConfig.js"; + +/** + * Resolves the language used for LLM-generated content (topic labels, summaries, + * the weekly reading digest, the cover concept). This is a global-facing project, + * so the default follows the language the user actually reads. + * + * Resolution order: + * 1. Explicit override — USER_CONFIG.contentLanguage, when set and not "auto". + * 2. The `pages.lang` column, when enough pages carry a real language tag. + * 3. A zero-dependency script heuristic over recent titles + summaries + * (kana → Japanese, hangul → Korean, other CJK → Chinese, Latin → English), + * because Chrome/extension language tags are frequently empty. + * 4. English as the ultimate fallback. + * + * Image-generation prompts stay English regardless (image models expect English); + * only the human-readable concept text follows this language. + */ + +// BCP-47-ish code → human language name used inside prompts ("write in "). +const LANG_NAMES: Record = { + en: "English", + zh: "Traditional Chinese", + "zh-tw": "Traditional Chinese", + "zh-hant": "Traditional Chinese", + "zh-hk": "Traditional Chinese", + "zh-cn": "Simplified Chinese", + "zh-hans": "Simplified Chinese", + ja: "Japanese", + ko: "Korean", + es: "Spanish", + fr: "French", + de: "German", + pt: "Portuguese", + it: "Italian", + nl: "Dutch", + ru: "Russian", +}; + +function nameForCode(code: string): string | null { + const c = code.trim().toLowerCase(); + if (LANG_NAMES[c]) return LANG_NAMES[c]; + const base = c.split("-")[0]; + return LANG_NAMES[base] ?? null; +} + +// Dominant language among pages that carry a usable tag (empty/null/und/zz filtered out). +// Returns null when the signal is too thin to trust. +function fromLangColumn(): string | null { + const rows = getDb() + .prepare( + `SELECT lang, COUNT(*) AS n FROM pages + WHERE is_knowledge = 1 AND lang IS NOT NULL AND lang != '' AND lang != 'und' AND lang != 'zz' + GROUP BY lang ORDER BY n DESC`, + ) + .all() as Array<{ lang: string; n: number }>; + const total = rows.reduce((a, r) => a + r.n, 0); + if (total < 5) return null; // too little signal — fall through to the script heuristic + return nameForCode(rows[0].lang); +} + +// Script heuristic over recent reading — robust when language tags are missing. +function fromScript(): string | null { + const rows = getDb() + .prepare( + `SELECT COALESCE(title, '') || ' ' || COALESCE(summary, '') AS t FROM pages + WHERE is_knowledge = 1 AND summary IS NOT NULL + ORDER BY last_seen DESC LIMIT 60`, + ) + .all() as Array<{ t: string }>; + let kana = 0; + let hangul = 0; + let cjk = 0; + let latin = 0; + for (const { t } of rows) { + for (const ch of t) { + const cp = ch.codePointAt(0) ?? 0; + if (cp >= 0x3040 && cp <= 0x30ff) kana++; + else if (cp >= 0xac00 && cp <= 0xd7a3) hangul++; + else if (cp >= 0x4e00 && cp <= 0x9fff) cjk++; + else if ((cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a)) latin++; + } + } + const max = Math.max(kana, hangul, cjk, latin); + if (max === 0) return null; + if (max === kana) return "Japanese"; + if (max === hangul) return "Korean"; + if (max === cjk) return "Traditional Chinese"; // can't tell trad/simp by script; lang column covers Simplified + return "English"; +} + +export function resolveContentLanguage(): string { + const override = (USER_CONFIG as { contentLanguage?: string }).contentLanguage; + if (override && override.trim().toLowerCase() !== "auto") { + // Accept either a BCP-47 code ("en", "ja") or a plain language name ("English"). + return nameForCode(override) ?? override.trim(); + } + return fromLangColumn() ?? fromScript() ?? "English"; +} diff --git a/src/pipeline/enrich.ts b/src/pipeline/enrich.ts index 25731dc..9f6d028 100644 --- a/src/pipeline/enrich.ts +++ b/src/pipeline/enrich.ts @@ -1,6 +1,7 @@ import { getDb } from "../db.js"; import { fetchArticle } from "../fetch/extract.js"; import { getProvider, parseJsonReply } from "../llm/provider.js"; +import { resolveContentLanguage } from "../locale.js"; import { normalizeTitle } from "../shared/urls.js"; /** @@ -73,14 +74,21 @@ export function applyEnrichment(records: EnrichmentRecord[]): { updated: number; export async function classifyCandidates(candidates: Candidate[]): Promise { if (candidates.length === 0) return []; const provider = getProvider(); + const lang = resolveContentLanguage(); const list = candidates.map((c) => ({ id: c.id, kind: c.kind, title: c.title, host: new URL(c.url).hostname })); const reply = await provider.complete({ system: - "你是 Browstack 個人週刊的選題編輯。只收錄知識型內容:科技、AI、產業分析、商業洞察、深度公共議題、專業知識、有觀點或資訊價值的社群貼文。" + - "一律排除:娛樂八卦、彩券、購物促銷、會員活動、網站專區或列表頁(非單篇內容)、純聊天或情緒抒發、廣告宣傳頁、" + - "以及所有「快查行為」——百科條目、字典查詢、單字問答、天氣、對獎,這些是查資料不是閱讀。", + "You are the commissioning editor of the Browstack personal weekly digest. Keep only knowledge-type " + + "content: technology, AI, industry analysis, business insight, substantive public-affairs pieces, " + + "professional knowledge, and social posts with a real point of view or informational value. " + + "Always exclude: entertainment gossip, lotteries, shopping promos, membership drives, site sections or " + + "list pages (not a single piece), pure chatter or venting, ad/marketing pages, and all 'quick lookup' " + + "behavior — encyclopedia entries, dictionary/word lookups, weather, prize checks — which is looking " + + "things up, not reading.", prompt: - `判斷以下候選內容,回傳 JSON array,每項格式 {"id": number, "is_knowledge": boolean, "topic": "2~6字中文主題標籤"}(非知識型的 topic 給 null)。只輸出 JSON。\n\n` + + `Classify the candidates below. Return a JSON array; each item ` + + `{"id": number, "is_knowledge": boolean, "topic": "a short topic label in ${lang} (2–4 words, or 2–6 characters for CJK)"} ` + + `(topic null when not knowledge-type). Output only JSON.\n\n` + JSON.stringify(list, null, 1), maxTokens: 4096, }); @@ -121,6 +129,7 @@ export async function fetchMissingContent(limit = 12): Promise<{ fetched: number export async function summarizeKnowledgePages(): Promise { const db = getDb(); const provider = getProvider(); + const lang = resolveContentLanguage(); const weekAgo = Math.floor(Date.now() / 1000) - DAYS * 86400; // 文章:內文(或退而求其次用標題)→ 三個重點 + 一句 takeaway @@ -158,10 +167,13 @@ export async function summarizeKnowledgePages(): Promise { } knownArticles.add(titleKey); const reply = await provider.complete({ - system: "你是週刊編輯,把文章濃縮成足以取代原文閱讀的摘要。", + system: `You are a weekly-digest editor. Condense the article into a summary written in ${lang} that can replace reading the original.`, prompt: - `輸出 JSON:{"bullets": ["…", "…", "…"], "takeaway": "…"}。三個 bullet 各 ≤ 42 字,takeaway 是一句「為什麼值得記住」≤ 32 字。只輸出 JSON。\n\n` + - `標題:${a.title}\n內文節錄:${a.content_text.slice(0, 6000)}`, + `Output JSON: {"bullets": ["…", "…", "…"], "takeaway": "…"}, written in ${lang}. ` + + `Three bullets, each a single tight line (≈ ≤ 14 words, or ≤ 42 characters for CJK); ` + + `the takeaway is one line on "why this is worth remembering" (≈ ≤ 11 words, or ≤ 32 characters for CJK). ` + + `Output only JSON.\n\n` + + `Title: ${a.title}\nBody excerpt: ${a.content_text.slice(0, 6000)}`, maxTokens: 1024, }); saveSummary.run(JSON.stringify(parseJsonReply(reply)), a.id); @@ -200,9 +212,11 @@ export async function summarizeKnowledgePages(): Promise { }); if (posts.length > 0) { const reply = await provider.complete({ - system: "你是週刊編輯。", + system: "You are a weekly-digest editor.", prompt: - `為每則社群貼文寫一句編輯脈絡(≤ 36 字,說明它在談什麼、為何值得記住)。回傳 JSON array:[{"id": number, "context": "…"}]。只輸出 JSON。\n\n` + + `For each social post, write one line of editorial context in ${lang} ` + + `(≈ ≤ 12 words, or ≤ 36 characters for CJK; what it is about and why it is worth remembering). ` + + `Return a JSON array: [{"id": number, "context": "…"}]. Output only JSON.\n\n` + JSON.stringify(posts.map((p) => ({ id: p.id, text: p.title.slice(0, 500) }))), maxTokens: 2048, }); diff --git a/src/render/cover.ts b/src/render/cover.ts index e4c97e3..ccf60b1 100644 --- a/src/render/cover.ts +++ b/src/render/cover.ts @@ -6,6 +6,7 @@ import { getCurrentIssue } from "../issue.js"; import { ClaudeCliProvider } from "../llm/claudeCli.js"; import { getImageProvider } from "../llm/image.js"; import { getProvider, parseJsonReply } from "../llm/provider.js"; +import { resolveContentLanguage } from "../locale.js"; /** * 封面生成引擎:每期依內容主題,以 The New Yorker 的封面藝術語言生成插畫。 @@ -52,25 +53,29 @@ if (items.length === 0) { const provider = getProvider(); console.log(`本期主題素材 ${items.length} 項,請 ${provider.name} 擔任藝術總監…`); +const lang = resolveContentLanguage(); const reply = await provider.complete({ system: - "你是 The New Yorker 的封面藝術總監,為個人週刊 Browstack(把讀者自己的瀏覽閱讀編成刊物)設計本期封面。" + - "你的任務:從本期內容中找出『一個』值得評論的時代觀察,轉化成單一視覺隱喻。不要拼貼多個主題。", + "You are the cover art director for Browstack, a personal weekly digest that turns the reader's " + + "own browsing/reading into an issue. Your job: from this week's content, find ONE observation worth " + + "commenting on and turn it into a single visual metaphor. Do not collage multiple topics.", prompt: - `本期內容主題與標題:\n${JSON.stringify(items, null, 1)}\n\n` + - `固定風格規格(不可違反):\n${ART_DIRECTION_EN}\n\n` + - `輸出 JSON:{"concept_zh": "80 字內的概念說明(給編輯看)", "image_prompt_en": "給圖像生成模型的完整英文 prompt,含場景、構圖、色盤 hex、氛圍,並完整內嵌上述風格規格的要求"}。只輸出 JSON。`, + `This issue's topics and titles:\n${JSON.stringify(items, null, 1)}\n\n` + + `Fixed style spec (must not be violated):\n${ART_DIRECTION_EN}\n\n` + + `Output JSON: {"concept": "a concept note for the editor, <= ~60 words, written in ${lang}", ` + + `"image_prompt_en": "the full ENGLISH prompt for the image model — scene, composition, palette hex, ` + + `mood — with the style spec above fully embedded"}. Output only JSON.`, maxTokens: 2048, }); -const concept = parseJsonReply<{ concept_zh: string; image_prompt_en: string }>(reply); +const concept = parseJsonReply<{ concept: string; image_prompt_en: string }>(reply); const coversDir = path.join(CONFIG.dataDir, "..", "assets", "covers"); fs.mkdirSync(coversDir, { recursive: true }); fs.writeFileSync( path.join(coversDir, `issue-${issueNo}.concept.json`), JSON.stringify(concept, null, 2), ); -console.log(`\n本期封面概念:${concept.concept_zh}\n`); +console.log(`\n本期封面概念:${concept.concept}\n`); try { if (process.env.BROWSTACK_DISABLE_IMAGE) throw new Error("圖像引擎已由 BROWSTACK_DISABLE_IMAGE 停用"); @@ -95,7 +100,7 @@ try { } } -async function generateSvgCover(c: { concept_zh: string; image_prompt_en: string }): Promise { +async function generateSvgCover(c: { concept: string; image_prompt_en: string }): Promise { // 偏好最強訂閱模型+高思考;不可用時退回預設模型。畫圖較慢,給 10 分鐘。 const artists = CONFIG.llm.provider === "claude-cli" @@ -109,16 +114,17 @@ async function generateSvgCover(c: { concept_zh: string; image_prompt_en: string try { const reply = await artist.complete({ system: - "你是頂尖的向量插畫家,以 The New Yorker 封面傳統作畫。你將直接用 SVG 作為畫布完成一幅完整、精緻、有構圖層次的插畫。", + "You are a top vector illustrator working in the New Yorker cover tradition. You will use SVG " + + "directly as your canvas to produce one complete, refined illustration with compositional depth.", prompt: - `依下列概念完成封面插畫:\n${c.concept_zh}\n\n場景參考(供理解,不必逐字照做):${c.image_prompt_en.slice(0, 800)}\n\n` + - `硬性規格:\n` + - `- 只輸出一個完整的 ,不要任何其他文字或圍欄\n` + - `- viewBox="0 0 1000 1500" 直式構圖;畫面上緣 18% 保持簡潔供刊頭壓字\n` + - `- 只用這些顏色:#1f4e5f #16394a #b5361c #e8a13d #f2e8d5 #211c15 #6b8f71 #d9cfb4\n` + - `- 扁平色塊、無漸層、無濾鏡;構圖要有前中後景與大量負空間\n` + - `- 禁止:任何文字/字母/數字、