diff --git a/CHANGELOG.md b/CHANGELOG.md index 524b90c..7b0dff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/migrations/Version20260612120840.php b/migrations/Version20260612120840.php new file mode 100644 index 0000000..aefeaef --- /dev/null +++ b/migrations/Version20260612120840.php @@ -0,0 +1,29 @@ +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'); + } +} diff --git a/src/Entity/Assistant.php b/src/Entity/Assistant.php new file mode 100644 index 0000000..4322212 --- /dev/null +++ b/src/Entity/Assistant.php @@ -0,0 +1,134 @@ + + */ + #[ORM\Column(type: Types::JSON)] + private array $tags = []; + + /** + * @param list $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 + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * @param list $tags + */ + public function setTags(array $tags): static + { + $this->tags = array_values($tags); + + return $this; + } +} diff --git a/src/Repository/AssistantRepository.php b/src/Repository/AssistantRepository.php new file mode 100644 index 0000000..647939e --- /dev/null +++ b/src/Repository/AssistantRepository.php @@ -0,0 +1,20 @@ + + */ +class AssistantRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Assistant::class); + } +} diff --git a/tests/Entity/AssistantTest.php b/tests/Entity/AssistantTest.php new file mode 100644 index 0000000..af48d2d --- /dev/null +++ b/tests/Entity/AssistantTest.php @@ -0,0 +1,98 @@ +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 + // — `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()); + } +}