diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b7f22f..e85e7e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,6 +62,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
migration. Organization linkage and the richer #14 scope
(system prompt, parameters, OpenWebUI mapping, versioning) are
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.
+- README refocused as human-facing project documentation: project purpose,
+ tech stack, and local development bootstrap. Developer command reference
+ moved to `CLAUDE.md` (and later `CONTRIBUTING.md`, tracked in #9).
+- ITK Dev Docker setup via the `symfony-8` template (phpfpm 8.4, nginx, MariaDB, Mailpit).
+- Dev dependencies for coding standards and composer normalization:
+ `ergebnis/composer-normalize`, `friendsofphp/php-cs-fixer`, `vincentlanglet/twig-cs-fixer`.
+- Project README with local development instructions.
+- Frontend tooling: Tailwind CSS (via `symfonycasts/tailwind-bundle`),
+ Symfony AssetMapper, and Stimulus (via `symfony/stimulus-bundle`).
+ Decision recorded in [ADR 002](docs/adr/002-frontend-tooling.md).
([#14](https://github.com/itk-dev/ai-lib/issues/14), [#16](https://github.com/itk-dev/ai-lib/issues/16)).
- Base Twig layout (`templates/base.html.twig`) and frontend asset
entrypoints (`assets/app.js`, `assets/styles/app.css`).
diff --git a/CLAUDE.md b/CLAUDE.md
index 9e01351..84fbdcd 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..bdca7d2 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.
+ 'organizations' => 10,
+ 'models' => $this->assistants->countDistinctLanguageModels(),
],
]);
}
diff --git a/src/DataFixtures/AssistantFixtures.php b/src/DataFixtures/AssistantFixtures.php
new file mode 100644
index 0000000..526a2c5
--- /dev/null
+++ b/src/DataFixtures/AssistantFixtures.php
@@ -0,0 +1,161 @@
+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'],
+ ),
+ new Assistant(
+ title: 'Uden kategorier',
+ description: 'Pladsholder uden tags — bruges til at vise hvordan detaljevisningen håndterer en helt umarkeret post.',
+ languageModel: 'gpt-4o',
+ framework: 'openwebui',
+ ),
+ ];
+
+ 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..6920981 100644
--- a/templates/frontpage/index.html.twig
+++ b/templates/frontpage/index.html.twig
@@ -14,7 +14,7 @@
-
+
@@ -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/Integration/Controller/AssistantControllerTest.php b/tests/Integration/Controller/AssistantControllerTest.php
new file mode 100644
index 0000000..73e6539
--- /dev/null
+++ b/tests/Integration/Controller/AssistantControllerTest.php
@@ -0,0 +1,69 @@
+client = self::createClient();
+ }
+
+ public function testRendersAssistantDetail(): void
+ {
+ $repository = self::getContainer()->get(AssistantRepository::class);
+ $assistant = $repository->findOneBy(['title' => 'Borgerservice-vejviser']);
+ self::assertNotNull($assistant, 'fixture baseline must include the Borgerservice-vejviser entry');
+
+ $crawler = $this->client->request('GET', '/assistant/'.$assistant->getId());
+
+ 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('social', $tagsText);
+ self::assertStringContainsString('jura', $tagsText);
+ }
+
+ public function testOmitsTagsSectionWhenAssistantHasNone(): void
+ {
+ $repository = self::getContainer()->get(AssistantRepository::class);
+ $tagless = $repository->findOneBy(['title' => 'Uden kategorier']);
+ self::assertNotNull($tagless, 'fixture baseline must include the tagless edge-case entry');
+
+ $crawler = $this->client->request('GET', '/assistant/'.$tagless->getId());
+
+ 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/Integration/Controller/FrontpageControllerTest.php b/tests/Integration/Controller/FrontpageControllerTest.php
index 39d089a..75dc673 100644
--- a/tests/Integration/Controller/FrontpageControllerTest.php
+++ b/tests/Integration/Controller/FrontpageControllerTest.php
@@ -4,28 +4,63 @@
namespace App\Tests\Integration\Controller;
+use App\Repository\AssistantRepository;
+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.
+ * End-to-end coverage of the frontpage controller and the assistant
+ * rail / stats blocks it renders. Uses the baseline catalogue loaded
+ * by `tests/bootstrap_integration.php` (see `AssistantFixtures`,
+ * 21 entries — 6 detailed and 15 generated).
*/
-class FrontpageControllerTest extends WebTestCase
+final class FrontpageControllerTest extends WebTestCase
{
- /**
- * `GET /` returns 200 and shows the project identifier.
- */
- public function testFrontpageReturns200AndShowsProjectName(): void
+ private KernelBrowser $client;
+
+ protected function setUp(): void
+ {
+ $this->client = self::createClient();
+ }
+
+ public function testCardRailLinksToTheFiveNewestFixtureAssistants(): void
+ {
+ $repository = self::getContainer()->get(AssistantRepository::class);
+ $expected = $repository->findBy([], ['id' => 'DESC'], 5);
+ self::assertCount(5, $expected, 'fixture baseline must seed at least five assistants');
+
+ $crawler = $this->client->request('GET', '/');
+
+ self::assertResponseIsSuccessful();
+
+ $cardLinks = $crawler->filter('a[href^="/assistant/"]');
+ self::assertCount(5, $cardLinks, 'rail surfaces the controller\'s id-DESC limit of 5');
+
+ $hrefs = $cardLinks->each(static fn ($node) => $node->attr('href'));
+ foreach ($expected as $assistant) {
+ self::assertContains('/assistant/'.$assistant->getId(), $hrefs);
+ }
+ self::assertSame(
+ '/assistant/'.$expected[0]->getId(),
+ $hrefs[0],
+ 'newest fixture entry must lead the rail',
+ );
+
+ $railText = $crawler->filter('[aria-label="Eksempler på assistenter"]')->text();
+ self::assertStringContainsString($expected[0]->getTitle(), $railText);
+ self::assertStringContainsString($expected[0]->getLanguageModel(), $railText);
+ }
+
+ public function testStatsReflectFixtureCatalogueCounts(): void
{
- $client = self::createClient();
- $client->request('GET', '/');
+ $crawler = $this->client->request('GET', '/');
self::assertResponseIsSuccessful();
- self::assertSelectorTextContains('body', 'AI Bibliotek');
- self::assertSelectorTextContains('h1', 'kommunale');
+ $statsText = $crawler->filter('dl')->text();
+ // AssistantFixtures seeds 21 rows across 5 distinct language
+ // models (gpt-4o, gpt-4o-mini, claude-3.5-sonnet,
+ // llama-3.1-70b, mistral-large).
+ self::assertStringContainsString('21', $statsText, 'Assistanter count = 21');
+ self::assertStringContainsString('5', $statsText, 'Sprogmodeller count = 5');
}
}
diff --git a/tests/Unit/DataFixtures/AssistantFixturesTest.php b/tests/Unit/DataFixtures/AssistantFixturesTest.php
new file mode 100644
index 0000000..5288edf
--- /dev/null
+++ b/tests/Unit/DataFixtures/AssistantFixturesTest.php
@@ -0,0 +1,92 @@
+captureLoad();
+
+ self::assertCount(21, $persisted);
+
+ $detailedTitles = array_map(
+ static fn (Assistant $a) => $a->getTitle(),
+ \array_slice($persisted, 0, 6),
+ );
+ self::assertSame(
+ [
+ 'Borgerservice-vejviser',
+ 'Mødereferent',
+ 'Journaliseringsassistent',
+ 'Skole- og dagtilbudssvar',
+ 'Tilsynsrapport-assistent',
+ 'Uden kategorier',
+ ],
+ $detailedTitles,
+ );
+ self::assertSame([], $persisted[5]->getTags(), 'tagless detailed entry must carry no tags');
+
+ $generated = \array_slice($persisted, 6);
+ 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');
+
+ $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 testLoadIsDeterministic(): void
+ {
+ $first = array_map(
+ static fn (Assistant $a) => $a->getTitle(),
+ $this->captureLoad(),
+ );
+ $second = array_map(
+ static fn (Assistant $a) => $a->getTitle(),
+ $this->captureLoad(),
+ );
+
+ self::assertSame($first, $second);
+ }
+
+ /**
+ * @return list
+ */
+ private function captureLoad(): array
+ {
+ $captured = [];
+ $manager = $this->createMock(ObjectManager::class);
+ $manager->method('persist')->willReturnCallback(function (object $entity) use (&$captured): void {
+ \assert($entity instanceof Assistant);
+ $captured[] = $entity;
+ });
+ $manager->expects(self::once())->method('flush');
+
+ (new AssistantFixtures())->load($manager);
+
+ return $captured;
+ }
+}
diff --git a/tests/bootstrap_integration.php b/tests/bootstrap_integration.php
index 6a7e282..3ea29e3 100644
--- a/tests/bootstrap_integration.php
+++ b/tests/bootstrap_integration.php
@@ -1,5 +1,6 @@
createSchema($em->getMetadataFactory()->getAllMetadata());
$container->get(UserFixtures::class)->load($em);
+$container->get(AssistantFixtures::class)->load($em);
$kernel->shutdown();
diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml
index b69723a..f71b193 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:
@@ -50,7 +60,7 @@ frontpage:
lead: "Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt."
stats:
assistants: "Assistenter"
- kommuner: "Kommuner"
+ organizations: "Kommuner"
models: "Sprogmodeller"
rail:
eyebrow: "Forhåndsvisning"