Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>();
const countDocuments = jest.fn<any>();
const findOne = jest.fn<any>();
const updateOne = jest.fn<any>();

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<any>().mockResolvedValue(false),
updateFreemiumAllocation: jest.fn<any>(),
}));

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();
});
});
56 changes: 30 additions & 26 deletions server/src/modules/phrase_bundle/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ 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;
/** 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) */
confidence: number;
};

export type LinguisticData = {
Expand All @@ -30,25 +30,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 {
Expand All @@ -72,6 +59,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 {
Expand Down Expand Up @@ -100,6 +91,19 @@ const phraseSchema = new Schema<PhraseSchema>(
target: String,
},
linguistic_data: Schema.Types.Mixed,
chunks: {
type: [
{
text: String,
type: String,
definition: String,
transliteration: String,
confidence: Number,
},
],
default: [],
},
sourceUrl: String,
},
{ timestamps: true }
);
Expand Down
5 changes: 5 additions & 0 deletions server/src/modules/phrase_bundle/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -145,6 +146,8 @@ const createPhrase = defineFunction({
direction,
language_info,
linguistic_data,
chunks,
sourceUrl,
}: CreatePhraseParams): Promise<any> => {
const phraseBundleCollection = getCollection<any>(
DATABASE,
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions server/src/modules/translation/__tests__/url-normalise.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
});
52 changes: 49 additions & 3 deletions server/src/modules/translation/functions.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -18,6 +22,8 @@ const translateWithContext = defineFunction({
translationType = "simple",
sourceLanguage = "",
targetLanguage = "",
pageTitle,
pageUrl,
} = params;

// normalize the source language
Expand All @@ -32,6 +38,8 @@ const translateWithContext = defineFunction({
context,
sourceLanguage,
targetLanguage,
pageTitle,
pageUrl,
});
}

Expand Down Expand Up @@ -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,
];
Loading
Loading