feat(translations): async LLM translation pipeline for EMEX/PCAT terms
Add BullMQ-backed cold-path translation so first-time category and part names from EMEX/PCAT scrapers no longer stay English. translateMany now returns the original on DB miss but enqueues a translation job; the worker calls DeepSeek V3 via OpenRouter, persists to emex_category_translations, and updates already-stored rows where name = name_original. Redis NX flag dedupes concurrent enqueues so the same term is not translated 100x when many users hit it at once. Removes the dictionary fallback from TranslationsService — its word-by-word replacement produced half-translated strings (e.g. "Body frame" → "Kaporta frame") that polluted the DB. Bootstrap and backfill scripts cover EMEX and PCAT sources together. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,4 +25,5 @@ export const QUEUE_NAMES = {
|
||||
SUBSCRIPTION_EXPIRY: "subscription-expiry",
|
||||
QUERY_CLEANUP: "query-cleanup",
|
||||
CATALOG_PREFETCH: "catalog-prefetch",
|
||||
TRANSLATION: "translation",
|
||||
} as const;
|
||||
|
||||
142
apps/api/src/jobs/processors/translation.processor.ts
Normal file
142
apps/api/src/jobs/processors/translation.processor.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Job } from "bullmq";
|
||||
import { sql as drizzleSql, inArray } from "drizzle-orm";
|
||||
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import OpenAI from "openai";
|
||||
import Redis from "ioredis";
|
||||
import { emexCategoryTranslations } from "../../database/schema/core";
|
||||
|
||||
// Schema-loose local alias (matches the shape used by worker.ts which builds
|
||||
// drizzle without schema generics). Other processors do the same.
|
||||
type Database = PostgresJsDatabase<Record<string, unknown>>;
|
||||
|
||||
export interface TranslationJobData {
|
||||
/** Raw original names (from EMEX/PCAT scraper output) to translate. */
|
||||
terms: string[];
|
||||
}
|
||||
|
||||
const MODEL = "deepseek/deepseek-chat";
|
||||
const SYSTEM_PROMPT = `Sen bir Türk otomotiv çevirmenisin. Görevin: araç yedek parça kataloğundan gelen kategori ve parça isimlerini İngilizce'den (zaman zaman Rusça'dan) Türkçe'ye çevirmek.
|
||||
|
||||
Kurallar:
|
||||
1. Türkiye yedek parça sektöründe kullanılan terminolojiyi kullan ("Brake Pad" → "Fren Balatası", "Spark Plug" → "Buji").
|
||||
2. Türkçe karakterleri (ş, ı, ğ, ü, ö, ç) doğru kullan.
|
||||
3. Marka isimleri (BMW, VW, Toyota), model kodları, OEM parça kodları ve teknik kısaltmalar (ABS, ESP, OBD, ECU, R.H., L.H.) çevirmeden olduğu gibi kalır.
|
||||
4. Belirsiz terim için en yakın TR karşılığını yaz; çok belirsizse orijinali koru.
|
||||
5. Kısa, UI'da gösterilebilir (1-5 kelime ideal).
|
||||
6. "Boot" otomotiv bağlamında "Bagaj" demektir.
|
||||
7. Çıktı: girdi listesinin AYNI sırasında, eşit uzunlukta JSON dizisi.
|
||||
|
||||
Yanıt KESİN olarak şu JSON formatında olmalı:
|
||||
{"translations": ["çeviri1", "çeviri2", ...]}`;
|
||||
|
||||
async function callLLM(ai: OpenAI, terms: string[]): Promise<string[]> {
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const response = await ai.chat.completions.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: JSON.stringify({ terms }) },
|
||||
],
|
||||
});
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) throw new Error("Empty response");
|
||||
const match = content.match(/\{[\s\S]*?"translations"[\s\S]*?\}/);
|
||||
if (!match) throw new Error("No JSON in response");
|
||||
const parsed = JSON.parse(match[0]) as { translations: string[] };
|
||||
if (!Array.isArray(parsed.translations) || parsed.translations.length !== terms.length) {
|
||||
throw new Error(`Length mismatch: ${parsed.translations?.length} vs ${terms.length}`);
|
||||
}
|
||||
return parsed.translations;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const status =
|
||||
(err as { status?: number })?.status ??
|
||||
(err as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 429 || status === 529 || (status && status >= 500)) {
|
||||
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a translation job: translate `terms` via LLM, persist to
|
||||
* emex_category_translations, then UPDATE categories/parts rows where
|
||||
* `name = name_original` (still raw English) so already-fetched data
|
||||
* shows up Turkish on the next refresh. Cache invalidation flushes
|
||||
* Redis tree caches.
|
||||
*/
|
||||
export async function processTranslation(
|
||||
job: Job<TranslationJobData>,
|
||||
db: Database,
|
||||
ai: OpenAI,
|
||||
redis: Redis,
|
||||
): Promise<{ translated: number; skipped: number }> {
|
||||
const { terms } = job.data;
|
||||
if (!terms?.length) return { translated: 0, skipped: 0 };
|
||||
|
||||
// Skip terms already in DB (another worker may have raced ahead)
|
||||
const existing = await db
|
||||
.select({ originalName: emexCategoryTranslations.originalName })
|
||||
.from(emexCategoryTranslations)
|
||||
.where(inArray(emexCategoryTranslations.originalName, terms));
|
||||
const existingSet = new Set(existing.map((r) => r.originalName));
|
||||
const todo = terms.filter((t) => !existingSet.has(t));
|
||||
if (!todo.length) return { translated: 0, skipped: terms.length };
|
||||
|
||||
// LLM call (single batch)
|
||||
const translations = await callLLM(ai, todo);
|
||||
|
||||
// Persist translations
|
||||
await db
|
||||
.insert(emexCategoryTranslations)
|
||||
.values(
|
||||
todo.map((orig, i) => ({
|
||||
originalName: orig,
|
||||
translatedName: translations[i] || orig,
|
||||
isManual: false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Update existing categories/parts rows where name was left as English.
|
||||
// Run as a single statement per term — small N (≤50), and we keep it
|
||||
// scoped to the two scraper sources.
|
||||
for (let i = 0; i < todo.length; i++) {
|
||||
const orig = todo[i];
|
||||
const tr = translations[i];
|
||||
if (!tr || tr === orig) continue;
|
||||
await db.execute(drizzleSql`
|
||||
UPDATE categories
|
||||
SET name = ${tr}
|
||||
WHERE source IN ('emex', 'parts-catalogs')
|
||||
AND name_original = ${orig}
|
||||
AND name = name_original
|
||||
`);
|
||||
await db.execute(drizzleSql`
|
||||
UPDATE parts
|
||||
SET name = ${tr}
|
||||
WHERE source IN ('emex', 'parts-catalogs')
|
||||
AND name_original = ${orig}
|
||||
AND name = name_original
|
||||
`);
|
||||
}
|
||||
|
||||
// Invalidate translation lookup cache so next read sees DB value.
|
||||
// Tree cache (cat:tree:*) is intentionally NOT flushed wholesale here
|
||||
// because that would punish unrelated vehicles; a stale tree just
|
||||
// expires within 1h and the underlying name is already updated in DB.
|
||||
const trKeys = todo.map((t) => `tr:${t}`);
|
||||
if (trKeys.length) {
|
||||
await redis.del(...trKeys).catch(() => undefined);
|
||||
}
|
||||
|
||||
return { translated: todo.length, skipped: terms.length - todo.length };
|
||||
}
|
||||
22
apps/api/src/jobs/queues/translation.queue.ts
Normal file
22
apps/api/src/jobs/queues/translation.queue.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import { Queue } from "bullmq";
|
||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
|
||||
|
||||
export const TRANSLATION_QUEUE = "TRANSLATION_QUEUE";
|
||||
|
||||
export const TranslationQueueProvider: Provider = {
|
||||
provide: TRANSLATION_QUEUE,
|
||||
useFactory: () => {
|
||||
const telemetry = getBullTelemetry();
|
||||
return new Queue(QUEUE_NAMES.TRANSLATION, {
|
||||
connection: getBullConnection(),
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 5000 },
|
||||
removeOnComplete: { count: 500 },
|
||||
removeOnFail: { count: 1000 },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 {}
|
||||
|
||||
@@ -31,7 +31,8 @@ function createMockDeps() {
|
||||
}
|
||||
|
||||
function createService(db: unknown, redis: unknown): TranslationsService {
|
||||
return new TranslationsService(db as any, redis as any);
|
||||
const queue = { add: vi.fn().mockResolvedValue(undefined) };
|
||||
return new TranslationsService(db as any, redis as any, queue as any);
|
||||
}
|
||||
|
||||
describe("TranslationsService", () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
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 */
|
||||
@@ -259,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
|
||||
*/
|
||||
@@ -291,9 +327,9 @@ export class TranslationsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. No DB hit — return original. Dictionary fallback removed: its
|
||||
// word-by-word replacement produces half-translated strings and
|
||||
// would poison the DB. Bootstrap script handles new terms via LLM.
|
||||
// 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,
|
||||
@@ -302,6 +338,9 @@ export class TranslationsService {
|
||||
isAutoTranslated: false,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -359,14 +398,12 @@ export class TranslationsService {
|
||||
dbMap.set(row.originalName, { translated: row.translatedName, isManual: row.isManual });
|
||||
}
|
||||
|
||||
// Phase 3: Build result map + populate Redis pipeline.
|
||||
// We deliberately do NOT use translateWithDictionary's word-by-word
|
||||
// replacement here — it produces half-translated strings (e.g.
|
||||
// "Body frame" → "Kaporta frame") and would persist them to the DB,
|
||||
// poisoning future lookups. Anything missing falls through to a
|
||||
// short-TTL cache miss; the next emex-translate-bootstrap.ts run
|
||||
// picks it up and writes the proper LLM translation.
|
||||
// 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);
|
||||
@@ -390,10 +427,18 @@ export class TranslationsService {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ import "dotenv/config";
|
||||
import { Worker } from "bullmq";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import Redis from "ioredis";
|
||||
import OpenAI from "openai";
|
||||
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "./jobs/bull.config";
|
||||
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
|
||||
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
|
||||
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
|
||||
import { processTranslation } from "./jobs/processors/translation.processor";
|
||||
import { isOtelEnabled } from "./telemetry";
|
||||
import { sdk } from "./telemetry/worker-tracing";
|
||||
|
||||
@@ -119,6 +122,52 @@ queryCleanupWorker.on("failed", (job, err) => {
|
||||
|
||||
workers.push(queryCleanupWorker);
|
||||
|
||||
// Translation Worker (async LLM translation for new EMEX/PCAT terms)
|
||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (openrouterApiKey) {
|
||||
const ai = new OpenAI({
|
||||
apiKey: openrouterApiKey,
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://sase.tr",
|
||||
"X-Title": "Sase Translation Worker",
|
||||
},
|
||||
});
|
||||
const translationRedis = new Redis({
|
||||
host: process.env.REDIS_HOST || "localhost",
|
||||
port: Number(process.env.REDIS_PORT) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
const translationWorker = new Worker(
|
||||
QUEUE_NAMES.TRANSLATION,
|
||||
async (job) => {
|
||||
return processTranslation(job, db, ai, translationRedis);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 3,
|
||||
...(telemetry ? { telemetry } : {}),
|
||||
// Light rate limit — DeepSeek via OpenRouter handles ~10 req/s comfortably
|
||||
limiter: { max: 30, duration: 60_000 },
|
||||
},
|
||||
);
|
||||
|
||||
translationWorker.on("completed", (job) => {
|
||||
console.log(`[worker] translation job ${job.id} completed`);
|
||||
});
|
||||
translationWorker.on("failed", (job, err) => {
|
||||
console.error(`[worker] translation job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
workers.push(translationWorker);
|
||||
} else {
|
||||
console.warn(
|
||||
"[worker] OPENROUTER_API_KEY not set — translation worker disabled (terms will stay English until next manual bootstrap)",
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Startup ──────────────────────────────────────────
|
||||
console.log("[worker] BullMQ workers started");
|
||||
console.log(`[worker] Listening on queues: ${Object.values(QUEUE_NAMES).join(", ")}`);
|
||||
|
||||
194
scripts/emex-backfill-tr-names.ts
Normal file
194
scripts/emex-backfill-tr-names.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* EMEX Türkçe Backfill
|
||||
*
|
||||
* `emex_category_translations` tablosu doldurulduktan SONRA çalıştır.
|
||||
* `categories` ve `parts` tablolarındaki source='emex' kayıtlarının
|
||||
* `name` kolonunu çevirilmiş hali ile günceller. `name_original` korunur
|
||||
* (rollback edilebilir).
|
||||
*
|
||||
* İlk çalıştırmada UPDATE'leri hızlandıran partial index'leri oluşturur.
|
||||
* Sonunda EMEX kategori tree'lerinin Redis cache'ini flush eder.
|
||||
*
|
||||
* Kullanım:
|
||||
* pnpm tsx scripts/emex-backfill-tr-names.ts --dry-run
|
||||
* pnpm tsx scripts/emex-backfill-tr-names.ts --target=categories
|
||||
* pnpm tsx scripts/emex-backfill-tr-names.ts --target=parts
|
||||
* pnpm tsx scripts/emex-backfill-tr-names.ts # all
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import * as dotenv from "dotenv";
|
||||
import postgres from "postgres";
|
||||
import Redis from "ioredis";
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, "../apps/api/.env") });
|
||||
|
||||
// ---------- CLI ----------
|
||||
const argv = process.argv.slice(2);
|
||||
const dryRun = argv.includes("--dry-run");
|
||||
const targetArg = argv.find((a) => a.startsWith("--target="));
|
||||
const target = targetArg ? targetArg.split("=")[1] : "all";
|
||||
const chunkArg = argv.find((a) => a.startsWith("--chunk-size="));
|
||||
const chunkSize = chunkArg ? parseInt(chunkArg.split("=")[1], 10) : 50_000;
|
||||
|
||||
if (!["all", "categories", "parts"].includes(target)) {
|
||||
console.error(`Invalid --target: ${target}. Use one of: all, categories, parts`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL env var is required");
|
||||
}
|
||||
const sql = postgres(process.env.DATABASE_URL, { max: 1 });
|
||||
|
||||
// Step 1: ensure partial indexes exist (idempotent)
|
||||
if (!dryRun) {
|
||||
console.log("Ensuring partial indexes...");
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS categories_name_original_emex_idx
|
||||
ON categories(name_original) WHERE source = 'emex'
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS parts_name_original_emex_idx
|
||||
ON parts(name_original) WHERE source = 'emex'
|
||||
`;
|
||||
}
|
||||
|
||||
// Step 2: report scope
|
||||
console.log("\nScope:");
|
||||
|
||||
const [{ count: trCount }] = (await sql`
|
||||
SELECT COUNT(*)::int AS count FROM emex_category_translations
|
||||
`) as unknown as { count: number }[];
|
||||
console.log(` emex_category_translations rows: ${trCount}`);
|
||||
if (trCount === 0) {
|
||||
console.warn("\nWARNING: emex_category_translations is empty.");
|
||||
console.warn("Run scripts/emex-translate-bootstrap.ts first.");
|
||||
}
|
||||
|
||||
if (target === "all" || target === "categories") {
|
||||
const [{ count: catUpd }] = (await sql`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM categories c
|
||||
JOIN emex_category_translations t ON c.name_original = t.original_name
|
||||
WHERE c.source IN ('emex', 'parts-catalogs')
|
||||
AND c.name_original IS NOT NULL
|
||||
AND c.name <> t.translated_name
|
||||
`) as unknown as { count: number }[];
|
||||
console.log(` categories to update: ${catUpd}`);
|
||||
}
|
||||
|
||||
if (target === "all" || target === "parts") {
|
||||
const [{ count: partUpd }] = (await sql`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM parts p
|
||||
JOIN emex_category_translations t ON p.name_original = t.original_name
|
||||
WHERE p.source IN ('emex', 'parts-catalogs')
|
||||
AND p.name_original IS NOT NULL
|
||||
AND p.name <> t.translated_name
|
||||
`) as unknown as { count: number }[];
|
||||
console.log(` parts to update: ${partUpd}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\nDRY RUN — no UPDATE / Redis flush executed.");
|
||||
await sql.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: chunked UPDATE
|
||||
if (target === "all" || target === "categories") {
|
||||
console.log("\nUpdating categories...");
|
||||
let totalUpdated = 0;
|
||||
while (true) {
|
||||
const result = await sql`
|
||||
WITH targets AS (
|
||||
SELECT c.id, t.translated_name
|
||||
FROM categories c
|
||||
JOIN emex_category_translations t ON c.name_original = t.original_name
|
||||
WHERE c.source IN ('emex', 'parts-catalogs')
|
||||
AND c.name_original IS NOT NULL
|
||||
AND c.name <> t.translated_name
|
||||
LIMIT ${chunkSize}
|
||||
)
|
||||
UPDATE categories
|
||||
SET name = targets.translated_name
|
||||
FROM targets
|
||||
WHERE categories.id = targets.id
|
||||
RETURNING categories.id
|
||||
`;
|
||||
const n = result.count ?? result.length;
|
||||
totalUpdated += n;
|
||||
console.log(` +${n} (total: ${totalUpdated})`);
|
||||
if (n === 0) break;
|
||||
}
|
||||
console.log(`Categories updated: ${totalUpdated}`);
|
||||
}
|
||||
|
||||
if (target === "all" || target === "parts") {
|
||||
console.log("\nUpdating parts...");
|
||||
let totalUpdated = 0;
|
||||
while (true) {
|
||||
const result = await sql`
|
||||
WITH targets AS (
|
||||
SELECT p.id, t.translated_name
|
||||
FROM parts p
|
||||
JOIN emex_category_translations t ON p.name_original = t.original_name
|
||||
WHERE p.source IN ('emex', 'parts-catalogs')
|
||||
AND p.name_original IS NOT NULL
|
||||
AND p.name <> t.translated_name
|
||||
LIMIT ${chunkSize}
|
||||
)
|
||||
UPDATE parts
|
||||
SET name = targets.translated_name
|
||||
FROM targets
|
||||
WHERE parts.id = targets.id
|
||||
RETURNING parts.id
|
||||
`;
|
||||
const n = result.count ?? result.length;
|
||||
totalUpdated += n;
|
||||
console.log(` +${n} (total: ${totalUpdated})`);
|
||||
if (n === 0) break;
|
||||
}
|
||||
console.log(`Parts updated: ${totalUpdated}`);
|
||||
}
|
||||
|
||||
// Step 4: Redis cache flush — both per-vehicle category trees and translation entries
|
||||
console.log("\nFlushing Redis caches...");
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || "127.0.0.1",
|
||||
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
lazyConnect: true,
|
||||
});
|
||||
try {
|
||||
await redis.connect();
|
||||
const patterns = ["cat:tree:*", "tr:*"];
|
||||
for (const pattern of patterns) {
|
||||
let cursor = "0";
|
||||
let totalDeleted = 0;
|
||||
do {
|
||||
const [next, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 500);
|
||||
cursor = next;
|
||||
if (keys.length) {
|
||||
await redis.del(...keys);
|
||||
totalDeleted += keys.length;
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
console.log(` ${pattern}: ${totalDeleted} keys deleted`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Redis flush failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
|
||||
await sql.end();
|
||||
console.log("\nDone.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
371
scripts/emex-translate-bootstrap.ts
Normal file
371
scripts/emex-translate-bootstrap.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* EMEX Türkçe Çeviri Bootstrap
|
||||
*
|
||||
* DB'deki tüm unique EMEX kategori (categories.name_original) ve parça
|
||||
* (parts.name_original) adlarını OpenRouter üzerinden DeepSeek V3 ile
|
||||
* Türkçe'ye çevirir ve `emex_category_translations` tablosuna yazar.
|
||||
*
|
||||
* Kullanım:
|
||||
* pnpm tsx scripts/emex-translate-bootstrap.ts --dry-run # kapsam + maliyet
|
||||
* pnpm tsx scripts/emex-translate-bootstrap.ts --limit=100 # küçük örnek
|
||||
* pnpm tsx scripts/emex-translate-bootstrap.ts # tam çalıştırma
|
||||
* pnpm tsx scripts/emex-translate-bootstrap.ts --resume # checkpoint'ten devam
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as dotenv from "dotenv";
|
||||
import OpenAI from "openai";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, "../apps/api/.env") });
|
||||
|
||||
import { emexCategoryTranslations } from "../apps/api/src/database/schema/core";
|
||||
|
||||
// ---------- CLI ----------
|
||||
const argv = process.argv.slice(2);
|
||||
const dryRun = argv.includes("--dry-run");
|
||||
const resume = argv.includes("--resume");
|
||||
|
||||
const limitArg = argv.find((a) => a.startsWith("--limit="));
|
||||
const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : null;
|
||||
|
||||
const batchSizeArg = argv.find((a) => a.startsWith("--batch-size="));
|
||||
const batchSize = batchSizeArg ? parseInt(batchSizeArg.split("=")[1], 10) : 50;
|
||||
|
||||
const concurrencyArg = argv.find((a) => a.startsWith("--concurrency="));
|
||||
const concurrency = concurrencyArg ? parseInt(concurrencyArg.split("=")[1], 10) : 5;
|
||||
|
||||
const CHECKPOINT_PATH = path.join(__dirname, ".emex-translate-checkpoint.json");
|
||||
const MODEL = "deepseek/deepseek-chat"; // OpenRouter slug for DeepSeek V3 (latest)
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const MARKER = "Belirtilmemiş";
|
||||
|
||||
// ---------- Prompt ----------
|
||||
// DeepSeek V3 has automatic server-side prompt caching when the prefix is stable
|
||||
// (https://api-docs.deepseek.com/guides/kv_cache). Keeping this exact system
|
||||
// prompt across batches yields native cache hits without explicit cache_control.
|
||||
const SYSTEM_PROMPT = `Sen bir Türk otomotiv çevirmenisin. Görevin: EMEX otomobil yedek parça kataloğundan gelen kategori ve parça isimlerini İngilizce'den (zaman zaman Rusça'dan) Türkçe'ye çevirmek.
|
||||
|
||||
Kurallar:
|
||||
1. Türkiye yedek parça sektöründe kullanılan terminolojiyi kullan (örn. "Brake Pad" → "Fren Balatası", "Spark Plug" → "Buji").
|
||||
2. Türkçe karakterleri (ş, ı, ğ, ü, ö, ç) doğru kullan.
|
||||
3. Marka isimleri (BMW, VW, Toyota), model kodları, OEM parça kodları ve teknik kısaltmalar (ABS, ESP, OBD, ECU) çevirmeden olduğu gibi kalır.
|
||||
4. Belirsiz veya doğrudan karşılığı olmayan terim için en yakın TR karşılığını yaz; çok belirsizse orijinali koru.
|
||||
5. Kısa ve UI'da gösterilebilir olmalı (1-5 kelime ideal).
|
||||
6. Rusça girişler de TR'ye çevrilir.
|
||||
7. "Boot" otomotiv bağlamında "Bagaj" demektir, "Çizme" değil.
|
||||
8. Çıktı: girdi listesinin **aynı sırasında**, eşit uzunlukta JSON dizisi.
|
||||
|
||||
Örnekler (otomotiv bağlamı):
|
||||
- "Engine Oil Filter" → "Motor Yağ Filtresi"
|
||||
- "Front Brake Pad Set" → "Ön Fren Balata Seti"
|
||||
- "Cooling System" → "Soğutma Sistemi"
|
||||
- "Cylinder Head Gasket" → "Silindir Kapağı Contası"
|
||||
- "Suspension" → "Süspansiyon"
|
||||
- "Combination Rearlight/-Parts" → "Stop Lambası / Parçaları"
|
||||
- "Air Filter, passenger compartment" → "Polen Filtresi"
|
||||
- "Bumper/ Parts" → "Tampon / Parçaları"
|
||||
- "Fuel Tank / Parts" → "Yakıt Deposu / Parçaları"
|
||||
- "Gaskets / Seals" → "Contalar / Keçeler"
|
||||
- "Radiator /Parts" → "Radyatör / Parçaları"
|
||||
- "Valves/ Parts" → "Supaplar / Parçaları"
|
||||
- "Indicator/ Parts" → "Sinyal Lambası / Parçaları"
|
||||
- "Headlight/ Insert" → "Far / İç Parçalar"
|
||||
- "Alternator" → "Alternatör"
|
||||
- "Battery" → "Akü"
|
||||
- "Boot" → "Bagaj"
|
||||
- "Hood" → "Kaput"
|
||||
- "Bonnet" → "Kaput"
|
||||
- "SCREW" → "Vida"
|
||||
- "BOLT" → "Cıvata"
|
||||
- "NUT" → "Somun"
|
||||
- "CLIP" → "Klips"
|
||||
- "Cover" → "Kapak"
|
||||
- "Bracket" → "Braket"
|
||||
- "Spring" → "Yay"
|
||||
- "Six point socket screw" → "Altıgen İçten Vidalı"
|
||||
- "Plane washer" → "Düz Pul"
|
||||
- "Lock washer" → "Yaylı Pul"
|
||||
- "Flange screw" → "Flanşlı Vida"
|
||||
- "БОЛТ" → "Cıvata"
|
||||
- "ВТУЛКА" → "Burç"
|
||||
- "КОЛЛЕКТОР ВПУСКНОЙ" → "Emme Manifoldu"
|
||||
- "ГОЛОВКА БЛОКА ЦИЛИНДРОВ" → "Silindir Kapağı"
|
||||
- "Наименование не указано" → "Belirtilmemiş"
|
||||
|
||||
Yanıt formatı KESİN olarak şu JSON şeklinde olmalı, başka hiçbir metin ekleme:
|
||||
{"translations": ["çeviri1", "çeviri2", ...]}`;
|
||||
|
||||
// ---------- Checkpoint ----------
|
||||
interface Checkpoint {
|
||||
completed: Record<string, string>;
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
function loadCheckpoint(): Checkpoint {
|
||||
if (!fs.existsSync(CHECKPOINT_PATH)) return { completed: {}, failed: [] };
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CHECKPOINT_PATH, "utf-8"));
|
||||
} catch {
|
||||
return { completed: {}, failed: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function saveCheckpoint(cp: Checkpoint) {
|
||||
fs.writeFileSync(CHECKPOINT_PATH, JSON.stringify(cp, null, 2));
|
||||
}
|
||||
|
||||
// ---------- Filtering ----------
|
||||
function classify(text: string): "skip" | "marker" | "ok" {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return "skip";
|
||||
if (trimmed === "Наименование не указано") return "marker";
|
||||
// Tek karakter veya sadece sayılar (1, 12, 100)
|
||||
if (/^\d+$/.test(trimmed)) return "marker";
|
||||
// OEM kod gibi (5 karaktere kadar A-Z0-9 + en az 1 rakam)
|
||||
if (/^[A-Z0-9-]{1,5}$/i.test(trimmed) && /\d/.test(trimmed)) return "marker";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// ---------- LLM batch ----------
|
||||
async function translateBatch(
|
||||
ai: OpenAI,
|
||||
terms: string[],
|
||||
): Promise<string[]> {
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
const response = await ai.chat.completions.create({
|
||||
model: MODEL,
|
||||
max_tokens: 4096,
|
||||
// DeepSeek V3 supports JSON mode; this guarantees parseable output.
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: JSON.stringify({ terms }) },
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) throw new Error("Empty response");
|
||||
|
||||
const text = content.trim();
|
||||
const match = text.match(/\{[\s\S]*?"translations"[\s\S]*?\}/);
|
||||
if (!match) throw new Error(`No JSON in response: ${text.slice(0, 200)}`);
|
||||
const parsed = JSON.parse(match[0]) as { translations: string[] };
|
||||
if (!Array.isArray(parsed.translations)) {
|
||||
throw new Error("translations is not an array");
|
||||
}
|
||||
if (parsed.translations.length !== terms.length) {
|
||||
throw new Error(
|
||||
`Length mismatch: expected ${terms.length}, got ${parsed.translations.length}`,
|
||||
);
|
||||
}
|
||||
return parsed.translations;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const status =
|
||||
(err as { status?: number })?.status ??
|
||||
(err as { response?: { status?: number } })?.response?.status;
|
||||
const retriable =
|
||||
status === 429 ||
|
||||
status === 529 ||
|
||||
(typeof status === "number" && status >= 500 && status < 600);
|
||||
if (retriable && attempt < 4) {
|
||||
const backoff = Math.min(2 ** attempt * 1000 + Math.random() * 500, 30_000);
|
||||
console.warn(
|
||||
`Retry attempt ${attempt + 1} after ${Math.round(backoff)}ms (status=${status})`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, backoff));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL env var is required");
|
||||
}
|
||||
if (!dryRun && !process.env.OPENROUTER_API_KEY) {
|
||||
throw new Error("OPENROUTER_API_KEY env var is required (set in apps/api/.env)");
|
||||
}
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL);
|
||||
const db = drizzle(sql);
|
||||
const ai = !dryRun
|
||||
? new OpenAI({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
baseURL: OPENROUTER_BASE_URL,
|
||||
defaultHeaders: {
|
||||
// OpenRouter recommends these for ranking/abuse detection
|
||||
"HTTP-Referer": "https://sase.tr",
|
||||
"X-Title": "Sase EMEX Translation Bootstrap",
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
console.log("Fetching unique EMEX category and part names from DB...");
|
||||
const catNames = await sql<{ name_original: string }[]>`
|
||||
SELECT DISTINCT name_original FROM categories
|
||||
WHERE source IN ('emex', 'parts-catalogs')
|
||||
AND name_original IS NOT NULL
|
||||
AND length(trim(name_original)) > 0
|
||||
`;
|
||||
const partNames = await sql<{ name_original: string }[]>`
|
||||
SELECT DISTINCT name_original FROM parts
|
||||
WHERE source IN ('emex', 'parts-catalogs')
|
||||
AND name_original IS NOT NULL
|
||||
AND length(trim(name_original)) > 0
|
||||
`;
|
||||
console.log(`Categories: ${catNames.length} unique, Parts: ${partNames.length} unique`);
|
||||
|
||||
const allUnique = [
|
||||
...new Set([
|
||||
...catNames.map((r) => r.name_original),
|
||||
...partNames.map((r) => r.name_original),
|
||||
]),
|
||||
].sort();
|
||||
console.log(`Combined unique: ${allUnique.length}`);
|
||||
|
||||
console.log("Loading existing translations...");
|
||||
const existing = await db
|
||||
.select({ originalName: emexCategoryTranslations.originalName })
|
||||
.from(emexCategoryTranslations);
|
||||
const existingSet = new Set(existing.map((r) => r.originalName));
|
||||
|
||||
const checkpoint = resume ? loadCheckpoint() : { completed: {}, failed: [] };
|
||||
const completedSet = new Set(Object.keys(checkpoint.completed));
|
||||
|
||||
const skipTerms: string[] = [];
|
||||
const markerTerms: string[] = [];
|
||||
const llmTerms: string[] = [];
|
||||
|
||||
for (const term of allUnique) {
|
||||
if (existingSet.has(term)) continue;
|
||||
if (completedSet.has(term)) continue;
|
||||
const decision = classify(term);
|
||||
if (decision === "skip") skipTerms.push(term);
|
||||
else if (decision === "marker") markerTerms.push(term);
|
||||
else llmTerms.push(term);
|
||||
}
|
||||
|
||||
const finalLlmTerms = limit ? llmTerms.slice(0, limit) : llmTerms;
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < finalLlmTerms.length; i += batchSize) {
|
||||
batches.push(finalLlmTerms.slice(i, i + batchSize));
|
||||
}
|
||||
|
||||
// Cost estimate (DeepSeek V3 via OpenRouter, current rates ~ $0.27/M in, $1.10/M out)
|
||||
// System prompt is ~2K tokens; DeepSeek auto-caches stable prefixes (cache hit ~10x cheaper).
|
||||
const inputTokens = batches.length * 2000 + finalLlmTerms.length * 8;
|
||||
const outputTokens = finalLlmTerms.length * 6;
|
||||
const estCost =
|
||||
(inputTokens / 1_000_000) * 0.27 + (outputTokens / 1_000_000) * 1.1;
|
||||
|
||||
console.log("\n=== Translation Plan ===");
|
||||
console.log(`In DB already: ${existingSet.size}`);
|
||||
console.log(`In checkpoint: ${completedSet.size}`);
|
||||
console.log(`Skip (empty): ${skipTerms.length}`);
|
||||
console.log(`Marker only: ${markerTerms.length} ("${MARKER}")`);
|
||||
console.log(`LLM translate: ${finalLlmTerms.length} (model: ${MODEL})`);
|
||||
console.log(`Batches: ${batches.length} × ${batchSize}, concurrency=${concurrency}`);
|
||||
console.log(`Est. cost: ~$${estCost.toFixed(2)} (DeepSeek V3 via OpenRouter)`);
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\nDRY RUN — no API calls or DB writes. Sample terms:");
|
||||
finalLlmTerms.slice(0, 20).forEach((t) => console.log(` - ${t}`));
|
||||
await sql.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Marker bulk insert
|
||||
if (markerTerms.length) {
|
||||
console.log(`\nInserting ${markerTerms.length} marker rows...`);
|
||||
for (let i = 0; i < markerTerms.length; i += 1000) {
|
||||
const chunk = markerTerms.slice(i, i + 1000);
|
||||
await db
|
||||
.insert(emexCategoryTranslations)
|
||||
.values(
|
||||
chunk.map((t) => ({
|
||||
originalName: t,
|
||||
translatedName: MARKER,
|
||||
isManual: false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
// LLM batches with bounded concurrency
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let inFlight = 0;
|
||||
let nextIdx = 0;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const launch = () => {
|
||||
if (nextIdx >= batches.length && inFlight === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
while (inFlight < concurrency && nextIdx < batches.length) {
|
||||
const idx = nextIdx++;
|
||||
const batch = batches[idx];
|
||||
inFlight++;
|
||||
(async () => {
|
||||
try {
|
||||
if (!ai) throw new Error("AI client not initialized");
|
||||
const translations = await translateBatch(ai, batch);
|
||||
await db
|
||||
.insert(emexCategoryTranslations)
|
||||
.values(
|
||||
batch.map((orig, i) => ({
|
||||
originalName: orig,
|
||||
translatedName: translations[i] || orig,
|
||||
isManual: false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
checkpoint.completed[batch[i]] = translations[i] || batch[i];
|
||||
}
|
||||
success += batch.length;
|
||||
if (idx % 5 === 0) saveCheckpoint(checkpoint);
|
||||
console.log(
|
||||
`Batch ${idx + 1}/${batches.length} ok (${batch.length}). Total ok: ${success}`,
|
||||
);
|
||||
} catch (err) {
|
||||
failed += batch.length;
|
||||
checkpoint.failed.push(...batch);
|
||||
console.error(`Batch ${idx + 1} failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
inFlight--;
|
||||
launch();
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
launch();
|
||||
});
|
||||
|
||||
saveCheckpoint(checkpoint);
|
||||
|
||||
console.log("\n=== Done ===");
|
||||
console.log(`LLM translated: ${success}`);
|
||||
console.log(`Markers: ${markerTerms.length}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Checkpoint: ${CHECKPOINT_PATH}`);
|
||||
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user