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.back'|trans }} + +

+ {{ 'assistant.detail.eyebrow'|trans }} +

+ {{ assistant.title }} +

+

+ {{ assistant.description }} +

+
+ + +
+
+
+ {{ 'assistant.detail.framework_label'|trans }} +
+
{{ assistant.framework }}
+
+
+
+ {{ 'assistant.detail.language_model_label'|trans }} +
+
{{ assistant.languageModel }}
+
+
+
+ + {% if assistant.tags is not empty %} + + + + {% 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