From 5e313d65890a21eac60a1684d517cff789e1751c Mon Sep 17 00:00:00 2001 From: Zain Kazmi Date: Fri, 22 May 2026 21:38:41 -0400 Subject: [PATCH 1/3] feat: support language-specific feed translation prompts Signed-off-by: Zain Kazmi --- lib/middleware/parameter.test.ts | 30 +++++++++++++++++ lib/middleware/parameter.ts | 57 +++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/lib/middleware/parameter.test.ts b/lib/middleware/parameter.test.ts index c602a901dea5..bdc11e038451 100644 --- a/lib/middleware/parameter.test.ts +++ b/lib/middleware/parameter.test.ts @@ -1,4 +1,5 @@ import Parser from 'rss-parser'; +import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; process.env.OPENAI_API_KEY = 'sk-1234567890'; @@ -459,4 +460,33 @@ describe('openai', () => { expect(parsedDescriptionOnly.items[0].title).not.toContain('AI processed content.'); expect(parsedDescriptionOnly.items[0].content).toContain('AI processed content.'); }); + + it('supports target language override via query', async () => { + const { default: server } = await import('@/setup.test'); + const originalInput = config.openai.inputOption; + + server.use( + http.post('https://api.openai.mock/v1/chat/completions', async ({ request }) => { + const body = (await request.json()) as { messages?: Array<{ content?: string }> }; + const prompt = body.messages?.[0]?.content ?? ''; + + if (prompt.includes('into Japanese')) { + return HttpResponse.json({ + choices: [{ message: { content: '日本語タイトル' } }], + }); + } + + return HttpResponse.json({ + choices: [{ message: { content: 'AI processed content.' } }], + }); + }) + ); + + config.openai.inputOption = 'title'; + const response = await app.request('/test/gpt?chatgpt=true&chatgpt_to=Japanese&chatgpt_from=English'); + const parsed = await parser.parseString(await response.text()); + expect(parsed.items[0].title).toBe('日本語タイトル'); + + config.openai.inputOption = originalInput; + }); }); diff --git a/lib/middleware/parameter.ts b/lib/middleware/parameter.ts index 07e70e098005..c52abdd0ac13 100644 --- a/lib/middleware/parameter.ts +++ b/lib/middleware/parameter.ts @@ -56,6 +56,43 @@ const getAiCompletion = async (prompt: string, text: string) => { return response.choices[0].message.content; }; +const getChatgptLanguageOptions = (ctx) => { + const targetLanguage = ctx.req.query('chatgpt_to') ?? ctx.req.query('chatgpt_lang'); + const sourceLanguage = ctx.req.query('chatgpt_from'); + + return { + targetLanguage: targetLanguage?.trim() || undefined, + sourceLanguage: sourceLanguage?.trim() || undefined, + }; +}; + +const buildTranslationPrompt = (field: 'title' | 'content', targetLanguage: string, sourceLanguage?: string) => { + const fromSegment = sourceLanguage ? ` from ${sourceLanguage}` : ''; + const contentSegment = field === 'title' ? 'title' : 'content'; + const formatSegment = field === 'title' ? 'reply only translated text' : 'preserve markdown formatting'; + + return `Please translate the following ${contentSegment}${fromSegment} into ${targetLanguage} and ${formatSegment}.`; +}; + +const getOpenAiPrompt = (ctx, field: 'title' | 'content') => { + const { targetLanguage, sourceLanguage } = getChatgptLanguageOptions(ctx); + + if (!targetLanguage) { + return field === 'title' ? config.openai.promptTitle : config.openai.promptDescription; + } + + return buildTranslationPrompt(field, targetLanguage, sourceLanguage); +}; + +const getOpenAiCachePrefix = (ctx) => { + const { targetLanguage, sourceLanguage } = getChatgptLanguageOptions(ctx); + if (!targetLanguage && !sourceLanguage) { + return 'default'; + } + + return `${sourceLanguage ?? 'auto'}->${targetLanguage ?? 'default'}`; +}; + const getAuthorString = (item) => { let author = ''; if (item.author) { @@ -330,14 +367,18 @@ const middleware: MiddlewareHandler = async (ctx, next) => { // openai if (ctx.req.query('chatgpt') && config.openai.apiKey) { + const titlePrompt = getOpenAiPrompt(ctx, 'title'); + const descriptionPrompt = getOpenAiPrompt(ctx, 'content'); + const cachePrefix = getOpenAiCachePrefix(ctx); + data.item = await Promise.all( data.item.map(async (item) => { try { // handle description if (config.openai.inputOption === 'description' && item.description) { - const description = await cache.tryGet(`openai:description:${item.link}`, async () => { + const description = await cache.tryGet(`openai:${cachePrefix}:description:${item.link}`, async () => { const description = convert(item.description!); - const descriptionMd = await getAiCompletion(config.openai.promptDescription, description); + const descriptionMd = await getAiCompletion(descriptionPrompt, description); return md.render(descriptionMd); }); // add it to the description @@ -347,9 +388,9 @@ const middleware: MiddlewareHandler = async (ctx, next) => { } // handle title else if (config.openai.inputOption === 'title' && item.title) { - const title = await cache.tryGet(`openai:title:${item.link}`, async () => { + const title = await cache.tryGet(`openai:${cachePrefix}:title:${item.link}`, async () => { const title = convert(item.title!); - return await getAiCompletion(config.openai.promptTitle, title); + return await getAiCompletion(titlePrompt, title); }); // replace the title if (title !== '') { @@ -358,18 +399,18 @@ const middleware: MiddlewareHandler = async (ctx, next) => { } // handle both else if (config.openai.inputOption === 'both' && item.title && item.description) { - const title = await cache.tryGet(`openai:title:${item.link}`, async () => { + const title = await cache.tryGet(`openai:${cachePrefix}:title:${item.link}`, async () => { const title = convert(item.title!); - return await getAiCompletion(config.openai.promptTitle, title); + return await getAiCompletion(titlePrompt, title); }); // replace the title if (title !== '') { item.title = title + ''; } - const description = await cache.tryGet(`openai:description:${item.link}`, async () => { + const description = await cache.tryGet(`openai:${cachePrefix}:description:${item.link}`, async () => { const description = convert(item.description!); - const descriptionMd = await getAiCompletion(config.openai.promptDescription, description); + const descriptionMd = await getAiCompletion(descriptionPrompt, description); return md.render(descriptionMd); }); // add it to the description From 21258f953719ea00994f4131e1a8cf7a835e1bc9 Mon Sep 17 00:00:00 2001 From: Zain Kazmi Date: Fri, 22 May 2026 22:32:25 -0400 Subject: [PATCH 2/3] test: fix import ordering in parameter middleware tests --- lib/middleware/parameter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/middleware/parameter.test.ts b/lib/middleware/parameter.test.ts index bdc11e038451..9124a2a421d8 100644 --- a/lib/middleware/parameter.test.ts +++ b/lib/middleware/parameter.test.ts @@ -1,5 +1,5 @@ -import Parser from 'rss-parser'; import { http, HttpResponse } from 'msw'; +import Parser from 'rss-parser'; import { describe, expect, it, vi } from 'vitest'; process.env.OPENAI_API_KEY = 'sk-1234567890'; From 3732c408db268e469463e3d88da222d73ead6189 Mon Sep 17 00:00:00 2001 From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com> Date: Sat, 23 May 2026 03:44:35 +0000 Subject: [PATCH 3/3] fix: keep default cache key without target language --- lib/middleware/parameter.test.ts | 27 +++++++++++++++++++++++++++ lib/middleware/parameter.ts | 4 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/middleware/parameter.test.ts b/lib/middleware/parameter.test.ts index 9124a2a421d8..c2a1e88aa299 100644 --- a/lib/middleware/parameter.test.ts +++ b/lib/middleware/parameter.test.ts @@ -489,4 +489,31 @@ describe('openai', () => { config.openai.inputOption = originalInput; }); + + it('keeps the default cache key when only source language is provided', async () => { + const { default: server } = await import('@/setup.test'); + const cache = (await import('@/utils/cache')).default; + const originalInput = config.openai.inputOption; + let requests = 0; + + cache.clients.memoryCache?.clear(); + server.use( + http.post('https://api.openai.mock/v1/chat/completions', () => { + requests += 1; + return HttpResponse.json({ + choices: [{ message: { content: 'cache-compatible output' } }], + }); + }) + ); + + config.openai.inputOption = 'title'; + await app.request('/test/gpt?chatgpt=true'); + const requestsAfterDefault = requests; + await app.request('/test/gpt?chatgpt=true&chatgpt_from=English'); + + expect(requests).toBe(requestsAfterDefault); + + config.openai.inputOption = originalInput; + cache.clients.memoryCache?.clear(); + }); }); diff --git a/lib/middleware/parameter.ts b/lib/middleware/parameter.ts index c52abdd0ac13..17e09b056f1b 100644 --- a/lib/middleware/parameter.ts +++ b/lib/middleware/parameter.ts @@ -86,11 +86,11 @@ const getOpenAiPrompt = (ctx, field: 'title' | 'content') => { const getOpenAiCachePrefix = (ctx) => { const { targetLanguage, sourceLanguage } = getChatgptLanguageOptions(ctx); - if (!targetLanguage && !sourceLanguage) { + if (!targetLanguage) { return 'default'; } - return `${sourceLanguage ?? 'auto'}->${targetLanguage ?? 'default'}`; + return `${sourceLanguage ?? 'auto'}->${targetLanguage}`; }; const getAuthorString = (item) => {