15

Rozdział 15 z 21

Tłumaczenia kategorii i generowanie AI

Opublikowany

Domykamy wielojęzyczność: tłumaczenia kategorii (BlogCategoryTranslation), panel tłumaczeń w adminie z przełącznikiem języka oraz generowanie tłumaczeń przez AI z odpowiednim oznaczeniem treści.

Czego się nauczysz

  • Tłumaczenia kategorii (BlogCategoryTranslation)
  • Panel tłumaczeń w adminie
  • Przełącznik języka i LocaleSwitcher
  • Generowanie tłumaczeń przez AI

Rozdział 15: Tłumaczenia kategorii i generowanie AI

Domykamy wielojęzyczność. Do tłumaczeń artykułów (Rozdział 14) dokładamy tłumaczenia kategorii, panel tłumaczeń w adminie, generowanie przez AI oraz przełącznik języka w menu.

Stan wejściowy: tłumaczenia artykułów i BlogSlugResolver (Rozdział 14). Tu rozszerzamy wzorzec na kategorie i dokładamy narzędzia w adminie.


Tłumaczenia kategorii

Kategorie tłumaczymy tym samym wzorcem co artykuły — osobna tabela blog_category_translation z nazwą i slug-iem per język:

// src/Entity/BlogCategoryTranslation.php
#[ORM\Entity(repositoryClass: BlogCategoryTranslationRepository::class)]
#[ORM\Table(name: 'blog_category_translation')]
#[ORM\UniqueConstraint(name: 'uniq_bct_category_locale', fields: ['category', 'locale'])]
class BlogCategoryTranslation
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: BlogCategory::class, inversedBy: 'translations')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?BlogCategory $category = null;

    #[ORM\Column(length: 10)]
    private ?string $locale = null;

    #[ORM\Column(length: 100)]
    private ?string $name = null;

    #[ORM\Column(length: 120, unique: true, nullable: true)]
    private ?string $slug = null;
}

Dokładamy kolekcję translations do BlogCategory (odłożoną w Rozdziale 3) — z orphanRemoval, bo tłumaczenia w pełni należą do kategorii:

// src/Entity/BlogCategory.php
/** @var Collection<int, BlogCategoryTranslation> */
#[ORM\OneToMany(targetEntity: BlogCategoryTranslation::class, mappedBy: 'category',
    cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $translations;

Do wyświetlania dodajemy na encji metody „display”, które zwracają wersję dla bieżącego języka (z fallbackiem na oryginał):

public function getDisplayName(string $locale): string
{
    foreach ($this->translations as $t) {
        if ($t->getLocale() === $locale && null !== $t->getName()) {
            return $t->getName();
        }
    }
    return (string) $this->name;   // fallback: oryginał
}

BlogSlugResolver z Rozdziału 14 ma bliźniaczą metodę resolveCategory($slug) — slug kategorii również determinuje język strony kategorii.


Panel tłumaczeń w adminie

Tłumaczeniami zarządzamy w osobnym kontrolerze pod /admin/blog/{id}/translations (chronionym ROLE_ADMIN). Widok główny to macierz języków — dla każdego z obsługiwanych locale pokazujemy status (brak / szkic / opublikowane) i akcje:

// src/Controller/Admin/BlogArticleTranslationAdminController.php
#[Route('/admin/blog/{id}/translations', name: 'admin_blog_translation_')]
#[IsGranted('ROLE_ADMIN')]
class BlogArticleTranslationAdminController extends AbstractController
{
    private const LOCALES = [
        'en' => ['label' => 'English',  'flag' => '🇬🇧'],
        'de' => ['label' => 'Deutsch',  'flag' => '🇩🇪'],
        'fr' => ['label' => 'Français', 'flag' => '🇫🇷'],
        'es' => ['label' => 'Español',  'flag' => '🇪🇸'],
        'ru' => ['label' => 'Русский',  'flag' => '🇷🇺'],
    ];

    #[Route('', name: 'index')]
    public function index(BlogArticle $article, BlogArticleTranslationRepository $repo): Response
    {
        $byLocale = [];
        foreach ($repo->findAllForArticle($article) as $t) {
            $byLocale[$t->getLocale()] = $t;
        }

        return $this->render('admin/blog/translations/index.html.twig', [
            'article'  => $article,
            'locales'  => self::LOCALES,
            'byLocale' => $byLocale,
        ]);
    }
}

Akcja edit (/{locale}/edit) to zwykły formularz tłumaczenia (tytuł, slug, zajawka, treść, status) — jego zapis działa jak każdy CRUD z Rozdziału 8.


Generowanie przez AI

