turash/bugulma/backend/internal/service/translation_orchestration_service.go

432 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bugulma/backend/internal/domain"
"bugulma/backend/internal/localization"
"context"
"fmt"
"strings"
"time"
)
// TranslationOrchestrationService orchestrates the complete translation workflow
// This service handles the high-level business logic for translating entities
type TranslationOrchestrationService struct {
workflowService *TranslationWorkflowService
entityLoader *EntityLoaderService
locService domain.LocalizationService
translationSvc *TranslationService
cacheService *TranslationCacheService
}
// NewTranslationOrchestrationService creates a new translation orchestration service
func NewTranslationOrchestrationService(
workflowService *TranslationWorkflowService,
entityLoader *EntityLoaderService,
locService domain.LocalizationService,
translationSvc *TranslationService,
cacheService *TranslationCacheService,
) *TranslationOrchestrationService {
return &TranslationOrchestrationService{
workflowService: workflowService,
entityLoader: entityLoader,
locService: locService,
translationSvc: translationSvc,
cacheService: cacheService,
}
}
// OrchestrationResult represents the result of a translation orchestration
// This is exported so CLI can format and display results
type OrchestrationResult struct {
TotalEntities int
TotalFields int
Translated int
Cached int
Reused int
Skipped int
Errors int
Duration time.Duration
EntityResults map[string]*EntityTypeResult
}
// EntityTypeResult represents results for a specific entity type
type EntityTypeResult struct {
EntityType string
Processed int
Translated int
Cached int
Reused int
Skipped int
Errors int
}
// TranslateAllEntities orchestrates translation for all entities matching the filter
func (s *TranslationOrchestrationService) TranslateAllEntities(
ctx context.Context,
targetLocale, entityTypeFilter string,
dryRun, allSites bool,
) (*OrchestrationResult, error) {
startTime := time.Now()
result := &OrchestrationResult{
EntityResults: make(map[string]*EntityTypeResult),
}
// Validate locale
if targetLocale != "en" && targetLocale != "tt" {
return nil, fmt.Errorf("invalid locale: %s. Supported locales: en, tt", targetLocale)
}
// Get entity types to process
entityTypes := localization.GetEntityTypesForFilter(entityTypeFilter)
if len(entityTypes) == 0 {
return nil, fmt.Errorf("no valid entity types found for filter: %s", entityTypeFilter)
}
// Process each entity type
for _, entityType := range entityTypes {
entityResult, err := s.translateEntityType(ctx, entityType, targetLocale, dryRun, allSites)
if err != nil {
// Log error but continue with other entity types
if entityResult == nil {
entityResult = &EntityTypeResult{EntityType: entityType, Errors: 1}
} else {
entityResult.Errors++
}
}
result.EntityResults[entityType] = entityResult
result.TotalEntities += entityResult.Processed
result.TotalFields += entityResult.Processed * len(localization.GetFieldsForEntityType(entityType))
result.Translated += entityResult.Translated
result.Cached += entityResult.Cached
result.Reused += entityResult.Reused
result.Skipped += entityResult.Skipped
result.Errors += entityResult.Errors
}
result.Duration = time.Since(startTime)
return result, nil
}
// TranslateSingleEntity translates a single entity by ID
func (s *TranslationOrchestrationService) TranslateSingleEntity(
ctx context.Context,
entityType, entityID, targetLocale string,
dryRun bool,
) (*EntityTranslationResult, error) {
// Load the entity
entity, err := s.entityLoader.LoadEntityByID(entityType, entityID)
if err != nil {
return nil, fmt.Errorf("failed to load entity %s:%s: %w", entityType, entityID, err)
}
// Get localizable fields
fields := localization.GetFieldsForEntityType(entityType)
if len(fields) == 0 {
return nil, fmt.Errorf("no localizable fields found for entity type: %s", entityType)
}
// Get existing localizations
localizations, err := s.locService.GetAllLocalizedValues(entityType, entityID)
if err != nil {
return nil, fmt.Errorf("failed to get localizations: %w", err)
}
// Translate all fields
result := &EntityTranslationResult{}
for _, field := range fields {
fieldResult := s.translateField(ctx, entityType, entityID, entity, field, localizations, targetLocale, dryRun)
if fieldResult.Error != nil {
result.Error = fieldResult.Error
continue
}
if fieldResult.Skipped {
result.Skipped++
continue
}
if fieldResult.Cached {
result.Cached++
} else if fieldResult.Reused {
result.Reused++
} else if fieldResult.Translated {
result.Translated++
}
}
return result, nil
}
// translateEntityType processes all entities of a specific type
func (s *TranslationOrchestrationService) translateEntityType(
ctx context.Context,
entityType, targetLocale string,
dryRun, allSites bool,
) (*EntityTypeResult, error) {
result := &EntityTypeResult{EntityType: entityType}
// Load entities
options := localization.LoadOptions{IncludeAllSites: allSites}
entities, err := s.entityLoader.LoadEntities(entityType, options)
if err != nil {
return result, fmt.Errorf("failed to load %s entities: %w", entityType, err)
}
if len(entities) == 0 {
return result, nil
}
// Get localizable fields
fields := localization.GetFieldsForEntityType(entityType)
// Process each entity
for _, entity := range entities {
entityID := s.entityLoader.GetEntityID(entity)
entityResults, err := s.translateEntity(ctx, entityType, entityID, entity, fields, targetLocale, dryRun)
if err != nil {
result.Errors++
continue
}
result.Processed++
result.Translated += entityResults.Translated
result.Cached += entityResults.Cached
result.Reused += entityResults.Reused
result.Skipped += entityResults.Skipped
if entityResults.Error != nil {
result.Errors++
}
}
return result, nil
}
// EntityTranslationResult represents results for translating a single entity
type EntityTranslationResult struct {
Translated int
Cached int
Reused int
Skipped int
Error error
}
// translateEntity processes translation for all fields of a single entity
func (s *TranslationOrchestrationService) translateEntity(
ctx context.Context,
entityType, entityID string,
entity interface{},
fields []string,
targetLocale string,
dryRun bool,
) (*EntityTranslationResult, error) {
result := &EntityTranslationResult{}
// Get existing localizations
localizations, err := s.locService.GetAllLocalizedValues(entityType, entityID)
if err != nil {
return result, fmt.Errorf("failed to get localizations: %w", err)
}
// Process each field
for _, field := range fields {
fieldResult := s.translateField(ctx, entityType, entityID, entity, field, localizations, targetLocale, dryRun)
if fieldResult.Error != nil {
result.Error = fieldResult.Error
continue
}
if fieldResult.Skipped {
result.Skipped++
continue
}
if fieldResult.Cached {
result.Cached++
} else if fieldResult.Reused {
result.Reused++
} else if fieldResult.Translated {
result.Translated++
}
}
return result, nil
}
// FieldTranslationResult represents the result of translating a single field
type FieldTranslationResult struct {
Translated bool
Cached bool
Reused bool
Skipped bool
Error error
}
// translateField handles translation logic for a single field
func (s *TranslationOrchestrationService) translateField(
ctx context.Context,
entityType, entityID string,
entity interface{},
field string,
localizations map[string]map[string]string,
targetLocale string,
dryRun bool,
) FieldTranslationResult {
result := FieldTranslationResult{}
// Get source value and language
sourceValue, sourceLang := s.getSourceValue(entity, field, localizations)
if sourceValue == "" {
result.Skipped = true
return result
}
// Skip very short content
if len(strings.TrimSpace(sourceValue)) < 2 {
result.Skipped = true
return result
}
// Check if translation already exists (and is not empty)
if fieldLocs, ok := localizations[field]; ok {
if existingValue, hasTranslation := fieldLocs[targetLocale]; hasTranslation && strings.TrimSpace(existingValue) != "" {
result.Skipped = true
return result
}
}
// Check for existing translation in database (reuse)
existingTranslation := s.findExistingTranslationInDB(ctx, entityType, field, targetLocale, sourceValue)
if existingTranslation != "" {
if !dryRun {
if err := s.locService.SetLocalizedValue(entityType, entityID, field, targetLocale, existingTranslation); err != nil {
result.Error = fmt.Errorf("failed to save reused translation: %w", err)
return result
}
}
result.Reused = true
return result
}
// Check cache
if cached, found, _ := s.cacheService.FindCachedTranslation(ctx, entityType, field, targetLocale, sourceValue); found {
if !dryRun {
if err := s.locService.SetLocalizedValue(entityType, entityID, field, targetLocale, cached); err != nil {
result.Error = fmt.Errorf("failed to save cached translation: %w", err)
return result
}
}
result.Cached = true
return result
}
// Perform translation
if dryRun {
result.Skipped = true
return result
}
translated, err := s.translationSvc.Translate(sourceValue, sourceLang, targetLocale)
if err != nil {
result.Error = fmt.Errorf("translation failed: %w", err)
return result
}
if translated == "" {
result.Error = fmt.Errorf("empty translation received")
return result
}
// Save translation
if err := s.locService.SetLocalizedValue(entityType, entityID, field, targetLocale, translated); err != nil {
result.Error = fmt.Errorf("failed to save translation: %w", err)
return result
}
// Update cache
s.cacheService.AddToCache(entityType, field, targetLocale, sourceValue, translated)
result.Translated = true
return result
}
// getSourceValue determines the source value and language for translation
// It prefers Russian localization, then falls back to entity field value
func (s *TranslationOrchestrationService) getSourceValue(
entity interface{},
field string,
localizations map[string]map[string]string,
) (string, string) {
// Check if Russian localization exists
if fieldLocs, ok := localizations[field]; ok {
if ruVal, hasRu := fieldLocs["ru"]; hasRu && ruVal != "" {
return ruVal, "ru"
}
}
// Get entity field value
entityValue := s.entityLoader.GetRussianContent(entity, field)
if entityValue == "" {
return "", ""
}
// Check if it's Russian (contains Cyrillic)
if s.hasCyrillic(entityValue) {
return entityValue, "ru"
}
// English content - check if we have English localization
if fieldLocs, ok := localizations[field]; ok {
if enVal, hasEn := fieldLocs["en"]; hasEn && enVal != "" {
return enVal, "en"
}
}
// Use entity field value as English source
return entityValue, "en"
}
// hasCyrillic checks if a string contains Cyrillic characters or Russian-specific symbols
func (s *TranslationOrchestrationService) hasCyrillic(text string) bool {
for _, r := range text {
// Check for Cyrillic letters (Russian and Tatar)
if (r >= 'А' && r <= 'Я') || (r >= 'а' && r <= 'я') || r == 'Ё' || r == 'ё' {
return true
}
// Check for Tatar-specific Cyrillic letters
if r == 'Ә' || r == 'ә' || r == 'Ө' || r == 'ө' || r == 'Ү' || r == 'ү' ||
r == 'Җ' || r == 'җ' || r == 'Ң' || r == 'ң' || r == 'Һ' || r == 'һ' {
return true
}
// Check for Russian typographic symbols
// № (U+2116) - Numero sign, commonly used in Russian
if r == '№' || r == 0x2116 {
return true
}
}
return false
}
// findExistingTranslationInDB searches for existing translations in the database
func (s *TranslationOrchestrationService) findExistingTranslationInDB(
ctx context.Context,
entityType, field, targetLocale, sourceText string,
) string {
if sourceText == "" {
return ""
}
normalized := strings.TrimSpace(sourceText)
// Use cache service to find existing translation
// The cache service should check the database for similar translations
translation, found, _ := s.cacheService.FindCachedTranslation(ctx, entityType, field, targetLocale, normalized)
if found {
return translation
}
return ""
}