Files
Dimension-47/server/src/modules/translations/translations.service.ts
Alexander Zielonka 94335ecd12 feat: Charaktere-Modul mit Pathbuilder Import
Backend:
- Characters-Modul (CRUD, HP-Tracking, Conditions)
- Pathbuilder 2e JSON Import Service
- Claude API Integration für automatische Übersetzungen
- Translations-Modul mit Datenbank-Caching
- Prisma Schema erweitert (Character, Abilities, Skills, Feats, Items, Resources)

Frontend:
- Kampagnen-Detailseite mit Mitglieder- und Charakterverwaltung
- Charakter erstellen Modal
- Pathbuilder Import Modal (Datei-Upload + JSON-Paste)
- Logo-Integration (Dimension 47 + Zeasy)
- Cinzel Font für Branding

Weitere Änderungen:
- Auth 401 Redirect Fix für Login-Seite
- PROGRESS.md mit Projektfortschritt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 20:36:44 +01:00

160 lines
4.8 KiB
TypeScript

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<TranslationResponse> {
// 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<Map<string, TranslationResponse>> {
const result = new Map<string, TranslationResponse>();
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<void> {
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('...');
}
}