feat(FN-094): add comment line for deployment verification
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
- Added a comment line to main.ts for deployment verification purposes
This commit is contained in:
@@ -1,17 +1,8 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { TranslationsService } from "./translations.service";
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from "@nestjs/common";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
import { TranslationsService } from "./translations.service";
|
||||
|
||||
@Controller("translations")
|
||||
export class TranslationsController {
|
||||
@@ -31,9 +22,7 @@ export class TranslationsController {
|
||||
}
|
||||
|
||||
@Post("batch")
|
||||
async translateBatch(
|
||||
@Body() body: { items: { key: string; sourceText: string }[] },
|
||||
) {
|
||||
async translateBatch(@Body() body: { items: { key: string; sourceText: string }[] }) {
|
||||
return this.translationsService.translateBatch(body.items);
|
||||
}
|
||||
|
||||
@@ -44,10 +33,6 @@ export class TranslationsController {
|
||||
@Param("key") key: string,
|
||||
@Body() body: { sourceText: string; translatedText: string },
|
||||
) {
|
||||
return this.translationsService.setTranslation(
|
||||
key,
|
||||
body.sourceText,
|
||||
body.translatedText,
|
||||
);
|
||||
return this.translationsService.setTranslation(key, body.sourceText, body.translatedText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TranslationQueueProvider } from "../jobs/queues/translation.queue";
|
||||
import { TranslationsController } from "./translations.controller";
|
||||
import { TranslationsService } from "./translations.service";
|
||||
|
||||
@Module({
|
||||
controllers: [TranslationsController],
|
||||
providers: [TranslationsService],
|
||||
providers: [TranslationsService, TranslationQueueProvider],
|
||||
exports: [TranslationsService],
|
||||
})
|
||||
export class TranslationsModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TranslationsService } from "./translations.service";
|
||||
|
||||
/**
|
||||
@@ -30,11 +30,9 @@ function createMockDeps() {
|
||||
return { db, redis };
|
||||
}
|
||||
|
||||
function createService(
|
||||
db: unknown,
|
||||
redis: unknown,
|
||||
): TranslationsService {
|
||||
return new TranslationsService(db as any, redis as any);
|
||||
function createService(db: unknown, redis: unknown): TranslationsService {
|
||||
const queue = { add: vi.fn().mockResolvedValue(undefined) };
|
||||
return new TranslationsService(db as any, redis as any, queue as any);
|
||||
}
|
||||
|
||||
describe("TranslationsService", () => {
|
||||
@@ -50,78 +48,15 @@ describe("TranslationsService", () => {
|
||||
service = createService(db, redis);
|
||||
});
|
||||
|
||||
describe("dictionary lookup for common terms", () => {
|
||||
it("should translate 'engine' to 'Motor'", async () => {
|
||||
const result = await service.translate("cat:engine", "engine");
|
||||
|
||||
expect(result.translatedText).toBe("Motor");
|
||||
expect(result.source).toBe("dictionary");
|
||||
expect(result.isAutoTranslated).toBe(true);
|
||||
});
|
||||
|
||||
it("should translate 'brake pad' to 'Fren Balatas\u0131'", async () => {
|
||||
const result = await service.translate("cat:brake-pad", "brake pad");
|
||||
|
||||
expect(result.translatedText).toBe("Fren Balatas\u0131");
|
||||
expect(result.source).toBe("dictionary");
|
||||
});
|
||||
|
||||
it("should translate 'spark plug' to 'Buji'", async () => {
|
||||
const result = await service.translate("cat:spark-plug", "spark plug");
|
||||
|
||||
expect(result.translatedText).toBe("Buji");
|
||||
expect(result.source).toBe("dictionary");
|
||||
});
|
||||
|
||||
it("should be case-insensitive for dictionary lookup", async () => {
|
||||
const result = await service.translate("cat:engine", "ENGINE");
|
||||
|
||||
expect(result.translatedText).toBe("Motor");
|
||||
expect(result.source).toBe("dictionary");
|
||||
});
|
||||
|
||||
it("should translate 'Steering Wheel' (mixed case)", async () => {
|
||||
const result = await service.translate(
|
||||
"cat:steering-wheel",
|
||||
"Steering Wheel",
|
||||
);
|
||||
|
||||
expect(result.translatedText).toBe("Direksiyon Simidi");
|
||||
expect(result.source).toBe("dictionary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("word-by-word replacement for compound phrases", () => {
|
||||
it("should translate compound phrase with known words", async () => {
|
||||
// "engine filter" contains "engine" and "filter" individually
|
||||
const result = await service.translate(
|
||||
"cat:engine-filter",
|
||||
"engine filter",
|
||||
);
|
||||
|
||||
// Should match individual words and replace them
|
||||
expect(result.source).toBe("dictionary");
|
||||
expect(result.translatedText).toContain("Motor");
|
||||
expect(result.translatedText).toContain("Filtre");
|
||||
});
|
||||
|
||||
it("should prefer longer dictionary entries over shorter ones", async () => {
|
||||
// "oil filter" is a specific entry (Yag Filtresi), not "oil" + "filter"
|
||||
const result = await service.translate("cat:oil-filter", "oil filter");
|
||||
|
||||
expect(result.translatedText).toBe("Ya\u011f Filtresi");
|
||||
expect(result.source).toBe("dictionary");
|
||||
});
|
||||
});
|
||||
// Dictionary fallback removed by design \u2014 its word-by-word replacement
|
||||
// produced half-translated strings that would poison the DB. Cache \u2192 DB \u2192
|
||||
// async enqueue is the only path now.
|
||||
|
||||
describe("no match returns original text", () => {
|
||||
it("should return original text when no dictionary match found", async () => {
|
||||
const result = await service.translate(
|
||||
"cat:unknown-part",
|
||||
"xylophone bracket",
|
||||
);
|
||||
const result = await service.translate("cat:unknown-part", "xylophone harpsichord");
|
||||
|
||||
expect(result.translatedText).toBe("xylophone bracket");
|
||||
expect(result.translatedText).toBe("xylophone harpsichord");
|
||||
expect(result.source).toBe("none");
|
||||
expect(result.isAutoTranslated).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,105 +1,249 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { eq, ilike, or } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { Queue } from "bullmq";
|
||||
import { eq, ilike, inArray, or } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { emexCategoryTranslations } from "../database/schema/core";
|
||||
import { TRANSLATION_QUEUE } from "../jobs/queues/translation.queue";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
|
||||
/** 30 days in seconds */
|
||||
const CACHE_TTL = 30 * 24 * 60 * 60;
|
||||
/** 1 day TTL for cache misses (allow re-check sooner) */
|
||||
const CACHE_MISS_TTL = 24 * 60 * 60;
|
||||
const CACHE_PREFIX = "tr:";
|
||||
|
||||
/** Common EN → TR automotive dictionary */
|
||||
/**
|
||||
* EN → TR automotive dictionary. Used as the last fallback before returning
|
||||
* the original text. Keys are lowercased; values use Turkish diacritics.
|
||||
*
|
||||
* Covers the common terms most likely to appear in EMEX category/part names.
|
||||
* Anything unmapped here is sent through the LLM bootstrap script
|
||||
* (scripts/emex-translate-bootstrap.ts) and persisted in
|
||||
* `emex_category_translations`.
|
||||
*/
|
||||
const AUTOMOTIVE_DICTIONARY: Record<string, string> = {
|
||||
"engine": "Motor",
|
||||
"brake": "Fren",
|
||||
"steering": "Direksiyon",
|
||||
"suspension": "Süspansiyon",
|
||||
"exhaust": "Egzoz",
|
||||
"transmission": "Şanzıman",
|
||||
"radiator": "Radyatör",
|
||||
"battery": "Akü",
|
||||
"filter": "Filtre",
|
||||
"clutch": "Debriyaj",
|
||||
"shock absorber": "Amortisör",
|
||||
"alternator": "Alternatör",
|
||||
"starter": "Marş Motoru",
|
||||
// Engine + powertrain
|
||||
engine: "Motor",
|
||||
motor: "Motor",
|
||||
piston: "Piston",
|
||||
cylinder: "Silindir",
|
||||
crankshaft: "Krank Mili",
|
||||
camshaft: "Eksantrik Mili",
|
||||
valve: "Supap",
|
||||
turbocharger: "Turbo",
|
||||
turbo: "Turbo",
|
||||
intercooler: "Intercooler",
|
||||
manifold: "Manifold",
|
||||
"intake manifold": "Emme Manifoldu",
|
||||
"exhaust manifold": "Egzoz Manifoldu",
|
||||
flywheel: "Volan",
|
||||
"spark plug": "Buji",
|
||||
"fuel pump": "Yakıt Pompası",
|
||||
"water pump": "Su Pompası",
|
||||
"oil pump": "Yağ Pompası",
|
||||
"timing belt": "Triger Kayışı",
|
||||
"fan belt": "Vantilatör Kayışı",
|
||||
"gasket": "Conta",
|
||||
"piston": "Piston",
|
||||
"cylinder": "Silindir",
|
||||
"crankshaft": "Krank Mili",
|
||||
"camshaft": "Eksantrik Mili",
|
||||
"valve": "Supap",
|
||||
"turbocharger": "Turbo",
|
||||
"intercooler": "Intercooler",
|
||||
"catalytic converter": "Katalitik Konvertör",
|
||||
"muffler": "Susturucu",
|
||||
"bumper": "Tampon",
|
||||
"fender": "Çamurluk",
|
||||
"hood": "Kaput",
|
||||
"trunk": "Bagaj",
|
||||
"windshield": "Ön Cam",
|
||||
"mirror": "Ayna",
|
||||
"headlight": "Far",
|
||||
"tail light": "Stop Lambası",
|
||||
"wiper": "Silecek",
|
||||
"door": "Kapı",
|
||||
"wheel": "Jant",
|
||||
"tire": "Lastik",
|
||||
"axle": "Aks",
|
||||
"bearing": "Rulman",
|
||||
"caliper": "Kaliper",
|
||||
"ignition coil": "Ateşleme Bobini",
|
||||
injector: "Enjektör",
|
||||
"fuel injector": "Yakıt Enjektörü",
|
||||
distributor: "Distribütör",
|
||||
"voltage regulator": "Voltaj Regülatörü",
|
||||
"throttle body": "Gaz Kelebeği",
|
||||
|
||||
// Transmission
|
||||
transmission: "Şanzıman",
|
||||
gearbox: "Vites Kutusu",
|
||||
clutch: "Debriyaj",
|
||||
"clutch kit": "Debriyaj Seti",
|
||||
"gear lever": "Vites Kolu",
|
||||
differential: "Diferansiyel",
|
||||
"drive shaft": "Şaft",
|
||||
driveshaft: "Şaft",
|
||||
"axle shaft": "Aks Mili",
|
||||
axle: "Aks",
|
||||
"cv joint": "Aks Kafası",
|
||||
"cv boot": "Aks Körüğü",
|
||||
|
||||
// Brake
|
||||
brake: "Fren",
|
||||
brakes: "Fren Sistemi",
|
||||
"brake pad": "Fren Balatası",
|
||||
"brake disc": "Fren Diski",
|
||||
"air filter": "Hava Filtresi",
|
||||
"oil filter": "Yağ Filtresi",
|
||||
"fuel filter": "Yakıt Filtresi",
|
||||
"cabin filter": "Polen Filtresi",
|
||||
"thermostat": "Termostat",
|
||||
"sensor": "Sensör",
|
||||
"relay": "Röle",
|
||||
"fuse": "Sigorta",
|
||||
"compressor": "Kompresör",
|
||||
"condenser": "Kondenser",
|
||||
"evaporator": "Evaporatör",
|
||||
"hose": "Hortum",
|
||||
"belt": "Kayış",
|
||||
"spring": "Yay",
|
||||
"strut": "Amortisör Bacağı",
|
||||
"brake rotor": "Fren Diski",
|
||||
"brake hose": "Fren Hortumu",
|
||||
"brake line": "Fren Borusu",
|
||||
"brake fluid": "Fren Hidroliği",
|
||||
caliper: "Fren Kaliperi",
|
||||
"master cylinder": "Ana Merkez",
|
||||
"slave cylinder": "Yardımcı Merkez",
|
||||
handbrake: "El Freni",
|
||||
|
||||
// Suspension + steering
|
||||
suspension: "Süspansiyon",
|
||||
steering: "Direksiyon",
|
||||
"steering wheel": "Direksiyon Simidi",
|
||||
"steering rack": "Kremayer",
|
||||
"steering gear": "Direksiyon Kutusu",
|
||||
"power steering": "Hidrolik Direksiyon",
|
||||
"power steering pump": "Hidrolik Direksiyon Pompası",
|
||||
"power steering hose": "Hidrolik Direksiyon Hortumu",
|
||||
"shock absorber": "Amortisör",
|
||||
shock: "Amortisör",
|
||||
strut: "Makferson",
|
||||
spring: "Yay",
|
||||
"control arm": "Salıncak",
|
||||
"tie rod": "Rot Başı",
|
||||
"ball joint": "Rotil",
|
||||
"cv joint": "Aks Kafası",
|
||||
"drive shaft": "Şaft",
|
||||
"differential": "Diferansiyel",
|
||||
"gearbox": "Vites Kutusu",
|
||||
"flywheel": "Volan",
|
||||
"injector": "Enjektör",
|
||||
"throttle body": "Gaz Kelebeği",
|
||||
"manifold": "Manifold",
|
||||
|
||||
// Wheels + tires
|
||||
wheel: "Tekerlek",
|
||||
wheels: "Jantlar",
|
||||
"wheel hub": "Poyra",
|
||||
hub: "Poyra",
|
||||
"wheel bearing": "Tekerlek Rulmanı",
|
||||
"hub bearing": "Poyra Rulmanı",
|
||||
bearing: "Rulman",
|
||||
"wheel stud": "Bijon",
|
||||
"lug nut": "Bijon Somunu",
|
||||
tire: "Lastik",
|
||||
tires: "Lastikler",
|
||||
|
||||
// Cooling + fuel + air
|
||||
cooling: "Soğutma Sistemi",
|
||||
radiator: "Radyatör",
|
||||
"radiator hose": "Radyatör Hortumu",
|
||||
thermostat: "Termostat",
|
||||
"water pump": "Su Pompası",
|
||||
"fan belt": "Vantilatör Kayışı",
|
||||
fan: "Fan",
|
||||
coolant: "Antifriz",
|
||||
fuel: "Yakıt",
|
||||
"fuel system": "Yakıt Sistemi",
|
||||
"fuel pump": "Yakıt Pompası",
|
||||
"fuel filter": "Yakıt Filtresi",
|
||||
"fuel tank": "Yakıt Deposu",
|
||||
air: "Hava",
|
||||
"air filter": "Hava Filtresi",
|
||||
"cabin filter": "Polen Filtresi",
|
||||
"pollen filter": "Polen Filtresi",
|
||||
"oil filter": "Yağ Filtresi",
|
||||
"oil pump": "Yağ Pompası",
|
||||
"oil pan": "Karter",
|
||||
"engine oil": "Motor Yağı",
|
||||
oil: "Yağ",
|
||||
filter: "Filtre",
|
||||
filters: "Filtreler",
|
||||
|
||||
// Exhaust
|
||||
exhaust: "Egzoz Sistemi",
|
||||
"catalytic converter": "Katalitik Konvertör",
|
||||
muffler: "Susturucu",
|
||||
|
||||
// Electrical
|
||||
electrical: "Elektrik Sistemi",
|
||||
battery: "Akü",
|
||||
alternator: "Alternatör",
|
||||
starter: "Marş Motoru",
|
||||
sensor: "Sensör",
|
||||
"oxygen sensor": "Oksijen Sensörü",
|
||||
"abs sensor": "ABS Sensörü",
|
||||
"ignition coil": "Ateşleme Bobini",
|
||||
"distributor": "Distribütör",
|
||||
"voltage regulator": "Voltaj Regülatörü",
|
||||
"window regulator": "Cam Krikosu",
|
||||
"crankshaft sensor": "Krank Sensörü",
|
||||
"camshaft sensor": "Eksantrik Sensörü",
|
||||
"coolant sensor": "Su Isısı Sensörü",
|
||||
"oil pressure sensor": "Yağ Basıncı Sensörü",
|
||||
relay: "Röle",
|
||||
fuse: "Sigorta",
|
||||
switch: "Anahtar",
|
||||
horn: "Korna",
|
||||
airbag: "Hava Yastığı",
|
||||
|
||||
// HVAC
|
||||
"air conditioning": "Klima",
|
||||
climate: "Klima",
|
||||
compressor: "Kompresör",
|
||||
condenser: "Kondenser",
|
||||
evaporator: "Evaporatör",
|
||||
heater: "Isıtıcı",
|
||||
blower: "Üfleyici",
|
||||
|
||||
// Belts, seals, gaskets
|
||||
belt: "Kayış",
|
||||
"timing belt": "Triger Kayışı",
|
||||
"timing chain": "Triger Zinciri",
|
||||
"serpentine belt": "V Kayışı",
|
||||
"drive belt": "Tahrik Kayışı",
|
||||
tensioner: "Gerdirici",
|
||||
"belt tensioner": "Kayış Gerdirici",
|
||||
idler: "Avare",
|
||||
"idler pulley": "Avare Kasnak",
|
||||
pulley: "Kasnak",
|
||||
"crankshaft pulley": "Krank Kasnağı",
|
||||
hose: "Hortum",
|
||||
gasket: "Conta",
|
||||
"head gasket": "Silindir Kapağı Contası",
|
||||
"valve cover gasket": "Külbütör Kapağı Contası",
|
||||
"oil pan gasket": "Karter Contası",
|
||||
"intake manifold gasket": "Emme Manifoldu Contası",
|
||||
"exhaust manifold gasket": "Egzoz Manifoldu Contası",
|
||||
seal: "Keçe",
|
||||
"o-ring": "O-Ring",
|
||||
|
||||
// Body + exterior
|
||||
body: "Kaporta",
|
||||
bumper: "Tampon",
|
||||
"front bumper": "Ön Tampon",
|
||||
"rear bumper": "Arka Tampon",
|
||||
fender: "Çamurluk",
|
||||
mudguard: "Çamurluk",
|
||||
"splash guard": "Paçalık",
|
||||
hood: "Kaput",
|
||||
bonnet: "Kaput",
|
||||
trunk: "Bagaj",
|
||||
boot: "Bagaj",
|
||||
grille: "Izgara",
|
||||
exterior: "Dış Aksam",
|
||||
|
||||
// Doors + windows + mirrors
|
||||
door: "Kapı",
|
||||
"door handle": "Kapı Kolu",
|
||||
"door lock": "Kapı Kilidi",
|
||||
"seat": "Koltuk",
|
||||
"dashboard": "Gösterge Paneli",
|
||||
"steering wheel": "Direksiyon Simidi",
|
||||
"gear lever": "Vites Kolu",
|
||||
"handbrake": "El Freni",
|
||||
"pedal": "Pedal",
|
||||
"radiator hose": "Radyatör Hortumu",
|
||||
"coolant": "Antifriz",
|
||||
"brake fluid": "Fren Hidroliği",
|
||||
"engine oil": "Motor Yağı",
|
||||
"power steering": "Hidrolik Direksiyon",
|
||||
"door hinge": "Kapı Menteşesi",
|
||||
window: "Cam",
|
||||
windshield: "Ön Cam",
|
||||
windscreen: "Ön Cam",
|
||||
"window regulator": "Cam Krikosu",
|
||||
"window motor": "Cam Motoru",
|
||||
mirror: "Ayna",
|
||||
"side mirror": "Yan Ayna",
|
||||
"rear view mirror": "İç Ayna",
|
||||
|
||||
// Wipers + lights
|
||||
wiper: "Silecek",
|
||||
"wiper blade": "Silecek Lastiği",
|
||||
"wiper motor": "Silecek Motoru",
|
||||
lighting: "Aydınlatma",
|
||||
lights: "Aydınlatma",
|
||||
headlight: "Far",
|
||||
"headlight bulb": "Far Ampulü",
|
||||
"tail light": "Stop Lambası",
|
||||
taillight: "Stop Lambası",
|
||||
"fog light": "Sis Lambası",
|
||||
"turn signal": "Sinyal Lambası",
|
||||
indicator: "Sinyal Lambası",
|
||||
|
||||
// Interior
|
||||
interior: "İç Aksam",
|
||||
seat: "Koltuk",
|
||||
dashboard: "Gösterge Paneli",
|
||||
pedal: "Pedal",
|
||||
carpet: "Halı",
|
||||
mat: "Paspas",
|
||||
|
||||
// Misc / fasteners
|
||||
pump: "Pompa",
|
||||
screw: "Vida",
|
||||
bolt: "Cıvata",
|
||||
nut: "Somun",
|
||||
washer: "Pul",
|
||||
clip: "Klips",
|
||||
bracket: "Braket",
|
||||
cover: "Kapak",
|
||||
plug: "Tıpa",
|
||||
};
|
||||
|
||||
export interface TranslationResult {
|
||||
@@ -117,8 +261,42 @@ export class TranslationsService {
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private redis: RedisService,
|
||||
@Inject(TRANSLATION_QUEUE) private translationQueue: Queue,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Enqueue terms for async LLM translation. Skips terms already known to
|
||||
* be queued (Redis NX flag, 5min TTL) to avoid 100x duplicate jobs when
|
||||
* many users hit the same fresh term concurrently.
|
||||
*/
|
||||
private async enqueueTranslation(terms: string[]): Promise<void> {
|
||||
if (!terms.length) return;
|
||||
const redis = this.redis.getClient();
|
||||
// Redis SET NX EX dedup: only queue terms whose flag was just set
|
||||
const fresh: string[] = [];
|
||||
const pipeline = redis.pipeline();
|
||||
for (const t of terms) pipeline.set(`tr:queued:${t}`, "1", "EX", 300, "NX");
|
||||
const results = await pipeline.exec();
|
||||
if (results) {
|
||||
results.forEach((r, i) => {
|
||||
if (r && r[1] === "OK") fresh.push(terms[i]);
|
||||
});
|
||||
}
|
||||
if (!fresh.length) return;
|
||||
|
||||
// Chunk into 50-term batches (matches bootstrap LLM throughput sweet spot)
|
||||
for (let i = 0; i < fresh.length; i += 50) {
|
||||
const chunk = fresh.slice(i, i + 50);
|
||||
await this.translationQueue.add(
|
||||
"translate",
|
||||
{ terms: chunk },
|
||||
// jobId hashes the chunk so identical retries collapse
|
||||
{ jobId: `tr:${Buffer.from(chunk.sort().join("|")).toString("base64").slice(0, 32)}` },
|
||||
);
|
||||
}
|
||||
this.logger.debug(`Enqueued ${fresh.length} term(s) for async translation`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a single key: Redis cache → DB → dictionary → original
|
||||
*/
|
||||
@@ -149,32 +327,9 @@ export class TranslationsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. Try dictionary-based translation
|
||||
const dictTranslation = this.translateWithDictionary(sourceText);
|
||||
if (dictTranslation !== null) {
|
||||
const result: TranslationResult = {
|
||||
key,
|
||||
sourceText,
|
||||
translatedText: dictTranslation,
|
||||
source: "dictionary",
|
||||
isAutoTranslated: true,
|
||||
};
|
||||
|
||||
// Persist to DB for future lookups
|
||||
await this.db
|
||||
.insert(emexCategoryTranslations)
|
||||
.values({
|
||||
originalName: sourceText,
|
||||
translatedName: dictTranslation,
|
||||
isManual: false,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
await this.redis.setJson(cacheKey, result, CACHE_TTL);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 4. No translation found — return original with flag
|
||||
// 3. No DB hit — return original now, async-translate in background.
|
||||
// Dictionary fallback was removed because its word-by-word replacement
|
||||
// produced half-translated strings and would poison the DB.
|
||||
const result: TranslationResult = {
|
||||
key,
|
||||
sourceText,
|
||||
@@ -182,21 +337,129 @@ export class TranslationsService {
|
||||
source: "none",
|
||||
isAutoTranslated: false,
|
||||
};
|
||||
// Cache "miss" with shorter TTL (1 day) so it gets re-checked sooner
|
||||
await this.redis.setJson(cacheKey, result, 24 * 60 * 60);
|
||||
await this.redis.setJson(cacheKey, result, CACHE_MISS_TTL);
|
||||
this.enqueueTranslation([sourceText]).catch((err) =>
|
||||
this.logger.warn(`Translation enqueue failed: ${(err as Error).message}`),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch translate multiple items
|
||||
* Bulk translate many strings in one shot — used by EMEX category/part
|
||||
* insertion paths (categories.service.ts) where a single fetch can yield
|
||||
* dozens to hundreds of names.
|
||||
*
|
||||
* Pipeline: Redis MGET → DB IN(...) → dictionary fallback → bulk INSERT
|
||||
* → Redis pipeline SET. Returns Map<originalText, translatedText>; entries
|
||||
* for which no translation is available map to the original text.
|
||||
*/
|
||||
async translateBatch(
|
||||
items: { key: string; sourceText: string }[],
|
||||
): Promise<TranslationResult[]> {
|
||||
const results = await Promise.all(
|
||||
items.map((item) => this.translate(item.key, item.sourceText)),
|
||||
);
|
||||
return results;
|
||||
async translateMany(sourceTexts: string[]): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
if (!sourceTexts.length) return result;
|
||||
|
||||
// Dedupe and drop empties — caller often has duplicates across rows
|
||||
const unique = [...new Set(sourceTexts.filter((s) => s && s.trim().length > 0))];
|
||||
if (!unique.length) return result;
|
||||
|
||||
// Phase 1: Redis MGET
|
||||
const redis = this.redis.getClient();
|
||||
const cacheKeys = unique.map((s) => `${CACHE_PREFIX}${s}`);
|
||||
const cachedRaw = await redis.mget(...cacheKeys);
|
||||
|
||||
const missing: string[] = [];
|
||||
cachedRaw.forEach((raw, idx) => {
|
||||
const text = unique[idx];
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as TranslationResult;
|
||||
result.set(text, parsed.translatedText);
|
||||
} catch {
|
||||
missing.push(text);
|
||||
}
|
||||
} else {
|
||||
missing.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
if (!missing.length) return result;
|
||||
|
||||
// Phase 2: DB IN(...) for cache misses
|
||||
const dbRows = await this.db
|
||||
.select({
|
||||
originalName: emexCategoryTranslations.originalName,
|
||||
translatedName: emexCategoryTranslations.translatedName,
|
||||
isManual: emexCategoryTranslations.isManual,
|
||||
})
|
||||
.from(emexCategoryTranslations)
|
||||
.where(inArray(emexCategoryTranslations.originalName, missing));
|
||||
|
||||
const dbMap = new Map<string, { translated: string; isManual: boolean }>();
|
||||
for (const row of dbRows) {
|
||||
dbMap.set(row.originalName, { translated: row.translatedName, isManual: row.isManual });
|
||||
}
|
||||
|
||||
// Phase 3: Build result map + populate Redis pipeline. DB miss falls
|
||||
// through to a short-TTL cache placeholder + async BullMQ job — the
|
||||
// worker translates via LLM and updates DB so the next refresh shows
|
||||
// Turkish without blocking this request.
|
||||
const pipeline = redis.pipeline();
|
||||
const toEnqueue: string[] = [];
|
||||
|
||||
for (const text of missing) {
|
||||
const dbHit = dbMap.get(text);
|
||||
if (dbHit) {
|
||||
result.set(text, dbHit.translated);
|
||||
const payload: TranslationResult = {
|
||||
key: text,
|
||||
sourceText: text,
|
||||
translatedText: dbHit.translated,
|
||||
source: "db",
|
||||
isAutoTranslated: !dbHit.isManual,
|
||||
};
|
||||
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_TTL);
|
||||
} else {
|
||||
result.set(text, text);
|
||||
const payload: TranslationResult = {
|
||||
key: text,
|
||||
sourceText: text,
|
||||
translatedText: text,
|
||||
source: "none",
|
||||
isAutoTranslated: false,
|
||||
};
|
||||
pipeline.set(`${CACHE_PREFIX}${text}`, JSON.stringify(payload), "EX", CACHE_MISS_TTL);
|
||||
toEnqueue.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
|
||||
// Fire-and-forget — failures here must not break the hot path
|
||||
if (toEnqueue.length) {
|
||||
this.enqueueTranslation(toEnqueue).catch((err) =>
|
||||
this.logger.warn(`Translation enqueue failed: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch translate — kept for backwards compatibility with the controller's
|
||||
* /translations/batch endpoint. Internally uses translateMany.
|
||||
*/
|
||||
async translateBatch(items: { key: string; sourceText: string }[]): Promise<TranslationResult[]> {
|
||||
if (!items.length) return [];
|
||||
const trMap = await this.translateMany(items.map((i) => i.sourceText));
|
||||
return items.map((i) => {
|
||||
const translated = trMap.get(i.sourceText) ?? i.sourceText;
|
||||
const same = translated === i.sourceText;
|
||||
return {
|
||||
key: i.key,
|
||||
sourceText: i.sourceText,
|
||||
translatedText: translated,
|
||||
source: same ? "none" : "db",
|
||||
isAutoTranslated: !same,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,9 +488,11 @@ export class TranslationsService {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Invalidate cache
|
||||
const cacheKey = `${CACHE_PREFIX}${key}`;
|
||||
await this.redis.del(cacheKey);
|
||||
// Invalidate cache (key + sourceText so both lookup paths see the change)
|
||||
await this.redis.del(`${CACHE_PREFIX}${key}`);
|
||||
if (key !== sourceText) {
|
||||
await this.redis.del(`${CACHE_PREFIX}${sourceText}`);
|
||||
}
|
||||
|
||||
this.logger.log(`Translation set: "${sourceText}" → "${translatedText}"`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user