diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b0dff5..e3f7a53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
deferred to follow-on PRs per ADR 005
([#14](https://github.com/itk-dev/ai-lib/issues/14),
[#16](https://github.com/itk-dev/ai-lib/issues/16)).
+- Assistant detail page at `/assistant/{id}` rendering the base
+ fields (title, description, framework, language model, tags).
+ Export entry point, organisation / author display, system-prompt
+ preview, and back-link to the catalogue listing all wait for the
+ follow-on data and #15 / #22
+ ([#20](https://github.com/itk-dev/ai-lib/issues/20)).
+- `AssistantFixtures` seeding 20 deterministic assistants — five
+ hand-written authentic catalogue entries (Borgerservice-vejviser,
+ Mødereferent, Journaliseringsassistent, Skole- og dagtilbudssvar,
+ Tilsynsrapport-assistent) plus 15 generated from a fixed set of
+ topics × kommuner × language models, no randomness.
+- Frontpage CardRail and stats now read from the database. The
+ hardcoded `SAMPLE_ASSISTANTS` constant in `FrontpageController` is
+ gone; cards iterate the five most-recent persisted `Assistant`s
+ (newest first) and each card links to `/assistant/{id}`. The
+ Assistanter and Sprogmodeller stat values are computed from real
+ queries; Kommuner stays a placeholder (`10`) until ADR 005 /
+ [#65](https://github.com/itk-dev/ai-lib/issues/65) lands the
+ `Organization` entity.
### Changed
diff --git a/CLAUDE.md b/CLAUDE.md
index 526eff0..4430a2e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -112,6 +112,13 @@ logic. Push logic into a service class. A controller action looks like:
inject service → call service method → return `render()` / `Response` /
`RedirectResponse`.
+**Do not add PHPDoc to controllers.** The class name, route attribute,
+action name, parameter types, and return type already describe what an
+action does; class- and method-level docblocks duplicate that. Push the
+explanatory prose into the (fully documented) service the controller
+delegates to. If a controller is so unusual that it needs a docblock to
+explain itself, that's the signal it's doing too much.
+
### Service classes are fully documented
Every service class method (public, protected, private) carries a PHPDoc block
diff --git a/src/Controller/AssistantController.php b/src/Controller/AssistantController.php
new file mode 100644
index 0000000..840d245
--- /dev/null
+++ b/src/Controller/AssistantController.php
@@ -0,0 +1,21 @@
+ '\d+'], methods: ['GET'])]
+ public function show(Assistant $assistant): Response
+ {
+ return $this->render('assistant/show.html.twig', [
+ 'assistant' => $assistant,
+ ]);
+ }
+}
diff --git a/src/Controller/FrontpageController.php b/src/Controller/FrontpageController.php
index 6f24b5c..8723dce 100644
--- a/src/Controller/FrontpageController.php
+++ b/src/Controller/FrontpageController.php
@@ -4,76 +4,30 @@
namespace App\Controller;
+use App\Repository\AssistantRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
-class FrontpageController extends AbstractController
+final class FrontpageController extends AbstractController
{
- /**
- * Hardcoded placeholder assistants used by the design preview.
- *
- * Drawn from the AI Bibliotek prototype's seed data to give an
- * accurate first-glance impression of the catalogue. Replaced by
- * a real repository query once the persistence layer lands.
- */
- private const SAMPLE_ASSISTANTS = [
- [
- 'kommune' => 'Aarhus Kommune',
- 'model' => 'gpt-4o',
- 'name' => 'Borgerservice-vejviser',
- 'summary' => 'Hjælper sagsbehandlere med at finde den rigtige paragraf i lov om social service og opsummere borgerens situation.',
- ],
- [
- 'kommune' => 'Københavns Kommune',
- 'model' => 'claude-3.5-sonnet',
- 'name' => 'Mødereferent',
- 'summary' => 'Tager udgangspunkt i et indtalt møde og leverer struktureret referat med beslutninger, ansvar og deadlines.',
- ],
- [
- 'kommune' => 'Odense Kommune',
- 'model' => 'llama-3.1-70b',
- 'name' => 'Journaliseringsassistent',
- 'summary' => 'Foreslår journalplan-numre og overskrifter ud fra dokumentets indhold, så fagmedarbejdere kan godkende i et klik.',
- ],
- [
- 'kommune' => 'Vejle Kommune',
- 'model' => 'gpt-4o-mini',
- 'name' => 'Skole- og dagtilbudssvar',
- 'summary' => 'Drafter svar til forældrehenvendelser på skole- og dagtilbudsområdet med kildehenvisninger til kommunens egen vejledningssamling.',
- ],
- [
- 'kommune' => 'Aalborg Kommune',
- 'model' => 'mistral-large',
- 'name' => 'Tilsynsrapport-assistent',
- 'summary' => 'Læser plejehjemstilsynsrapporter og fremhæver afvigelser, opfølgningspunkter og forbedringer over tid.',
- ],
- ];
+ public function __construct(private readonly AssistantRepository $assistants)
+ {
+ }
- /**
- * Render the placeholder frontpage.
- *
- * Anonymous visitors to `/` receive a design-preview landing page
- * that mirrors the AI Bibliotek prototype. Hero, search prompt,
- * sample-assistant rail, and "Sådan virker det" steps are rendered
- * with hardcoded sample data — the point is to convey what the
- * catalogue will feel like before the persistence and search
- * layers land.
- *
- * @return Response the rendered `frontpage/index.html.twig` template
- */
#[Route('/', name: 'app_frontpage', methods: ['GET'])]
public function index(): Response
{
- $kommuner = array_unique(array_column(self::SAMPLE_ASSISTANTS, 'kommune'));
- $models = array_unique(array_column(self::SAMPLE_ASSISTANTS, 'model'));
-
return $this->render('frontpage/index.html.twig', [
- 'assistants' => self::SAMPLE_ASSISTANTS,
+ 'assistants' => $this->assistants->findBy([], ['id' => 'DESC'], 5),
'stats' => [
- 'assistants' => count(self::SAMPLE_ASSISTANTS),
- 'kommuner' => count($kommuner),
- 'models' => count($models),
+ 'assistants' => $this->assistants->count([]),
+ // TODO: derive from `OrganizationRepository::count()` once
+ // ADR 005 / #65 lands the Organization entity. For now we
+ // surface the static count that matches what AssistantFixtures
+ // seeds across its detailed + generated entries.
+ 'kommuner' => 10,
+ 'models' => $this->assistants->countDistinctLanguageModels(),
],
]);
}
diff --git a/src/DataFixtures/AssistantFixtures.php b/src/DataFixtures/AssistantFixtures.php
new file mode 100644
index 0000000..2bf1549
--- /dev/null
+++ b/src/DataFixtures/AssistantFixtures.php
@@ -0,0 +1,153 @@
+loadDetailed($manager);
+ $this->loadGenerated($manager);
+ $manager->flush();
+ }
+
+ private function loadDetailed(ObjectManager $manager): void
+ {
+ $entries = [
+ new Assistant(
+ title: 'Borgerservice-vejviser',
+ description: 'Hjælper sagsbehandlere i borgerservice med at finde den rigtige paragraf i lov om social service og lov om aktiv socialpolitik. Tager udgangspunkt i en kort beskrivelse af borgerens situation og foreslår relevante lovhjemler, sagskategorier og næste skridt. Indeholder kommunens egne vejledninger og praksisnotater som baggrundsviden. Delt af Aarhus Kommune.',
+ languageModel: 'gpt-4o',
+ framework: 'openwebui',
+ tags: ['borgerservice', 'social', 'jura'],
+ ),
+ new Assistant(
+ title: 'Mødereferent',
+ description: 'Tager udgangspunkt i et indtalt eller transskriberet mødeoptag og leverer et struktureret referat med beslutninger, ansvarsfordeling og deadlines. Identificerer automatisk handlepunkter og foreslår opfølgningstidspunkter. Bruges på direktionsmøder, projektmøder og udvalgsmøder. Delt af Københavns Kommune.',
+ languageModel: 'claude-3.5-sonnet',
+ framework: 'openwebui',
+ tags: ['mødeledelse', 'dokumentation', 'produktivitet'],
+ ),
+ new Assistant(
+ title: 'Journaliseringsassistent',
+ description: 'Foreslår journalplan-numre og overskrifter ud fra dokumentets indhold, så fagmedarbejdere kan godkende i ét klik. Tager højde for kommunens egen klassifikationsstruktur og henter forslag fra historiske, lignende sager. Reducerer den tid medarbejdere bruger på korrekt arkivering markant. Delt af Odense Kommune.',
+ languageModel: 'llama-3.1-70b',
+ framework: 'openwebui',
+ tags: ['dokumentation', 'journalisering', 'arkiv'],
+ ),
+ new Assistant(
+ title: 'Skole- og dagtilbudssvar',
+ description: 'Drafter svar til forældrehenvendelser på skole- og dagtilbudsområdet. Bygger svaret på kommunens egen vejledningssamling, gældende lovgivning på området og det specifikke dagtilbuds praksis. Vedhæfter kildehenvisninger så medarbejderen kan tjekke baggrunden inden afsendelse. Delt af Vejle Kommune.',
+ languageModel: 'gpt-4o-mini',
+ framework: 'openwebui',
+ tags: ['skole', 'dagtilbud', 'kommunikation'],
+ ),
+ new Assistant(
+ title: 'Tilsynsrapport-assistent',
+ description: 'Læser plejehjemstilsynsrapporter og fremhæver afvigelser, opfølgningspunkter og udvikling over tid. Sammenligner det enkelte plejehjems resultater med kommune- og landsgennemsnit og foreslår fokusområder til det næste tilsyn. Bygger på Styrelsen for Patientsikkerheds tilsynsdata. Delt af Aalborg Kommune.',
+ languageModel: 'mistral-large',
+ framework: 'openwebui',
+ tags: ['sundhed', 'tilsyn', 'plejehjem'],
+ ),
+ ];
+
+ foreach ($entries as $assistant) {
+ $manager->persist($assistant);
+ }
+ }
+
+ private function loadGenerated(ObjectManager $manager): void
+ {
+ $topics = [
+ [
+ 'title' => 'HR-håndbog assistent',
+ 'description' => 'Slår op i kommunens personalehåndbog og besvarer spørgsmål om ferie, sygdomsregler og overenskomster med citater fra kilden.',
+ 'tags' => ['hr', 'personale'],
+ ],
+ [
+ 'title' => 'Indkøbsguide',
+ 'description' => 'Hjælper indkøbsansvarlige med at finde gældende rammeaftaler, foreslå relevante leverandører og generere udkast til rekvisitioner.',
+ 'tags' => ['indkøb', 'udbud'],
+ ],
+ [
+ 'title' => 'Politisk dagsorden-resumé',
+ 'description' => 'Læser udvalgs- og byrådsdagsordener og leverer letlæste resuméer med beslutningspunkter, høringssvar og baggrundsmateriale.',
+ 'tags' => ['politik', 'dagsorden'],
+ ],
+ [
+ 'title' => 'Forvaltningsret-vejviser',
+ 'description' => 'Vejleder sagsbehandlere i forvaltningsrettens grundprincipper med praksisnotater og henvisninger til relevante lovparagraffer.',
+ 'tags' => ['jura', 'sagsbehandling'],
+ ],
+ [
+ 'title' => 'Sundhedsfaglig sparring',
+ 'description' => 'Faglig sparringspartner for hjemmeplejen — kvalitetssikrer plejeplaner og foreslår dokumentationsforbedringer ud fra Sundhedsstyrelsens retningslinjer.',
+ 'tags' => ['sundhed', 'hjemmepleje'],
+ ],
+ [
+ 'title' => 'Borgerhenvendelse-svarudkast',
+ 'description' => 'Drafter udkast til svar på borgermails ud fra kommunens egne vejledninger og gældende lovgivning, så medarbejderen kan rette til og godkende.',
+ 'tags' => ['borgerservice', 'kommunikation'],
+ ],
+ [
+ 'title' => 'Statistikfortolker',
+ 'description' => 'Læser kommunens KPI-rapporter og foreslår tekstuelle forklaringer på udsving samt sammenligninger med foregående perioder og kommunegennemsnit.',
+ 'tags' => ['statistik', 'rapportering'],
+ ],
+ ];
+
+ $kommunes = [
+ 'Aarhus Kommune',
+ 'Københavns Kommune',
+ 'Odense Kommune',
+ 'Vejle Kommune',
+ 'Aalborg Kommune',
+ 'Esbjerg Kommune',
+ 'Frederiksberg Kommune',
+ 'Randers Kommune',
+ 'Kolding Kommune',
+ 'Horsens Kommune',
+ ];
+
+ $languageModels = [
+ 'gpt-4o',
+ 'gpt-4o-mini',
+ 'claude-3.5-sonnet',
+ 'llama-3.1-70b',
+ 'mistral-large',
+ ];
+
+ $topicCount = count($topics);
+ $kommuneCount = count($kommunes);
+ $modelCount = count($languageModels);
+
+ for ($i = 0; $i < 15; ++$i) {
+ $topic = $topics[$i % $topicCount];
+ $kommune = $kommunes[$i % $kommuneCount];
+ $languageModel = $languageModels[$i % $modelCount];
+
+ $manager->persist(new Assistant(
+ title: $topic['title'].' – '.$kommune,
+ description: $topic['description'].' Delt af '.$kommune.'.',
+ languageModel: $languageModel,
+ framework: 'openwebui',
+ tags: $topic['tags'],
+ ));
+ }
+ }
+}
diff --git a/src/Repository/AssistantRepository.php b/src/Repository/AssistantRepository.php
index 647939e..26cf207 100644
--- a/src/Repository/AssistantRepository.php
+++ b/src/Repository/AssistantRepository.php
@@ -17,4 +17,16 @@ public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Assistant::class);
}
+
+ /**
+ * Count how many distinct `languageModel` values are in use across
+ * the catalogue. Powers the frontpage "Sprogmodeller" stat.
+ */
+ public function countDistinctLanguageModels(): int
+ {
+ return (int) $this->createQueryBuilder('a')
+ ->select('COUNT(DISTINCT a.languageModel)')
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
}
diff --git a/templates/assistant/show.html.twig b/templates/assistant/show.html.twig
new file mode 100644
index 0000000..02fa234
--- /dev/null
+++ b/templates/assistant/show.html.twig
@@ -0,0 +1,52 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{{ 'assistant.detail.title'|trans({'%title%': assistant.title, '%brand%': brand_name}) }}{% endblock %}
+
+{% block body %}
+
+
+
+
+
+
+
-
+ {{ 'assistant.detail.framework_label'|trans }}
+
+ - {{ assistant.framework }}
+
+
+
+ {{ 'assistant.detail.language_model_label'|trans }}
+
+ {{ assistant.languageModel }}
+
+
+
+
+ {% if assistant.tags is not empty %}
+
+
+ {% for tag in assistant.tags %}
+ -
+ {{ tag }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+{% endblock %}
diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig
index e67515d..ce910af 100644
--- a/templates/frontpage/index.html.twig
+++ b/templates/frontpage/index.html.twig
@@ -22,12 +22,18 @@
- {% for a in assistants %}
+ {# Card prop names (`kommune`, `model`, `name`, `summary`) come from #}
+ {# the original prototype design; today we map them to the fields #}
+ {# the Assistant entity actually carries. Once the Organization #}
+ {# entity lands (ADR 005), the `kommune` slot returns to a real #}
+ {# organisation name. #}
+ {% for assistant in assistants %}
+ kommune="{{ assistant.framework }}"
+ model="{{ assistant.languageModel }}"
+ name="{{ assistant.title }}"
+ summary="{{ assistant.description }}"
+ href="{{ path('app_assistant_show', {id: assistant.id}) }}" />
{% endfor %}
diff --git a/tests/Controller/AssistantControllerTest.php b/tests/Controller/AssistantControllerTest.php
new file mode 100644
index 0000000..960aeca
--- /dev/null
+++ b/tests/Controller/AssistantControllerTest.php
@@ -0,0 +1,86 @@
+client = self::createClient();
+ $container = self::getContainer();
+ $em = $container->get(EntityManagerInterface::class);
+ self::resetSchema($em);
+
+ $this->assistant = new Assistant(
+ title: 'Borgerservice-vejviser',
+ description: 'Hjælper sagsbehandlere med at finde den rigtige paragraf.',
+ languageModel: 'gpt-4o',
+ framework: 'openwebui',
+ tags: ['borgerservice', 'paragraf'],
+ );
+ $em->persist($this->assistant);
+ $em->flush();
+ }
+
+ public function testRendersAssistantDetail(): void
+ {
+ $id = $this->assistant->getId();
+ self::assertNotNull($id);
+
+ $crawler = $this->client->request('GET', '/assistant/'.$id);
+
+ self::assertResponseIsSuccessful();
+ self::assertSelectorTextContains('h1', 'Borgerservice-vejviser');
+ self::assertSelectorTextContains('article', 'Hjælper sagsbehandlere');
+
+ $runtime = $crawler->filter('article dl')->text();
+ self::assertStringContainsString('openwebui', $runtime);
+ self::assertStringContainsString('gpt-4o', $runtime);
+
+ $tagsText = $crawler->filter('article ul')->text();
+ self::assertStringContainsString('borgerservice', $tagsText);
+ self::assertStringContainsString('paragraf', $tagsText);
+ }
+
+ public function testOmitsTagsSectionWhenAssistantHasNone(): void
+ {
+ $bare = new Assistant('Tagless', 'No tags here.', 'gpt-4o', 'openwebui');
+ $em = self::getContainer()->get(EntityManagerInterface::class);
+ $em->persist($bare);
+ $em->flush();
+ $id = $bare->getId();
+ self::assertNotNull($id);
+
+ $crawler = $this->client->request('GET', '/assistant/'.$id);
+
+ self::assertResponseIsSuccessful();
+ self::assertCount(0, $crawler->filter('article ul'), 'tags must be absent when the list is empty');
+ }
+
+ public function testUnknownAssistantReturns404(): void
+ {
+ $this->client->request('GET', '/assistant/999999');
+
+ self::assertResponseStatusCodeSame(404);
+ }
+}
diff --git a/tests/Controller/FrontpageControllerTest.php b/tests/Controller/FrontpageControllerTest.php
index 25d57b5..f5beca6 100644
--- a/tests/Controller/FrontpageControllerTest.php
+++ b/tests/Controller/FrontpageControllerTest.php
@@ -4,28 +4,92 @@
namespace App\Tests\Controller;
+use App\Entity\Assistant;
+use App\Tests\Support\ResetsDatabaseSchemaTrait;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
-/**
- * Functional smoke test for the placeholder frontpage.
- *
- * Ensures that anonymous visitors to `/` receive a 200 response and
- * that the rendered HTML identifies the project. The test is committed
- * ahead of the PHPUnit setup in #31 so it begins to run automatically
- * once the test runner lands.
- */
-class FrontpageControllerTest extends WebTestCase
+final class FrontpageControllerTest extends WebTestCase
{
- /**
- * `GET /` returns 200 and shows the project identifier.
- */
- public function testFrontpageReturns200AndShowsProjectName(): void
+ use ResetsDatabaseSchemaTrait;
+
+ private KernelBrowser $client;
+ private EntityManagerInterface $em;
+
+ protected function setUp(): void
+ {
+ $this->client = self::createClient();
+ $container = self::getContainer();
+ $this->em = $container->get(EntityManagerInterface::class);
+ self::resetSchema($this->em);
+ }
+
+ public function testFrontpageRendersWithoutAssistants(): void
{
- $client = self::createClient();
- $client->request('GET', '/');
+ $crawler = $this->client->request('GET', '/');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'AI Bibliotek');
self::assertSelectorTextContains('h1', 'kommunale');
+ // No CardRail entries persisted yet — the rail wrapper still
+ // renders, but no card links are emitted.
+ self::assertCount(0, $crawler->filter('a[href^="/assistant/"]'));
+ }
+
+ public function testCardRailLinksToPersistedAssistants(): void
+ {
+ $first = new Assistant(
+ title: 'Borgerservice-vejviser',
+ description: 'Hjælper sagsbehandlere.',
+ languageModel: 'gpt-4o',
+ framework: 'openwebui',
+ tags: ['borgerservice'],
+ );
+ $second = new Assistant(
+ title: 'Mødereferent',
+ description: 'Leverer struktureret referat.',
+ languageModel: 'claude-3.5-sonnet',
+ framework: 'openwebui',
+ tags: ['referat'],
+ );
+ $this->em->persist($first);
+ $this->em->persist($second);
+ $this->em->flush();
+
+ $crawler = $this->client->request('GET', '/');
+
+ self::assertResponseIsSuccessful();
+ $cardLinks = $crawler->filter('a[href^="/assistant/"]');
+ self::assertCount(2, $cardLinks);
+
+ $hrefs = $cardLinks->each(static fn ($node) => $node->attr('href'));
+ self::assertContains('/assistant/'.$first->getId(), $hrefs);
+ self::assertContains('/assistant/'.$second->getId(), $hrefs);
+
+ // Newest first (the controller orders by id DESC).
+ self::assertSame('/assistant/'.$second->getId(), $hrefs[0]);
+
+ // Card content surfaces the entity's actual fields.
+ $railText = $crawler->filter('[aria-label="Eksempler på assistenter"]')->text();
+ self::assertStringContainsString('Borgerservice-vejviser', $railText);
+ self::assertStringContainsString('Mødereferent', $railText);
+ self::assertStringContainsString('gpt-4o', $railText);
+ self::assertStringContainsString('claude-3.5-sonnet', $railText);
+ }
+
+ public function testStatsReflectCatalogueCounts(): void
+ {
+ $this->em->persist(new Assistant('A', 'd', 'gpt-4o', 'openwebui'));
+ $this->em->persist(new Assistant('B', 'd', 'gpt-4o', 'openwebui')); // same LM
+ $this->em->persist(new Assistant('C', 'd', 'claude-3.5-sonnet', 'openwebui'));
+ $this->em->flush();
+
+ $crawler = $this->client->request('GET', '/');
+
+ self::assertResponseIsSuccessful();
+ $statsText = $crawler->filter('dl')->text();
+ self::assertStringContainsString('3', $statsText, 'Assistanter count = 3');
+ self::assertStringContainsString('2', $statsText, 'Sprogmodeller count = 2 (gpt-4o + claude)');
}
}
diff --git a/tests/DataFixtures/AssistantFixturesTest.php b/tests/DataFixtures/AssistantFixturesTest.php
new file mode 100644
index 0000000..4d78d02
--- /dev/null
+++ b/tests/DataFixtures/AssistantFixturesTest.php
@@ -0,0 +1,103 @@
+get(EntityManagerInterface::class);
+ self::resetSchema($em);
+
+ $container->get(AssistantFixtures::class)->load($em);
+
+ /** @var AssistantRepository $repository */
+ $repository = $container->get(AssistantRepository::class);
+ $all = $repository->findBy([], ['id' => 'ASC']);
+
+ self::assertCount(20, $all);
+
+ $detailedTitles = array_map(
+ static fn (Assistant $a) => $a->getTitle(),
+ \array_slice($all, 0, 5),
+ );
+ self::assertSame(
+ [
+ 'Borgerservice-vejviser',
+ 'Mødereferent',
+ 'Journaliseringsassistent',
+ 'Skole- og dagtilbudssvar',
+ 'Tilsynsrapport-assistent',
+ ],
+ $detailedTitles,
+ );
+
+ // The 15 generated entries should each carry the kommune in
+ // the title (the detailed five don't), and every row should be
+ // a unique (title, description) combination.
+ $generated = \array_slice($all, 5);
+ self::assertCount(15, $generated);
+ foreach ($generated as $assistant) {
+ self::assertStringContainsString(' – ', $assistant->getTitle(), 'generated titles include the kommune');
+ self::assertStringContainsString('Delt af ', $assistant->getDescription());
+ }
+ $signatures = array_map(
+ static fn (Assistant $a) => $a->getTitle().'|'.$a->getDescription(),
+ $generated,
+ );
+ self::assertSame($signatures, array_unique($signatures), 'every generated entry must be unique');
+
+ // Language model rotation reaches all five values.
+ $models = array_unique(array_map(
+ static fn (Assistant $a) => $a->getLanguageModel(),
+ $generated,
+ ));
+ sort($models);
+ self::assertSame(
+ ['claude-3.5-sonnet', 'gpt-4o', 'gpt-4o-mini', 'llama-3.1-70b', 'mistral-large'],
+ $models,
+ );
+ }
+
+ public function testGenerationIsDeterministic(): void
+ {
+ self::bootKernel();
+ $container = self::getContainer();
+ $em = $container->get(EntityManagerInterface::class);
+ /** @var AssistantRepository $repository */
+ $repository = $container->get(AssistantRepository::class);
+
+ self::resetSchema($em);
+ $container->get(AssistantFixtures::class)->load($em);
+ $firstRun = array_map(
+ static fn (Assistant $a) => $a->getTitle(),
+ $repository->findBy([], ['id' => 'ASC']),
+ );
+
+ // Re-seed from a clean schema. Clear the identity map first or
+ // the second persist sees stale managed entities from run #1
+ // and refuses to assign their freshly-generated IDs.
+ $em->clear();
+ self::resetSchema($em);
+ $container->get(AssistantFixtures::class)->load($em);
+ $secondRun = array_map(
+ static fn (Assistant $a) => $a->getTitle(),
+ $repository->findBy([], ['id' => 'ASC']),
+ );
+
+ self::assertSame($firstRun, $secondRun);
+ }
+}
diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml
index b69723a..d70991d 100644
--- a/translations/messages.da.yaml
+++ b/translations/messages.da.yaml
@@ -42,6 +42,16 @@ security:
password_label: "Adgangskode"
submit: "Log ind"
+assistant:
+ detail:
+ title: "%title% – %brand%"
+ back: "← Tilbage til kataloget"
+ eyebrow: "Assistent"
+ runtime_heading: "Driftsplatform"
+ framework_label: "Framework"
+ language_model_label: "Sprogmodel"
+ tags_heading: "Tags"
+
frontpage:
title: "%brand% – forhåndsvisning"
hero: