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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
console commands `app:user:create` and `app:user:change-password`,
and end-to-end functional + unit tests
([#2](https://github.com/itk-dev/ai-lib/issues/2)).
- `Assistant` Doctrine entity with base fields (title, description,
language model, framework, tags as a JSON list), repository, and
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)).

### Changed

Expand Down
29 changes: 29 additions & 0 deletions migrations/Version20260612120840.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Initial `assistant` table for the catalogue (#14).
*/
final class Version20260612120840 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the assistant table with base fields (title, description, languageModel, framework, tags).';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE assistant (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, description LONGTEXT NOT NULL, language_model VARCHAR(255) NOT NULL, framework VARCHAR(255) NOT NULL, tags JSON NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}

public function down(Schema $schema): void
{
$this->addSql('DROP TABLE assistant');
}
}
134 changes: 134 additions & 0 deletions src/Entity/Assistant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\AssistantRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: AssistantRepository::class)]
#[ORM\Table(name: 'assistant')]
class Assistant
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private string $title;

#[ORM\Column(type: Types::TEXT)]
private string $description;

/**
* Captured at creation time from the creator's organisation (see
* ADR 005); stored on the assistant so an organisation switching
* its default later does not silently rewrite older catalogue
* rows.
*/
#[ORM\Column(length: 255)]
private string $languageModel;

/**
* @see self::$languageModel for the snapshot rationale
*/
#[ORM\Column(length: 255)]
private string $framework;

/**
* @var list<string>
*/
#[ORM\Column(type: Types::JSON)]
private array $tags = [];

/**
* @param list<string> $tags
*/
public function __construct(
string $title,
string $description,
string $languageModel,
string $framework,
array $tags = [],
) {
$this->title = $title;
$this->description = $description;
$this->languageModel = $languageModel;
$this->framework = $framework;
$this->tags = array_values($tags);
}

public function getId(): ?int
{
return $this->id;
}

public function getTitle(): string
{
return $this->title;
}

public function setTitle(string $title): static
{
$this->title = $title;

return $this;
}

public function getDescription(): string
{
return $this->description;
}

public function setDescription(string $description): static
{
$this->description = $description;

return $this;
}

public function getLanguageModel(): string
{
return $this->languageModel;
}

public function setLanguageModel(string $languageModel): static
{
$this->languageModel = $languageModel;

return $this;
}

public function getFramework(): string
{
return $this->framework;
}

public function setFramework(string $framework): static
{
$this->framework = $framework;

return $this;
}

/**
* @return list<string>
*/
public function getTags(): array
{
return $this->tags;
}

/**
* @param list<string> $tags
*/
public function setTags(array $tags): static
{
$this->tags = array_values($tags);

return $this;
}
}
20 changes: 20 additions & 0 deletions src/Repository/AssistantRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Assistant;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
* @extends ServiceEntityRepository<Assistant>
*/
class AssistantRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Assistant::class);
}
}
98 changes: 98 additions & 0 deletions tests/Entity/AssistantTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace App\Tests\Entity;

use App\Entity\Assistant;
use App\Repository\AssistantRepository;
use App\Tests\Support\ResetsDatabaseSchemaTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

/**
* Round-trips an Assistant through the entity manager so every getter
* and setter sees real persistence + hydration rather than in-memory
* object-graph wiring only.
*/
final class AssistantTest extends KernelTestCase
{
use ResetsDatabaseSchemaTrait;

private EntityManagerInterface $em;
private AssistantRepository $repository;

protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
self::resetSchema($container->get(EntityManagerInterface::class));

$this->em = $container->get(EntityManagerInterface::class);
$this->repository = $container->get(AssistantRepository::class);
}

public function testPersistsAndHydratesBaseFields(): void
{
$assistant = new Assistant(
title: 'Borgerservice-vejviser',
description: 'Hjælper sagsbehandlere med at finde den rigtige paragraf.',
languageModel: 'gpt-4o',
framework: 'openwebui',
tags: ['borgerservice', 'paragraf'],
);

$this->em->persist($assistant);
$this->em->flush();
$id = $assistant->getId();
self::assertNotNull($id);

$this->em->clear();
$reloaded = $this->repository->find($id);

self::assertInstanceOf(Assistant::class, $reloaded);
self::assertSame('Borgerservice-vejviser', $reloaded->getTitle());
self::assertSame('Hjælper sagsbehandlere med at finde den rigtige paragraf.', $reloaded->getDescription());
self::assertSame('gpt-4o', $reloaded->getLanguageModel());
self::assertSame('openwebui', $reloaded->getFramework());
self::assertSame(['borgerservice', 'paragraf'], $reloaded->getTags());
}

public function testSettersUpdatePersistedValues(): void
{
$assistant = new Assistant('original-title', 'original-desc', 'gpt-4o', 'openwebui');
$this->em->persist($assistant);
$this->em->flush();
$id = $assistant->getId();
self::assertNotNull($id);

$assistant
->setTitle('new-title')
->setDescription('new-desc')
->setLanguageModel('claude-3.5-sonnet')
->setFramework('custom-runtime')
->setTags(['x', 'y']);
$this->em->flush();
$this->em->clear();

$reloaded = $this->repository->find($id);
self::assertInstanceOf(Assistant::class, $reloaded);
self::assertSame('new-title', $reloaded->getTitle());
self::assertSame('new-desc', $reloaded->getDescription());
self::assertSame('claude-3.5-sonnet', $reloaded->getLanguageModel());
self::assertSame('custom-runtime', $reloaded->getFramework());
self::assertSame(['x', 'y'], $reloaded->getTags());
}

public function testTagsDefaultToEmptyListAndAreReindexedOnSet(): void
{
$assistant = new Assistant('t', 'd', 'm', 'f');
self::assertSame([], $assistant->getTags());

// Setting with non-sequential keys must produce a clean list<string>
// — `array_values()` in setTags() guarantees JSON serialises as an
// array, not an object.
$assistant->setTags([3 => 'alpha', 7 => 'beta']);
self::assertSame(['alpha', 'beta'], $assistant->getTags());
}
}
Loading