Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/Controller/AssistantController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Assistant;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class AssistantController extends AbstractController
{
#[Route(path: '/assistant/{id}', name: 'app_assistant_show', requirements: ['id' => '\d+'], methods: ['GET'])]
public function show(Assistant $assistant): Response
{
return $this->render('assistant/show.html.twig', [
'assistant' => $assistant,
]);
}
}
72 changes: 13 additions & 59 deletions src/Controller/FrontpageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
],
]);
}
Expand Down
153 changes: 153 additions & 0 deletions src/DataFixtures/AssistantFixtures.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\Assistant;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

/**
* Seed twenty assistants for local development.
*
* Five are hand-written, authentic catalogue entries drawn from the
* AI Bibliotek prototype. The remaining fifteen are generated
* deterministically from a fixed set of topics, kommuner and
* language models — same input on every run, no randomness — so
* test assertions and design previews stay reproducible.
*/
final class AssistantFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$this->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'],
));
}
}
}
12 changes: 12 additions & 0 deletions src/Repository/AssistantRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
52 changes: 52 additions & 0 deletions templates/assistant/show.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% extends 'base.html.twig' %}

{% block title %}{{ 'assistant.detail.title'|trans({'%title%': assistant.title, '%brand%': brand_name}) }}{% endblock %}

{% block body %}
<article class="view-root grid gap-12">
<header class="max-w-3xl">
<p class="mb-4">
<a class="text-sm font-medium text-text-muted no-underline hover:text-primary"
href="{{ path('app_frontpage') }}">
{{ 'assistant.detail.back'|trans }}
</a>
</p>
<twig:Eyebrow as="p" class="mb-4">{{ 'assistant.detail.eyebrow'|trans }}</twig:Eyebrow>
<h1 class="mb-4 font-display text-[clamp(2rem,3vw,2.6rem)] font-medium leading-tight tracking-tight text-ink">
{{ assistant.title }}
</h1>
<p class="max-w-[60ch] text-lg leading-relaxed text-text">
{{ assistant.description }}
</p>
</header>

<twig:Box eyebrow="assistant.detail.runtime_heading">
<dl class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<dt class="text-xs uppercase tracking-widest text-text-muted">
{{ 'assistant.detail.framework_label'|trans }}
</dt>
<dd class="font-display text-lg font-semibold text-ink">{{ assistant.framework }}</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-xs uppercase tracking-widest text-text-muted">
{{ 'assistant.detail.language_model_label'|trans }}
</dt>
<dd class="font-display text-lg font-semibold text-ink">{{ assistant.languageModel }}</dd>
</div>
</dl>
</twig:Box>

{% if assistant.tags is not empty %}
<twig:Box eyebrow="assistant.detail.tags_heading">
<ul class="flex flex-wrap gap-2" aria-label="{{ 'assistant.detail.tags_heading'|trans }}">
{% for tag in assistant.tags %}
<li class="inline-flex items-center rounded-full border border-line bg-surface-2 px-3 py-1 text-sm text-text">
{{ tag }}
</li>
{% endfor %}
</ul>
</twig:Box>
{% endif %}
</article>
{% endblock %}
16 changes: 11 additions & 5 deletions templates/frontpage/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@
<twig:SearchBox placeholder="search.placeholder" />

<twig:CardRail:Container eyebrow="frontpage.rail.eyebrow" linkLabel="frontpage.rail.link">
{% 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 %}
<twig:CardRail:Card
kommune="{{ a.kommune }}"
model="{{ a.model }}"
name="{{ a.name }}"
summary="{{ a.summary }}" />
kommune="{{ assistant.framework }}"
model="{{ assistant.languageModel }}"
name="{{ assistant.title }}"
summary="{{ assistant.description }}"
href="{{ path('app_assistant_show', {id: assistant.id}) }}" />
{% endfor %}
</twig:CardRail:Container>

Expand Down
Loading
Loading