Zamiast tłumaczyć ręcznie, generujemy wersję językową modelem AI. Logikę zamykamy w serwisie BlogTranslationAiService, który woła API modelu (tu Anthropic/Claude) przez HttpClientInterface:

// src/Service/BlogTranslationAiService.php (szkielet)
final readonly class BlogTranslationAiService
{
    private const MODEL = 'claude-sonnet-4-6';

    public function __construct(
        private HttpClientInterface $httpClient,
        private EntityManagerInterface $em,
        #[Autowire('%env(ANTHROPIC_API_KEY)%')] private string $apiKey,
    ) {}

    public function translate(BlogArticle $article, string $sourceLocale, string $targetLocale, ?User $triggeredBy): BlogArticleTranslation
    {
        $source = $this->resolveSource($article, $sourceLocale);       // tekst źródłowy (PL lub inne tłum.)
        $result = $this->callAnthropic($source, $targetLocale);        // → tytuł/slug/zajawka/treść

        $translation = /* utwórz lub zaktualizuj BlogArticleTranslation */;
        $translation->setIsAi(true)->setAiModel(self::MODEL);          // oznacz jako AI
        $this->em->flush();

        return $translation;
    }
}

Akcja kontrolera jest JSON-owa (POST /{locale}/ai) — front wywołuje ją AJAX-em i wypełnia formularz edycji wygenerowaną treścią:

#[Route('/{locale}/ai', name: 'ai', methods: ['POST'])]
public function aiGenerate(BlogArticle $article, string $locale, Request $request, BlogTranslationAiService $ai): JsonResponse
{
    $sourceLocale = $request->request->getString('source_locale', 'pl');

    try {
        $t = $ai->translate($article, $sourceLocale, $locale, $this->getUser());

        return $this->json([
            'ok' => true, 'title' => $t->getTitle(), 'slug' => $t->getSlug(),
            'excerpt' => $t->getExcerpt(), 'content' => $t->getContent(),
            'isAi' => true, 'model' => $t->getAiModel(),
        ]);
    } catch (\Throwable $e) {
        return $this->json(['ok' => false, 'error' => $e->getMessage()], 500);
    }
}
  • isAi + aiModel — na encji tłumaczenia zapisujemy, że powstało z AI i którym modelem. Na froncie pokazujemy przy takim artykule notkę „to tłumaczenie zostało wygenerowane przez AI”.
  • Klucz API z ENV (ANTHROPIC_API_KEY) — nigdy w kodzie; wstrzykujemy przez #[Autowire('%env(...)%')].
  • Wynik trafia do formularza edit, więc admin może go poprawić przed publikacją — AI to punkt startowy, nie ostateczna wersja.

Przełącznik języka w menu

Skoro artykuł zna swoje tłumaczenia (kolekcja article.translations), możemy zbudować przełącznik języka w menu — link na każdą opublikowaną wersję, z fallbackiem na oryginał, gdy tłumaczenia brak:

{% for t in article.translations if t.status == 'published' and t.slug %}
    <a class="dropdown-item {{ t.locale == app.request.locale ? 'active' }}"
       href="{{ path('app_blog_show', { slug: t.slug }) }}">{{ t.locale|upper }}</a>
{% endfor %}

Przełącznik prowadzi wprost na slug danego języka — a BlogSlugResolver (Rozdział 14) rozpozna język z tego slug-a i wyrenderuje stronę we właściwej wersji. Do programistycznej zmiany języka Symfony ma też serwis LocaleSwitcher (użyliśmy go w show()).


Podsumowanie

Blog jest w pełni wielojęzyczny:

  • ✅ tłumaczenia kategorii (BlogCategoryTranslation, resolveCategory, metody getDisplayName/Slug z fallbackiem),
  • ✅ panel tłumaczeń w adminie (/admin/blog/{id}/translations) — macierz języków + formularz edycji,
  • ✅ generowanie AI (BlogTranslationAiService przez HttpClientInterface, klucz z ENV, akcja JSON, flaga isAi/aiModel + notka na froncie),
  • ✅ przełącznik języka oparty na article.translations (+ LocaleSwitcher),
  • ✅ działający stan: dodajesz/generujesz tłumaczenie w adminie, przełącznik prowadzi na slug języka, a strona renderuje się w danym języku z poprawnym SEO (Rozdział 14).

To koniec Części III — blog obsługuje wiele języków (artykuły i kategorie, slug per język, hreflang, generowanie AI).

W Części IV (Rozdziały 16–17) udostępnimy blog przez REST API — publiczne endpointy z DTO i dokumentacją OpenAPI oraz API admina z uwierzytelnianiem tokenem i rate limitingiem. Zaczniemy w Rozdziale 16.

Narzędzia / paczki

BlogCategoryTranslation LocaleSwitcher

Spis treści