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:
Sase Dev
2026-05-10 08:59:32 +00:00
parent 5ab868480d
commit 48166b4cf3
9 changed files with 838 additions and 12 deletions

View File

@@ -25,4 +25,5 @@ export const QUEUE_NAMES = {
SUBSCRIPTION_EXPIRY: "subscription-expiry",
QUERY_CLEANUP: "query-cleanup",
CATALOG_PREFETCH: "catalog-prefetch",
TRANSLATION: "translation",
} as const;

View 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 };
}

View 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 },
},
});
},
};

View File

@@ -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 {}

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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(", ")}`);

View 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);
});

View 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);
});