From 82abb5edfe456028d4d9c540bcc9f1cb3e96645a Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 21 May 2026 12:37:49 +0300 Subject: [PATCH 1/3] feat(translation,phrase): chunk-aware save schema, AI advisor, bundle suggestion PRFAQ-001 Phase 1 (server, CU-86exnxn8c): - Replace actual_phrase with chunks[{text,type,transliteration,confidence}]; drop linguistic_data.examples[], phonetic.ipa, and RelatedExpressionSchema. - getDetailedTranslation gains pageTitle/pageUrl inputs + suggested_bundle_name output; per-chunk + source-phrase transliteration in the target alphabet. - New translationAdvice RPC: Q&A tutor (reply) with optional chunk edits and conversation history. - normaliseSourceUrl util; persist chunks + normalised sourceUrl on phrase docs. - Tests for url-normalise and createPhrase persistence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/createPhrase.test.ts | 76 ++++++++++++++ server/src/modules/phrase_bundle/db.ts | 53 +++++----- server/src/modules/phrase_bundle/functions.ts | 5 + .../__tests__/url-normalise.test.ts | 48 +++++++++ server/src/modules/translation/functions.ts | 52 +++++++++- server/src/modules/translation/schema.ts | 74 +++++++++----- server/src/modules/translation/service.ts | 99 ++++++++++++++++++- server/src/modules/translation/types.ts | 23 ++++- .../src/modules/translation/url-normalise.ts | 30 ++++++ 9 files changed, 402 insertions(+), 58 deletions(-) create mode 100644 server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts create mode 100644 server/src/modules/translation/__tests__/url-normalise.test.ts create mode 100644 server/src/modules/translation/url-normalise.ts diff --git a/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts b/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts new file mode 100644 index 0000000..a3d9d20 --- /dev/null +++ b/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; + +// Capture inserted docs so we can assert what createPhrase persists. +const insertMany = jest.fn(); +const countDocuments = jest.fn(); +const findOne = jest.fn(); +const updateOne = jest.fn(); + +jest.mock("@modular-rest/server", () => ({ + // defineFunction returns its config so the callback is reachable in tests. + defineFunction: (config: any) => config, + getCollection: () => ({ + insertMany, + countDocuments, + findOne, + updateOne, + }), +})); + +jest.mock("../../subscription/service", () => ({ + isUserOnFreemium: jest.fn().mockResolvedValue(false), + updateFreemiumAllocation: jest.fn(), +})); + +jest.mock("../triggers", () => ({ phraseBundleTriggers: [] })); + +const { functions } = require("../functions"); +const createPhrase = functions.find((f: any) => f.name === "createPhrase"); + +describe("createPhrase persistence", () => { + beforeEach(() => { + jest.clearAllMocks(); + countDocuments.mockResolvedValue(0); // no bundles to verify + findOne.mockResolvedValue(null); // phrase does not already exist + insertMany.mockResolvedValue([{ _id: "new-phrase-id" }]); + updateOne.mockResolvedValue({}); + }); + + it("persists chunks and a normalised sourceUrl on a linguistic phrase", async () => { + await createPhrase.callback({ + phrase: "we had to decide quickly", + translation: "...", + translation_language: "fa", + bundleIds: [], + refId: "user-1", + type: "linguistic", + context: "you know, we had to decide quickly because nobody else would.", + chunks: [{ text: "had to", type: "collocation", confidence: 0.9 }], + sourceUrl: "https://www.youtube.com/watch?v=abc123&t=42s#c", + }); + + expect(insertMany).toHaveBeenCalledTimes(1); + const doc = (insertMany.mock.calls[0][0] as any[])[0]; + expect(doc.chunks).toEqual([ + { text: "had to", type: "collocation", confidence: 0.9 }, + ]); + expect(doc.sourceUrl).toBe("https://www.youtube.com/watch"); + }); + + it("does not set chunks/sourceUrl for a normal phrase", async () => { + await createPhrase.callback({ + phrase: "hello", + translation: "...", + translation_language: "fa", + bundleIds: [], + refId: "user-1", + type: "normal", + chunks: [{ text: "x", type: "other", confidence: 1 }], + sourceUrl: "https://example.com/page", + }); + + const doc = (insertMany.mock.calls[0][0] as any[])[0]; + expect(doc.chunks).toBeUndefined(); + expect(doc.sourceUrl).toBeUndefined(); + }); +}); diff --git a/server/src/modules/phrase_bundle/db.ts b/server/src/modules/phrase_bundle/db.ts index 59a436e..56a39db 100644 --- a/server/src/modules/phrase_bundle/db.ts +++ b/server/src/modules/phrase_bundle/db.ts @@ -9,18 +9,16 @@ import { phraseBundleTriggers } from "./triggers"; import { DATABASE, BUNDLE_COLLECTION, PHRASE_COLLECTION } from "../../config"; -export type Example = { - /** Example sentence showing the text in use */ - source: string; - /** Translation of the example sentence */ - target: string; -}; - -export type RelatedExpression = { - /** Related word or expression */ - source: string; - /** Translation of the related expression */ - target: string; +/** A reusable language pattern the user confirmed inside their selection. */ +export type Chunk = { + /** The exact reusable pattern as it appears in the selection */ + text: string; + /** Kind of pattern (collocation, phrasal_verb, idiom, discourse_marker, other) */ + type: string; + /** Pronunciation of the chunk written in the target language alphabet */ + transliteration?: string; + /** Model confidence that this is a useful learnable chunk (0-1) */ + confidence: number; }; export type LinguisticData = { @@ -30,25 +28,12 @@ export type LinguisticData = { type: string; /** Clear explanation of meaning, contextualized to usage */ definition: string; - /** Information about how and when to use this text */ - // usage_notes: string; - /** Phonetic guidance (especially for non-Latin script languages) */ + /** Phonetic guidance written in the target language alphabet */ phonetic: { - ipa: string; transliteration: string; }; /** Indication of formality level */ formality_level: "formal" | "neutral" | "informal"; - /** When the literal meaning differs significantly from idiomatic usage */ - // literal_translation: string; - /** Cultural context important for proper understanding */ - // cultural_notes: string; - /** Additional grammatical information when relevant */ - // grammar_notes: string; - /** Example sentences showing the text in use, with translations */ - examples: Example[]; - /** Similar or connected expressions with translations */ - related_expressions: RelatedExpression[]; }; export interface PhraseSchema { @@ -72,6 +57,10 @@ export interface PhraseSchema { target: string; }; linguistic_data?: LinguisticData; + /** Confirmed reusable patterns inside the selection. Source of truth for Pool + L3+ fill-in. */ + chunks?: Chunk[]; + /** Normalised URL of the page the phrase was saved from. */ + sourceUrl?: string; } interface PhraseBundleSchema { @@ -100,6 +89,18 @@ const phraseSchema = new Schema( target: String, }, linguistic_data: Schema.Types.Mixed, + chunks: { + type: [ + { + text: String, + type: String, + transliteration: String, + confidence: Number, + }, + ], + default: [], + }, + sourceUrl: String, }, { timestamps: true } ); diff --git a/server/src/modules/phrase_bundle/functions.ts b/server/src/modules/phrase_bundle/functions.ts index 5c5e564..7ffa84b 100644 --- a/server/src/modules/phrase_bundle/functions.ts +++ b/server/src/modules/phrase_bundle/functions.ts @@ -7,6 +7,7 @@ import { // Import the PhraseSchema type from the database module import { PhraseSchema } from "./db"; +import { normaliseSourceUrl } from "../translation/url-normalise"; interface RemoveBundleParams { _id: string; @@ -145,6 +146,8 @@ const createPhrase = defineFunction({ direction, language_info, linguistic_data, + chunks, + sourceUrl, }: CreatePhraseParams): Promise => { const phraseBundleCollection = getCollection( DATABASE, @@ -192,6 +195,8 @@ const createPhrase = defineFunction({ if (direction) newPhraseDoc.direction = direction; if (language_info) newPhraseDoc.language_info = language_info; if (linguistic_data) newPhraseDoc.linguistic_data = linguistic_data; + if (chunks && chunks.length) newPhraseDoc.chunks = chunks; + if (sourceUrl) newPhraseDoc.sourceUrl = normaliseSourceUrl(sourceUrl); } // Insert new phrase diff --git a/server/src/modules/translation/__tests__/url-normalise.test.ts b/server/src/modules/translation/__tests__/url-normalise.test.ts new file mode 100644 index 0000000..623741a --- /dev/null +++ b/server/src/modules/translation/__tests__/url-normalise.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "@jest/globals"; +import { normaliseSourceUrl } from "../url-normalise"; + +describe("normaliseSourceUrl", () => { + it("drops the query string (including timestamps)", () => { + expect( + normaliseSourceUrl("https://www.youtube.com/watch?v=abc123&t=42s") + ).toBe("https://www.youtube.com/watch"); + }); + + it("drops the fragment", () => { + expect(normaliseSourceUrl("https://example.com/article#section-3")).toBe( + "https://example.com/article" + ); + }); + + it("lowercases the host but keeps the path case", () => { + expect(normaliseSourceUrl("https://EXAMPLE.com/Path/To")).toBe( + "https://example.com/Path/To" + ); + }); + + it("removes a trailing slash but keeps the root slash", () => { + expect(normaliseSourceUrl("https://example.com/path/")).toBe( + "https://example.com/path" + ); + expect(normaliseSourceUrl("https://example.com/")).toBe( + "https://example.com/" + ); + }); + + it("is idempotent", () => { + const once = normaliseSourceUrl( + "https://www.youtube.com/watch?v=abc123&t=42s#comments" + ); + expect(normaliseSourceUrl(once)).toBe(once); + }); + + it("returns the input unchanged when it cannot be parsed", () => { + expect(normaliseSourceUrl("not a url")).toBe("not a url"); + }); + + it("returns empty string for falsy input", () => { + expect(normaliseSourceUrl("")).toBe(""); + // @ts-expect-error testing runtime guard + expect(normaliseSourceUrl(undefined)).toBe(""); + }); +}); diff --git a/server/src/modules/translation/functions.ts b/server/src/modules/translation/functions.ts index f6e88b4..6c4d1bf 100644 --- a/server/src/modules/translation/functions.ts +++ b/server/src/modules/translation/functions.ts @@ -1,6 +1,10 @@ import { defineFunction } from "@modular-rest/server"; -import { getDetailedTranslation, getSimpleTranslation } from "./service"; -import { TranslateWithContextParams } from "./types"; +import { + getDetailedTranslation, + getSimpleTranslation, + getTranslationAdvice, +} from "./service"; +import { TranslateWithContextParams, TranslationAdviceParams } from "./types"; import { TextToSpeechClient } from "@google-cloud/text-to-speech"; /** @@ -18,6 +22,8 @@ const translateWithContext = defineFunction({ translationType = "simple", sourceLanguage = "", targetLanguage = "", + pageTitle, + pageUrl, } = params; // normalize the source language @@ -32,6 +38,8 @@ const translateWithContext = defineFunction({ context, sourceLanguage, targetLanguage, + pageTitle, + pageUrl, }); } @@ -171,4 +179,42 @@ const listVoices = defineFunction({ }, }); -export const functions = [translateWithContext, textToSpeech, listVoices]; +/** + * Conversational advisor for the save modal's "fix this?" chat. + * Returns either { reply } text or { chunks } updated patterns. + */ +const translationAdvice = defineFunction({ + name: "translationAdvice", + permissionTypes: ["anonymous_access"], + callback: async (params: TranslationAdviceParams) => { + const { phrase, context, message } = params; + + if (!message || typeof message !== "string") { + throw new Error("message is required"); + } + + try { + return getTranslationAdvice({ + phrase, + context, + message, + currentChunks: params.currentChunks, + history: params.history, + sourceLanguage: params.sourceLanguage, + targetLanguage: params.targetLanguage, + }); + } catch (error: unknown) { + console.error("Translation advice error:", error); + const errorMessage = + error instanceof Error ? error.message : String(error); + throw new Error(`Failed to get translation advice: ${errorMessage}`); + } + }, +}); + +export const functions = [ + translateWithContext, + textToSpeech, + listVoices, + translationAdvice, +]; diff --git a/server/src/modules/translation/schema.ts b/server/src/modules/translation/schema.ts index 2eb45e2..d0b3c94 100644 --- a/server/src/modules/translation/schema.ts +++ b/server/src/modules/translation/schema.ts @@ -6,16 +6,28 @@ import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; -// Define the Example schema -const ExampleSchema = z.object({ - source: z.string(), - target: z.string(), -}); - -// Define the RelatedExpression schema -const RelatedExpressionSchema = z.object({ - source: z.string(), - target: z.string(), +// A reusable language pattern detected inside the user's selection. +export const ChunkSchema = z.object({ + text: z + .string() + .describe("The exact reusable pattern as it appears in the selection."), + type: z + .enum([ + "collocation", + "phrasal_verb", + "idiom", + "discourse_marker", + "other", + ]) + .describe("Kind of reusable language pattern."), + transliteration: z + .string() + .describe( + "How to pronounce THIS chunk (the source-language text above), spelled out using the TARGET language's alphabet. Example with source=English, target=Persian: 'in fact' -> 'این فَکت', 'powers over' -> 'پاورز اوور'." + ), + confidence: z + .number() + .describe("Model confidence that this is a useful learnable chunk (0-1)."), }); // Define the LinguisticData schema @@ -34,31 +46,26 @@ const LinguisticDataSchema = z.object({ phonetic: z .object({ - ipa: z.string(), transliteration: z .string() .describe( - "write the pronunciation of the word in target language alphabets, example: 'box' -> 'باکس', 'helō' -> 'هِلو'" + "Pronunciation of the SOURCE-language phrase (the user's exact selection), spelled out using the TARGET language's alphabet. This is NOT a transliteration of the translation. Example with source=English, target=Persian: 'box' -> 'باکس', 'in fact' -> 'این فَکت'. Return an empty string for long selections (~5 words or more)." ), }) .describe( - "pronunciation guidance in two versions, one in source language (IPA) and one in target language (transliteration)" + "How to pronounce the source phrase, written in the target language alphabet" ), formality_level: z .enum(["formal", "neutral", "informal"]) .describe("Indication of formality level"), - - examples: z - .array(ExampleSchema) - .describe("Example of phrase usage in source language, with translation"), }); // Define the LanguageLearningData schema export const LanguageLearningDataSchema = z.object({ - actual_phrase: z - .string() + chunks: z + .array(ChunkSchema) .describe( - "detected correct combination of words from the context which could have complete meaning." + "Reusable language patterns found inside the user's selection. At most one chunk per 5-8 words of selection, hard ceiling of 2. Return an empty array for selections under ~5 words, or when the selection's language differs from the target learning language." ), direction: z .object({ @@ -84,14 +91,37 @@ export const LanguageLearningDataSchema = z.object({ linguistic_data: LinguisticDataSchema.describe( "Linguistic analysis data in target language" ), + suggested_bundle_name: z + .string() + .optional() + .describe( + "A short, clean bundle name derived from the page title, generalised so multiple episodes/chapters/articles from the same source group together (e.g. 'Stranger Things S2E5 — Netflix' -> 'Stranger Things S2'). Only set when a page title is provided." + ), }); // Type inference from Zod schemas -export type Example = z.infer; -export type RelatedExpression = z.infer; +export type Chunk = z.infer; export type LinguisticData = z.infer; export type DetailedPhraseDataType = z.infer; +// Advisor response: the model either replies with text or returns updated chunks. +export const TranslationAdviceSchema = z.object({ + reply: z + .string() + .optional() + .describe( + "A short plain-text answer to the user's question. Set this when the user asked for an explanation or advice rather than a change to the highlighted patterns." + ), + chunks: z + .array(ChunkSchema) + .optional() + .describe( + "Updated reusable patterns. Set this only when the user asked to change which patterns are highlighted. Same cap rules as the save flow apply." + ), +}); + +export type TranslationAdviceType = z.infer; + /** * Get the JSON schema for language learning data * Converts the Zod schema to a JSON Schema for use with OpenRouter's structured output diff --git a/server/src/modules/translation/service.ts b/server/src/modules/translation/service.ts index eccc78b..cb576a5 100644 --- a/server/src/modules/translation/service.ts +++ b/server/src/modules/translation/service.ts @@ -1,6 +1,14 @@ import { openRouter } from "../../utils/openrouter"; -import { DetailedPhraseDataType, TranslateWithContextParams } from "./types"; -import { LanguageLearningDataSchema } from "./schema"; +import { + DetailedPhraseDataType, + TranslateWithContextParams, + TranslationAdviceParams, + TranslationAdviceType, +} from "./types"; +import { + LanguageLearningDataSchema, + TranslationAdviceSchema, +} from "./schema"; /** * Get a simple translation of a phrase with context @@ -69,17 +77,32 @@ export async function getDetailedTranslation({ context, sourceLanguage = "en", targetLanguage, + pageTitle, + pageUrl, }: TranslateWithContextParams): Promise { // Create prompt for OpenRouter const systemPrompt = ` - As a language learning specialist, take the "phrase" and "context", and provide all descriptive fields in the mentioned target language. + As a language learning specialist, take the "phrase" and "context", and provide all descriptive fields in the mentioned target language. all grammart, cultural, and usage notes must be about the source language. - dont forget to use original source language terms in the notes when needed.`; + dont forget to use original source language terms in the notes when needed. + + Phonetic transliteration: spell out how to pronounce the SOURCE-language "phrase" itself (${sourceLanguage} -> read by a ${targetLanguage} speaker), written using the ${targetLanguage} alphabet. Do NOT transliterate the translation. For long selections (~5 words or more), return an empty string for the top-level transliteration and rely on the per-chunk transliterations instead. + + Chunks: inside the user's selection ("phrase"), find the reusable language patterns worth learning (collocations, phrasal verbs, idioms, discourse markers). + Rules: at most one chunk per 5-8 words of the selection, hard ceiling of 2 chunks. Each chunk's "text" must appear verbatim inside the selection. For each chunk, also provide its own "transliteration": how to pronounce that chunk (source language) written in the ${targetLanguage} alphabet. + Return an empty "chunks" array when the selection is under ~5 words, or when the selection is written in a different language than the target learning language. + ${ + pageTitle + ? `Bundle name: also produce "suggested_bundle_name" - a short, clean name derived from the page title that generalises across multiple episodes/chapters/articles from the same source (e.g. "Stranger Things S2E5 — Netflix" -> "Stranger Things S2").` + : `Do not set "suggested_bundle_name".` + }`; const userPrompt = ` Translate from ${sourceLanguage} to ${targetLanguage}: Phrase: "${phrase}" - Accuracy context: "${context}"`; + Accuracy context: "${context}"${ + pageTitle ? `\n Page title: "${pageTitle}"` : "" + }${pageUrl ? `\n Page URL: "${pageUrl}"` : ""}`; try { // Use the Zod schema directly with the OpenRouter service @@ -119,3 +142,69 @@ export async function getDetailedTranslation({ throw new Error(`Failed to translate text: ${errorMessage}`); } } + +/** + * Conversational advisor for the save modal's "fix this?" chat. + * Given the user's message and the current selection/context (+ current chunks), + * the model either replies with plain text or returns an updated chunks list. + */ +export async function getTranslationAdvice({ + phrase, + context, + message, + currentChunks = [], + history = [], + sourceLanguage = "en", + targetLanguage, +}: TranslationAdviceParams): Promise { + const systemPrompt = ` + You are a friendly language tutor helping the user understand a phrase they are learning. + The selection language is ${sourceLanguage}; reply in ${targetLanguage}. + Selection: "${phrase}" + Context: "${context}" + Currently highlighted patterns (chunks): ${JSON.stringify(currentChunks)} + + Your MAIN job is to answer the user's questions about this phrase: meaning, grammar, usage, nuance, examples, differences between words, etc. Put your answer in "reply". + + Editing the highlighted patterns is a SECONDARY ability. Only return a "chunks" array when the user EXPLICITLY asks to add, remove, or change which patterns are highlighted (e.g. "highlight X", "remove that", "don't include Y"). When you do, each chunk's "text" must appear verbatim in the selection, include its "transliteration" (pronunciation in the ${targetLanguage} alphabet), and you may also add a short "reply" explaining the change. + If the user is just asking a question (even one that mentions a phrase, like "what about 'on the'?"), answer it in "reply" and DO NOT change the chunks.`; + + // Maintain the conversation: replay prior turns so the model has context. + const historyMessages = (history || []) + .filter((m) => m && m.text) + .map((m) => ({ + role: (m.role === "assistant" ? "assistant" : "user") as + | "assistant" + | "user", + content: m.text, + })); + + try { + const result = + await openRouter.createStructuredOutputWithZod({ + options: { + models: [ + "google/gemini-2.5-flash-lite", + "google/gemini-2.5-flash", + "google/gemini-flash-1.5-8b", + ], + messages: [ + { role: "system", content: systemPrompt }, + ...historyMessages, + { role: "user", content: message }, + ], + temperature: 0, + max_tokens: 400, + }, + zodSchema: TranslationAdviceSchema, + schemaName: "translation_advice", + strict: true, + }); + + return result; + } catch (error: unknown) { + console.error("Translation advice error:", error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to get translation advice: ${errorMessage}`); + } +} diff --git a/server/src/modules/translation/types.ts b/server/src/modules/translation/types.ts index ec3e9f6..27d3173 100644 --- a/server/src/modules/translation/types.ts +++ b/server/src/modules/translation/types.ts @@ -2,10 +2,12 @@ export type { DetailedPhraseDataType, LinguisticData, - Example, - RelatedExpression, + Chunk, + TranslationAdviceType, } from "./schema"; +import type { Chunk } from "./schema"; + // Function parameter types export interface TranslateWithContextParams { phrase: string; @@ -13,4 +15,21 @@ export interface TranslateWithContextParams { sourceLanguage?: string; targetLanguage?: string; translationType?: "simple" | "detailed"; + pageTitle?: string; + pageUrl?: string; +} + +export interface TranslationAdviceMessage { + role: "user" | "assistant"; + text: string; +} + +export interface TranslationAdviceParams { + phrase: string; + context: string; + message: string; + currentChunks?: Chunk[]; + history?: TranslationAdviceMessage[]; + sourceLanguage?: string; + targetLanguage?: string; } diff --git a/server/src/modules/translation/url-normalise.ts b/server/src/modules/translation/url-normalise.ts new file mode 100644 index 0000000..4e59668 --- /dev/null +++ b/server/src/modules/translation/url-normalise.ts @@ -0,0 +1,30 @@ +/** + * Normalise a source page URL so that saves from the same content group together. + * Drops query strings, fragments, and known timestamp params; lowercases the host; + * removes a trailing slash. Returns the input unchanged when it cannot be parsed. + */ +export function normaliseSourceUrl(raw: string): string { + if (!raw || typeof raw !== "string") return ""; + + const trimmed = raw.trim(); + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return trimmed; + } + + url.hash = ""; + url.search = ""; + url.hostname = url.hostname.toLowerCase(); + + let normalised = url.toString(); + + // Drop a single trailing slash on the path (but keep the root "/"). + if (normalised.endsWith("/") && url.pathname !== "/") { + normalised = normalised.slice(0, -1); + } + + return normalised; +} From 363fe395daecf55f4fac5fc3266b47134a79e46d Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 21 May 2026 12:56:43 +0300 Subject: [PATCH 2/3] feat(translation): per-chunk definition for chunk-aware save Each chunk now carries its own short definition (alongside its transliteration), so multi-chunk phrases get one explanation per pattern instead of a single combined paragraph. Persisted on the phrase document. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/modules/phrase_bundle/db.ts | 3 +++ server/src/modules/translation/schema.ts | 5 +++++ server/src/modules/translation/service.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/modules/phrase_bundle/db.ts b/server/src/modules/phrase_bundle/db.ts index 56a39db..0b54c31 100644 --- a/server/src/modules/phrase_bundle/db.ts +++ b/server/src/modules/phrase_bundle/db.ts @@ -15,6 +15,8 @@ export type Chunk = { text: string; /** Kind of pattern (collocation, phrasal_verb, idiom, discourse_marker, other) */ type: string; + /** Short explanation of the chunk's meaning/usage in the target language */ + definition?: string; /** Pronunciation of the chunk written in the target language alphabet */ transliteration?: string; /** Model confidence that this is a useful learnable chunk (0-1) */ @@ -94,6 +96,7 @@ const phraseSchema = new Schema( { text: String, type: String, + definition: String, transliteration: String, confidence: Number, }, diff --git a/server/src/modules/translation/schema.ts b/server/src/modules/translation/schema.ts index d0b3c94..5c0ba2d 100644 --- a/server/src/modules/translation/schema.ts +++ b/server/src/modules/translation/schema.ts @@ -20,6 +20,11 @@ export const ChunkSchema = z.object({ "other", ]) .describe("Kind of reusable language pattern."), + definition: z + .string() + .describe( + "A short, self-contained explanation of THIS chunk's meaning and how it is used, written in the target language (1-2 sentences)." + ), transliteration: z .string() .describe( diff --git a/server/src/modules/translation/service.ts b/server/src/modules/translation/service.ts index cb576a5..4e88064 100644 --- a/server/src/modules/translation/service.ts +++ b/server/src/modules/translation/service.ts @@ -89,7 +89,7 @@ export async function getDetailedTranslation({ Phonetic transliteration: spell out how to pronounce the SOURCE-language "phrase" itself (${sourceLanguage} -> read by a ${targetLanguage} speaker), written using the ${targetLanguage} alphabet. Do NOT transliterate the translation. For long selections (~5 words or more), return an empty string for the top-level transliteration and rely on the per-chunk transliterations instead. Chunks: inside the user's selection ("phrase"), find the reusable language patterns worth learning (collocations, phrasal verbs, idioms, discourse markers). - Rules: at most one chunk per 5-8 words of the selection, hard ceiling of 2 chunks. Each chunk's "text" must appear verbatim inside the selection. For each chunk, also provide its own "transliteration": how to pronounce that chunk (source language) written in the ${targetLanguage} alphabet. + Rules: at most one chunk per 5-8 words of the selection, hard ceiling of 2 chunks. Each chunk's "text" must appear verbatim inside the selection. For each chunk, also provide: "transliteration" (how to pronounce that chunk, source language, in the ${targetLanguage} alphabet) and "definition" (a short, self-contained explanation of that chunk's meaning and usage, 1-2 sentences, in ${targetLanguage}). Return an empty "chunks" array when the selection is under ~5 words, or when the selection is written in a different language than the target learning language. ${ pageTitle From f6e9fc55b045ba63920a1a722aedbab38097e46d Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 21 May 2026 19:48:30 +0300 Subject: [PATCH 3/3] refactor(translation): move OpenRouter model list + pricing to a config Addresses PR review: define allowed models and their pricing in utils/openrouter-models.ts (TRANSLATION_MODELS) instead of duplicating the hardcoded ids/pricing comments across the translation service. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/modules/translation/service.ts | 23 ++--------- server/src/utils/openrouter-models.ts | 50 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 server/src/utils/openrouter-models.ts diff --git a/server/src/modules/translation/service.ts b/server/src/modules/translation/service.ts index 4e88064..9de87f3 100644 --- a/server/src/modules/translation/service.ts +++ b/server/src/modules/translation/service.ts @@ -9,6 +9,7 @@ import { LanguageLearningDataSchema, TranslationAdviceSchema, } from "./schema"; +import { TRANSLATION_MODELS } from "../../utils/openrouter-models"; /** * Get a simple translation of a phrase with context @@ -37,13 +38,7 @@ export async function getSimpleTranslation({ try { const response = await openRouter.createChatCompletion({ - models: [ - // Accepted models - "google/gemini-2.5-flash-lite", // 1.05M context, $0.10/M input, $0.40/M output - "google/gemini-2.5-flash", // 1m context, $0.15/M input, $0.60/M output - "google/gemini-flash-1.5-8b", // 1m context, $0.038/M input, $0.15/M output - // "openai/gpt-4.1-nano", // 1m context, $0.10/M input, $0.40/M output - ], + models: TRANSLATION_MODELS, messages: [ { role: "system", @@ -109,13 +104,7 @@ export async function getDetailedTranslation({ const result = await openRouter.createStructuredOutputWithZod({ options: { - models: [ - // Accepted models - "google/gemini-2.5-flash-lite", // 1.05M context, $0.10/M input, $0.40/M output - "google/gemini-2.5-flash", // 1m context, $0.15/M input, $0.60/M output - "google/gemini-flash-1.5-8b", // 1m context, $0.038/M input, $0.15/M output - // "openai/gpt-4.1-nano", // 1m context, $0.10/M input, $0.40/M output - ], + models: TRANSLATION_MODELS, messages: [ { role: "system", @@ -183,11 +172,7 @@ export async function getTranslationAdvice({ const result = await openRouter.createStructuredOutputWithZod({ options: { - models: [ - "google/gemini-2.5-flash-lite", - "google/gemini-2.5-flash", - "google/gemini-flash-1.5-8b", - ], + models: TRANSLATION_MODELS, messages: [ { role: "system", content: systemPrompt }, ...historyMessages, diff --git a/server/src/utils/openrouter-models.ts b/server/src/utils/openrouter-models.ts new file mode 100644 index 0000000..4d3598f --- /dev/null +++ b/server/src/utils/openrouter-models.ts @@ -0,0 +1,50 @@ +/** + * Central registry of the OpenRouter models the server is allowed to use, + * together with their context window and pricing. Keep model ids and pricing + * here (not inline in services) so cost is auditable in one place. + * + * Pricing is USD per 1,000,000 tokens, taken from OpenRouter. + */ + +export interface ModelPricing { + /** USD per 1M input tokens */ + inputPerMillion: number; + /** USD per 1M output tokens */ + outputPerMillion: number; +} + +export interface OpenRouterModel { + /** OpenRouter model id passed to the API */ + id: string; + /** Context window in tokens */ + contextWindow: number; + pricing: ModelPricing; +} + +export const OPENROUTER_MODELS = { + GEMINI_2_5_FLASH_LITE: { + id: "google/gemini-2.5-flash-lite", + contextWindow: 1_050_000, + pricing: { inputPerMillion: 0.1, outputPerMillion: 0.4 }, + }, + GEMINI_2_5_FLASH: { + id: "google/gemini-2.5-flash", + contextWindow: 1_000_000, + pricing: { inputPerMillion: 0.15, outputPerMillion: 0.6 }, + }, + GEMINI_1_5_FLASH_8B: { + id: "google/gemini-flash-1.5-8b", + contextWindow: 1_000_000, + pricing: { inputPerMillion: 0.038, outputPerMillion: 0.15 }, + }, +} satisfies Record; + +/** + * Preferred fallback order for translation + advisor calls (cheapest capable + * model first). OpenRouter tries each in turn if one is unavailable. + */ +export const TRANSLATION_MODELS: string[] = [ + OPENROUTER_MODELS.GEMINI_2_5_FLASH_LITE.id, + OPENROUTER_MODELS.GEMINI_2_5_FLASH.id, + OPENROUTER_MODELS.GEMINI_1_5_FLASH_8B.id, +];