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>
This commit is contained in:
159
server/src/modules/translations/translations.service.ts
Normal file
159
server/src/modules/translations/translations.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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('...');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user