import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { ClaudeService, TranslationRequest, TranslationResponse } from '../claude/claude.service'; import { TranslationType, TranslationQuality } from '../../generated/prisma/client.js'; @Injectable() export class TranslationsService { private readonly logger = new Logger(TranslationsService.name); constructor( private prisma: PrismaService, private claudeService: ClaudeService, ) {} /** * Get a translation from cache or translate via Claude */ async getTranslation( type: TranslationType, englishName: string, englishDescription?: string, ): Promise { // Check cache first const cached = await this.prisma.translation.findUnique({ where: { type_englishName: { type, englishName } }, }); if (cached && !this.isIncomplete(cached.germanDescription)) { this.logger.debug(`Cache hit for ${type}: ${englishName}`); return { englishName: cached.englishName, germanName: cached.germanName, germanDescription: cached.germanDescription || undefined, translationQuality: cached.quality, }; } // Translate via Claude this.logger.debug(`Cache miss for ${type}: ${englishName}, calling Claude`); const translation = await this.claudeService.translateSingle({ type: type as TranslationRequest['type'], englishName, englishDescription, }); // Cache the result await this.upsertTranslation(type, translation); return translation; } /** * Get multiple translations, using cache where possible */ async getTranslationsBatch( type: TranslationType, items: Array<{ englishName: string; englishDescription?: string }>, ): Promise> { const result = new Map(); if (items.length === 0) { return result; } // Check cache for all items const englishNames = items.map(i => i.englishName); const cached = await this.prisma.translation.findMany({ where: { type, englishName: { in: englishNames }, }, }); const cachedMap = new Map(cached.map(c => [c.englishName, c])); const toTranslate: TranslationRequest[] = []; for (const item of items) { const cachedItem = cachedMap.get(item.englishName); if (cachedItem && !this.isIncomplete(cachedItem.germanDescription)) { result.set(item.englishName, { englishName: cachedItem.englishName, germanName: cachedItem.germanName, germanDescription: cachedItem.germanDescription || undefined, translationQuality: cachedItem.quality, }); } else { toTranslate.push({ type: type as TranslationRequest['type'], englishName: item.englishName, englishDescription: item.englishDescription, }); } } this.logger.log(`${type}: ${cached.length} cached, ${toTranslate.length} to translate`); // Translate missing items in batches of 20 if (toTranslate.length > 0) { const batchSize = 20; for (let i = 0; i < toTranslate.length; i += batchSize) { const batch = toTranslate.slice(i, i + batchSize); const translations = await this.claudeService.translateBatch(batch); for (const translation of translations) { result.set(translation.englishName, translation); await this.upsertTranslation(type, translation); } } } return result; } /** * Store or update a translation in the cache */ async upsertTranslation( type: TranslationType, translation: TranslationResponse, ): Promise { await this.prisma.translation.upsert({ where: { type_englishName: { type, englishName: translation.englishName }, }, update: { germanName: translation.germanName, germanDescription: translation.germanDescription, quality: translation.translationQuality as TranslationQuality, updatedAt: new Date(), }, create: { type, englishName: translation.englishName, germanName: translation.germanName, germanDescription: translation.germanDescription, quality: translation.translationQuality as TranslationQuality, translatedBy: 'claude-api', }, }); } /** * Get all cached translations of a type */ async getAllByType(type: TranslationType) { return this.prisma.translation.findMany({ where: { type }, orderBy: { englishName: 'asc' }, }); } /** * Check if a translation is incomplete (truncated) */ private isIncomplete(description?: string | null): boolean { if (!description) return false; return description.trim().endsWith('…') || description.trim().endsWith('...'); } }