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>
160 lines
4.8 KiB
TypeScript
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('...');
|
|
}
|
|
}
|