From 6398bb24ea4b674d713fc12e02104348991fc08f Mon Sep 17 00:00:00 2001 From: TomaszB Date: Thu, 29 Jan 2026 19:56:26 +0100 Subject: [PATCH 1/5] Add StoryAI core functionality with LARP-centric services and AI-driven features: - Introduced `AIAssistantController` with endpoints for AI queries, suggestions, and analyses (query, search, story arc suggestions, relationship suggestions, connections, and plot consistency). - Added DTO classes (`AIQueryResult`, `ChatMessage`) to structure AI interactions and results. - Implemented `EmbeddingService` and `ContextBuilder` to handle vector embeddings, chunked lore contexts, and AI-ready prompts. - Created `EmbeddingProviderInterface` for abstraction and compatibility with embedding engines. - Added `IndexLoreDocumentHandler` and `IndexLoreDocumentMessage` for async lore document indexing. - Registered PSR-17 factories via `http_discovery.yaml` for API operations. - Enhanced scalability via chunked lore document processing and batch embedding generation. --- .env | 13 +- composer.json | 1 + composer.lock | 228 ++++++++- config/packages/http_discovery.yaml | 10 + config/packages/messenger.yaml | 6 +- config/services.yaml | 20 +- migrations/Version20260129120000.php | 125 +++++ .../StoryAI/Command/ReindexStoryAICommand.php | 165 +++++++ .../Controller/API/AIAssistantController.php | 289 +++++++++++ src/Domain/StoryAI/DTO/AIQueryResult.php | 46 ++ src/Domain/StoryAI/DTO/ChatMessage.php | 49 ++ src/Domain/StoryAI/DTO/SearchResult.php | 59 +++ .../StoryAI/Entity/Enum/LoreDocumentType.php | 54 ++ .../StoryAI/Entity/LarpLoreDocument.php | 250 ++++++++++ .../StoryAI/Entity/LoreDocumentChunk.php | 198 ++++++++ .../StoryAI/Entity/StoryObjectEmbedding.php | 180 +++++++ .../StoryObjectIndexSubscriber.php | 100 ++++ .../Message/IndexLoreDocumentMessage.php | 18 + .../Message/IndexStoryObjectMessage.php | 18 + .../StoryAI/Message/ReindexLarpMessage.php | 18 + .../IndexLoreDocumentHandler.php | 53 ++ .../IndexStoryObjectHandler.php | 53 ++ .../MessageHandler/ReindexLarpHandler.php | 51 ++ .../Repository/LarpLoreDocumentRepository.php | 111 +++++ .../LoreDocumentChunkRepository.php | 121 +++++ .../StoryObjectEmbeddingRepository.php | 140 ++++++ .../Service/Embedding/EmbeddingService.php | 319 ++++++++++++ .../Embedding/StoryObjectSerializer.php | 461 ++++++++++++++++++ .../Provider/EmbeddingProviderInterface.php | 46 ++ .../Service/Provider/LLMProviderInterface.php | 48 ++ .../Service/Provider/OpenAIProvider.php | 196 ++++++++ .../StoryAI/Service/Query/ContextBuilder.php | 144 ++++++ .../StoryAI/Service/Query/RAGQueryService.php | 211 ++++++++ .../Service/Query/VectorSearchService.php | 180 +++++++ symfony.lock | 12 + 35 files changed, 3988 insertions(+), 5 deletions(-) create mode 100644 config/packages/http_discovery.yaml create mode 100644 migrations/Version20260129120000.php create mode 100644 src/Domain/StoryAI/Command/ReindexStoryAICommand.php create mode 100644 src/Domain/StoryAI/Controller/API/AIAssistantController.php create mode 100644 src/Domain/StoryAI/DTO/AIQueryResult.php create mode 100644 src/Domain/StoryAI/DTO/ChatMessage.php create mode 100644 src/Domain/StoryAI/DTO/SearchResult.php create mode 100644 src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php create mode 100644 src/Domain/StoryAI/Entity/LarpLoreDocument.php create mode 100644 src/Domain/StoryAI/Entity/LoreDocumentChunk.php create mode 100644 src/Domain/StoryAI/Entity/StoryObjectEmbedding.php create mode 100644 src/Domain/StoryAI/EventSubscriber/StoryObjectIndexSubscriber.php create mode 100644 src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php create mode 100644 src/Domain/StoryAI/Message/IndexStoryObjectMessage.php create mode 100644 src/Domain/StoryAI/Message/ReindexLarpMessage.php create mode 100644 src/Domain/StoryAI/MessageHandler/IndexLoreDocumentHandler.php create mode 100644 src/Domain/StoryAI/MessageHandler/IndexStoryObjectHandler.php create mode 100644 src/Domain/StoryAI/MessageHandler/ReindexLarpHandler.php create mode 100644 src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php create mode 100644 src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php create mode 100644 src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php create mode 100644 src/Domain/StoryAI/Service/Embedding/EmbeddingService.php create mode 100644 src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php create mode 100644 src/Domain/StoryAI/Service/Provider/EmbeddingProviderInterface.php create mode 100644 src/Domain/StoryAI/Service/Provider/LLMProviderInterface.php create mode 100644 src/Domain/StoryAI/Service/Provider/OpenAIProvider.php create mode 100644 src/Domain/StoryAI/Service/Query/ContextBuilder.php create mode 100644 src/Domain/StoryAI/Service/Query/RAGQueryService.php create mode 100644 src/Domain/StoryAI/Service/Query/VectorSearchService.php diff --git a/.env b/.env index 20fbe70..d01e037 100755 --- a/.env +++ b/.env @@ -63,4 +63,15 @@ GITHUB_REPO=tbuczen/LARPilot # GitHub Discussion Category ID for questions/general feedback # Get via GraphQL: https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions GITHUB_DISCUSSION_CATEGORY_ID=your-category-id-here -###< GitHub Integration \ No newline at end of file +###< GitHub Integration + +###> OpenAI Integration (StoryAI) +# OpenAI API Key for RAG-based story assistance +# Get your key at: https://platform.openai.com/api-keys +OPENAI_API_KEY=your-openai-api-key-here +# Models (optional, defaults shown) +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +OPENAI_COMPLETION_MODEL=gpt-4o-mini +# Enable auto-indexing of story objects on save (false by default to save costs) +STORY_AI_AUTO_INDEX=false +###< OpenAI Integration (StoryAI) \ No newline at end of file diff --git a/composer.json b/composer.json index 7d7c99f..8b58a43 100755 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "league/oauth2-google": ">=4.0.1", "moneyphp/money": "^4.8", "nikic/php-parser": "^5.6.2", + "openai-php/client": "^0.18.0", "phpdocumentor/reflection-docblock": "^5.6.3", "phpstan/phpdoc-parser": "^2.3", "scienta/doctrine-json-functions": "^6.3", diff --git a/composer.lock b/composer.lock index 848384e..d2b3fe1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "400f03c25f07ae07d462bff66b7f0094", + "content-hash": "d559eeb47219891fc5d092e04c788694", "packages": [ { "name": "composer/semver", @@ -2790,6 +2790,97 @@ }, "time": "2025-10-21T19:32:17+00:00" }, + { + "name": "openai-php/client", + "version": "v0.18.0", + "source": { + "type": "git", + "url": "https://github.com/openai-php/client.git", + "reference": "3362ab004fcfc9d77df3aff7671fbcbe70177cae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openai-php/client/zipball/3362ab004fcfc9d77df3aff7671fbcbe70177cae", + "reference": "3362ab004fcfc9d77df3aff7671fbcbe70177cae", + "shasum": "" + }, + "require": { + "php": "^8.2.0", + "php-http/discovery": "^1.20.0", + "php-http/multipart-stream-builder": "^1.4.2", + "psr/http-client": "^1.0.3", + "psr/http-client-implementation": "^1.0.1", + "psr/http-factory-implementation": "*", + "psr/http-message": "^1.1.0|^2.0.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.9.3", + "guzzlehttp/psr7": "^2.7.1", + "laravel/pint": "^1.24.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/collision": "^8.8.0", + "pestphp/pest": "^3.8.2|^4.0.0", + "pestphp/pest-plugin-arch": "^3.1.1|^4.0.0", + "pestphp/pest-plugin-type-coverage": "^3.5.1|^4.0.0", + "phpstan/phpstan": "^1.12.25", + "symfony/var-dumper": "^7.2.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/OpenAI.php" + ], + "psr-4": { + "OpenAI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri" + } + ], + "description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API", + "keywords": [ + "GPT-3", + "api", + "client", + "codex", + "dall-e", + "language", + "natural", + "openai", + "php", + "processing", + "sdk" + ], + "support": { + "issues": "https://github.com/openai-php/client/issues", + "source": "https://github.com/openai-php/client/tree/v0.18.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-10-31T18:58:57+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -2909,6 +3000,141 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/multipart-stream-builder", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/multipart-stream-builder.git", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/10086e6de6f53489cca5ecc45b6f468604d3460e", + "reference": "10086e6de6f53489cca5ecc45b6f468604d3460e", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/discovery": "^1.15", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.5", + "php-http/message-factory": "^1.0.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Message\\MultipartStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "A builder class that help you create a multipart stream", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "multipart stream", + "stream" + ], + "support": { + "issues": "https://github.com/php-http/multipart-stream-builder/issues", + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.4.2" + }, + "time": "2024-09-04T13:22:54+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", diff --git a/config/packages/http_discovery.yaml b/config/packages/http_discovery.yaml new file mode 100644 index 0000000..2a789e7 --- /dev/null +++ b/config/packages/http_discovery.yaml @@ -0,0 +1,10 @@ +services: + Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory' + + http_discovery.psr17_factory: + class: Http\Discovery\Psr17Factory diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 270f3c7..bee14fd 100755 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -25,5 +25,7 @@ framework: Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async - # Route your messages to the transports - # 'App\Message\YourMessage': async + # StoryAI embedding messages + App\Domain\StoryAI\Message\IndexStoryObjectMessage: async + App\Domain\StoryAI\Message\ReindexLarpMessage: async + App\Domain\StoryAI\Message\IndexLoreDocumentMessage: async diff --git a/config/services.yaml b/config/services.yaml index 62005e8..c8cdc1c 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,6 +8,10 @@ parameters: github.token: '%env(GITHUB_TOKEN)%' github.repo: '%env(GITHUB_REPO)%' github.discussion_category_id: '%env(GITHUB_DISCUSSION_CATEGORY_ID)%' + # StoryAI configuration + openai.api_key: '%env(OPENAI_API_KEY)%' + openai.embedding_model: '%env(OPENAI_EMBEDDING_MODEL)%' + openai.completion_model: '%env(OPENAI_COMPLETION_MODEL)%' services: _defaults: autowire: true # Automatically injects dependencies in your services. @@ -54,4 +58,18 @@ services: arguments: $integrationServices: !tagged_iterator app.integration - ShipMonk\DoctrineEntityPreloader\EntityPreloader: ~ \ No newline at end of file + ShipMonk\DoctrineEntityPreloader\EntityPreloader: ~ + + # StoryAI OpenAI Provider + App\Domain\StoryAI\Service\Provider\OpenAIProvider: + arguments: + $apiKey: '%openai.api_key%' + $embeddingModel: '%openai.embedding_model%' + $completionModel: '%openai.completion_model%' + + # Register OpenAIProvider for both interfaces + App\Domain\StoryAI\Service\Provider\EmbeddingProviderInterface: + alias: App\Domain\StoryAI\Service\Provider\OpenAIProvider + + App\Domain\StoryAI\Service\Provider\LLMProviderInterface: + alias: App\Domain\StoryAI\Service\Provider\OpenAIProvider \ No newline at end of file diff --git a/migrations/Version20260129120000.php b/migrations/Version20260129120000.php new file mode 100644 index 0000000..b8a5369 --- /dev/null +++ b/migrations/Version20260129120000.php @@ -0,0 +1,125 @@ +addSql('CREATE EXTENSION IF NOT EXISTS vector'); + + // Create story_object_embedding table + $this->addSql(' + CREATE TABLE story_object_embedding ( + id UUID NOT NULL, + larp_id UUID NOT NULL, + story_object_id UUID NOT NULL, + serialized_content TEXT NOT NULL, + content_hash VARCHAR(64) NOT NULL, + embedding vector(1536) NOT NULL, + embedding_model VARCHAR(100) NOT NULL, + dimensions INT NOT NULL, + token_count INT DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY(id) + ) + '); + + $this->addSql('CREATE INDEX idx_embedding_larp ON story_object_embedding (larp_id)'); + $this->addSql('CREATE INDEX idx_embedding_story_object ON story_object_embedding (story_object_id)'); + $this->addSql('CREATE INDEX idx_embedding_content_hash ON story_object_embedding (content_hash)'); + + // Create HNSW index for fast approximate nearest neighbor search + $this->addSql('CREATE INDEX idx_embedding_vector ON story_object_embedding USING hnsw (embedding vector_cosine_ops)'); + + $this->addSql('ALTER TABLE story_object_embedding ADD CONSTRAINT FK_embedding_larp FOREIGN KEY (larp_id) REFERENCES larp (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE story_object_embedding ADD CONSTRAINT FK_embedding_story_object FOREIGN KEY (story_object_id) REFERENCES story_object (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Create larp_lore_document table + $this->addSql(' + CREATE TABLE larp_lore_document ( + id UUID NOT NULL, + larp_id UUID NOT NULL, + created_by_id UUID DEFAULT NULL, + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT NULL, + type VARCHAR(50) NOT NULL, + content TEXT NOT NULL, + priority INT NOT NULL, + always_include BOOLEAN NOT NULL, + active BOOLEAN NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY(id) + ) + '); + + $this->addSql('CREATE INDEX idx_lore_doc_larp ON larp_lore_document (larp_id)'); + $this->addSql('CREATE INDEX idx_lore_doc_priority ON larp_lore_document (priority)'); + $this->addSql('CREATE INDEX idx_lore_doc_type ON larp_lore_document (type)'); + + $this->addSql('ALTER TABLE larp_lore_document ADD CONSTRAINT FK_lore_doc_larp FOREIGN KEY (larp_id) REFERENCES larp (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE larp_lore_document ADD CONSTRAINT FK_lore_doc_created_by FOREIGN KEY (created_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + + // Create lore_document_chunk table + $this->addSql(' + CREATE TABLE lore_document_chunk ( + id UUID NOT NULL, + document_id UUID NOT NULL, + larp_id UUID NOT NULL, + content TEXT NOT NULL, + chunk_index INT NOT NULL, + content_hash VARCHAR(64) NOT NULL, + embedding vector(1536) NOT NULL, + embedding_model VARCHAR(100) NOT NULL, + dimensions INT NOT NULL, + token_count INT DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY(id) + ) + '); + + $this->addSql('CREATE INDEX idx_chunk_document ON lore_document_chunk (document_id)'); + $this->addSql('CREATE INDEX idx_chunk_larp ON lore_document_chunk (larp_id)'); + $this->addSql('CREATE INDEX idx_chunk_index ON lore_document_chunk (chunk_index)'); + + // Create HNSW index for fast approximate nearest neighbor search on chunks + $this->addSql('CREATE INDEX idx_chunk_vector ON lore_document_chunk USING hnsw (embedding vector_cosine_ops)'); + + $this->addSql('ALTER TABLE lore_document_chunk ADD CONSTRAINT FK_chunk_document FOREIGN KEY (document_id) REFERENCES larp_lore_document (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE lore_document_chunk ADD CONSTRAINT FK_chunk_larp FOREIGN KEY (larp_id) REFERENCES larp (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE lore_document_chunk DROP CONSTRAINT FK_chunk_document'); + $this->addSql('ALTER TABLE lore_document_chunk DROP CONSTRAINT FK_chunk_larp'); + $this->addSql('DROP TABLE lore_document_chunk'); + + $this->addSql('ALTER TABLE larp_lore_document DROP CONSTRAINT FK_lore_doc_larp'); + $this->addSql('ALTER TABLE larp_lore_document DROP CONSTRAINT FK_lore_doc_created_by'); + $this->addSql('DROP TABLE larp_lore_document'); + + $this->addSql('ALTER TABLE story_object_embedding DROP CONSTRAINT FK_embedding_larp'); + $this->addSql('ALTER TABLE story_object_embedding DROP CONSTRAINT FK_embedding_story_object'); + $this->addSql('DROP TABLE story_object_embedding'); + + // Note: We don't drop the pgvector extension as it might be used by other tables + } +} diff --git a/src/Domain/StoryAI/Command/ReindexStoryAICommand.php b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php new file mode 100644 index 0000000..def8671 --- /dev/null +++ b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php @@ -0,0 +1,165 @@ +addArgument('larp-id', InputArgument::REQUIRED, 'The LARP ID to reindex (UUID)') + ->addOption('async', 'a', InputOption::VALUE_NONE, 'Process indexing asynchronously via messenger') + ->addOption('include-lore', 'l', InputOption::VALUE_NONE, 'Also reindex lore documents') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force reindex even if content unchanged'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $larpIdString = $input->getArgument('larp-id'); + + try { + $larpId = Uuid::fromString($larpIdString); + } catch (\InvalidArgumentException $e) { + $io->error(sprintf('Invalid UUID format: %s', $larpIdString)); + return Command::FAILURE; + } + + $larp = $this->entityManager->getRepository(Larp::class)->find($larpId); + + if (!$larp) { + $io->error(sprintf('LARP not found: %s', $larpIdString)); + return Command::FAILURE; + } + + $io->title(sprintf('Reindexing LARP: %s', $larp->getTitle())); + + // Show current stats + $existingCount = $this->embeddingRepository->countByLarp($larp); + $loreDocCount = $this->loreDocumentRepository->countActiveByLarp($larp); + + $io->info([ + sprintf('Existing embeddings: %d', $existingCount), + sprintf('Lore documents: %d', $loreDocCount), + ]); + + if ($input->getOption('async')) { + return $this->processAsync($io, $larp, $input->getOption('include-lore')); + } + + return $this->processSync($io, $larp, $input->getOption('include-lore'), $input->getOption('force')); + } + + private function processAsync(SymfonyStyle $io, Larp $larp, bool $includeLore): int + { + $io->section('Dispatching async reindex message'); + + $this->messageBus->dispatch(new ReindexLarpMessage($larp->getId())); + $io->success('Reindex message dispatched. Run messenger:consume to process.'); + + if ($includeLore) { + $io->note('Lore documents will need to be reindexed separately in async mode.'); + } + + return Command::SUCCESS; + } + + private function processSync(SymfonyStyle $io, Larp $larp, bool $includeLore, bool $force): int + { + $io->section('Indexing story objects'); + + $progressBar = $io->createProgressBar(); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%'); + + $stats = $this->embeddingService->reindexLarp( + $larp, + function (int $current, int $total, $storyObject) use ($progressBar) { + $progressBar->setMaxSteps($total); + $progressBar->setProgress($current); + $progressBar->setMessage($storyObject->getTitle()); + } + ); + + $progressBar->finish(); + $io->newLine(2); + + $io->success([ + sprintf('Indexed: %d', $stats['indexed']), + sprintf('Skipped (unchanged): %d', $stats['skipped']), + sprintf('Errors: %d', $stats['errors']), + ]); + + if ($includeLore) { + $io->section('Indexing lore documents'); + + $documents = $this->loreDocumentRepository->findActiveByLarp($larp); + + if (empty($documents)) { + $io->note('No active lore documents to index.'); + } else { + $progressBar = $io->createProgressBar(count($documents)); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%'); + + $loreStats = ['indexed' => 0, 'errors' => 0]; + + foreach ($documents as $document) { + $progressBar->setMessage($document->getTitle()); + + try { + $this->embeddingService->indexLoreDocument($document); + $loreStats['indexed']++; + } catch (\Throwable $e) { + $loreStats['errors']++; + $io->warning(sprintf('Error indexing "%s": %s', $document->getTitle(), $e->getMessage())); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $io->newLine(2); + + $io->success([ + sprintf('Lore documents indexed: %d', $loreStats['indexed']), + sprintf('Errors: %d', $loreStats['errors']), + ]); + } + } + + return $stats['errors'] > 0 ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/src/Domain/StoryAI/Controller/API/AIAssistantController.php b/src/Domain/StoryAI/Controller/API/AIAssistantController.php new file mode 100644 index 0000000..f974491 --- /dev/null +++ b/src/Domain/StoryAI/Controller/API/AIAssistantController.php @@ -0,0 +1,289 @@ +getContent(), true); + + if (empty($data['query'])) { + return $this->json([ + 'error' => 'Query is required', + ], Response::HTTP_BAD_REQUEST); + } + + $query = trim($data['query']); + + // Parse conversation history if provided + $conversationHistory = []; + if (isset($data['history']) && is_array($data['history'])) { + foreach ($data['history'] as $message) { + if (isset($message['role'], $message['content'])) { + $conversationHistory[] = new ChatMessage( + $message['role'], + $message['content'] + ); + } + } + } + + try { + $result = $this->ragQueryService->query( + $query, + $larp, + $conversationHistory, + maxSources: (int) ($data['maxSources'] ?? 10), + minSimilarity: (float) ($data['minSimilarity'] ?? 0.4), + ); + + return $this->json([ + 'response' => $result->response, + 'sources' => array_map(fn ($s) => [ + 'title' => $s->title, + 'type' => $s->entityType ?? 'Lore', + 'similarity' => $s->getSimilarityPercent(), + 'preview' => $s->getContentPreview(150), + 'entityId' => $s->entityId, + ], $result->sources), + 'usage' => $result->usage, + 'model' => $result->model, + 'processingTime' => round($result->processingTime, 2), + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Failed to process query', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Search for story content without AI completion. + */ + #[Route('/search', name: 'search', methods: ['POST'])] + #[IsGranted('LARP_VIEW', subject: 'larp')] + public function search(Request $request, Larp $larp): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (empty($data['query'])) { + return $this->json([ + 'error' => 'Query is required', + ], Response::HTTP_BAD_REQUEST); + } + + $query = trim($data['query']); + + try { + $results = $this->ragQueryService->search( + $query, + $larp, + limit: (int) ($data['limit'] ?? 10), + minSimilarity: (float) ($data['minSimilarity'] ?? 0.4), + ); + + return $this->json([ + 'results' => array_map(fn ($r) => [ + 'id' => $r->id, + 'title' => $r->title, + 'type' => $r->type, + 'entityType' => $r->entityType, + 'entityId' => $r->entityId, + 'similarity' => $r->getSimilarityPercent(), + 'preview' => $r->getContentPreview(200), + 'metadata' => $r->metadata, + ], $results), + 'count' => count($results), + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Search failed', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get story arc suggestions for a character. + */ + #[Route('/suggest/story-arc', name: 'suggest_story_arc', methods: ['POST'])] + #[IsGranted('LARP_VIEW', subject: 'larp')] + public function suggestStoryArc(Request $request, Larp $larp): JsonResponse + { + $data = json_decode($request->getContent(), true); + + $elementType = $data['elementType'] ?? 'character'; + $elementTitle = $data['elementTitle'] ?? ''; + $elementContext = $data['elementContext'] ?? ''; + + if (empty($elementTitle)) { + return $this->json([ + 'error' => 'Element title is required', + ], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->ragQueryService->suggestStoryArc( + $elementType, + $elementTitle, + $elementContext, + $larp + ); + + return $this->json([ + 'suggestion' => $result->response, + 'sources' => array_map(fn ($s) => [ + 'title' => $s->title, + 'type' => $s->entityType ?? 'Lore', + ], $result->sources), + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Failed to generate suggestion', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get relationship suggestions for a character. + */ + #[Route('/suggest/relationships', name: 'suggest_relationships', methods: ['POST'])] + #[IsGranted('LARP_VIEW', subject: 'larp')] + public function suggestRelationships(Request $request, Larp $larp): JsonResponse + { + $data = json_decode($request->getContent(), true); + + $characterName = $data['characterName'] ?? ''; + $characterContext = $data['characterContext'] ?? ''; + + if (empty($characterName)) { + return $this->json([ + 'error' => 'Character name is required', + ], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->ragQueryService->suggestRelationships( + $characterName, + $characterContext, + $larp + ); + + return $this->json([ + 'suggestion' => $result->response, + 'sources' => array_map(fn ($s) => [ + 'title' => $s->title, + 'type' => $s->entityType ?? 'Lore', + ], $result->sources), + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Failed to generate suggestion', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Find connections between multiple story elements. + */ + #[Route('/find-connections', name: 'find_connections', methods: ['POST'])] + #[IsGranted('LARP_VIEW', subject: 'larp')] + public function findConnections(Request $request, Larp $larp): JsonResponse + { + $data = json_decode($request->getContent(), true); + + $elementNames = $data['elementNames'] ?? []; + + if (empty($elementNames) || count($elementNames) < 2) { + return $this->json([ + 'error' => 'At least two element names are required', + ], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->ragQueryService->findConnections( + $elementNames, + $larp + ); + + return $this->json([ + 'analysis' => $result->response, + 'sources' => array_map(fn ($s) => [ + 'title' => $s->title, + 'type' => $s->entityType ?? 'Lore', + ], $result->sources), + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Failed to analyze connections', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Analyze plot consistency. + */ + #[Route('/analyze/consistency', name: 'analyze_consistency', methods: ['POST'])] + #[IsGranted('LARP_VIEW', subject: 'larp')] + public function analyzeConsistency(Request $request, Larp $larp): JsonResponse + { + $data = json_decode($request->getContent(), true); + + $plotElement = $data['plotElement'] ?? ''; + + if (empty($plotElement)) { + return $this->json([ + 'error' => 'Plot element description is required', + ], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->ragQueryService->analyzePlotConsistency( + $plotElement, + $larp + ); + + return $this->json([ + 'analysis' => $result->response, + 'sources' => array_map(fn ($s) => [ + 'title' => $s->title, + 'type' => $s->entityType ?? 'Lore', + ], $result->sources), + ]); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Failed to analyze consistency', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/Domain/StoryAI/DTO/AIQueryResult.php b/src/Domain/StoryAI/DTO/AIQueryResult.php new file mode 100644 index 0000000..9ad24b7 --- /dev/null +++ b/src/Domain/StoryAI/DTO/AIQueryResult.php @@ -0,0 +1,46 @@ + $r->title, $this->sources); + } + + /** + * Get estimated cost in USD (estimate). + */ + public function getEstimatedCost(): float + { + // GPT-4o-mini pricing: $0.15/1M input, $0.60/1M output + $inputCost = ($this->usage['prompt_tokens'] / 1_000_000) * 0.15; + $outputCost = ($this->usage['completion_tokens'] / 1_000_000) * 0.60; + + return $inputCost + $outputCost; + } +} diff --git a/src/Domain/StoryAI/DTO/ChatMessage.php b/src/Domain/StoryAI/DTO/ChatMessage.php new file mode 100644 index 0000000..46989b4 --- /dev/null +++ b/src/Domain/StoryAI/DTO/ChatMessage.php @@ -0,0 +1,49 @@ + $this->role, + 'content' => $this->content, + ]; + } +} diff --git a/src/Domain/StoryAI/DTO/SearchResult.php b/src/Domain/StoryAI/DTO/SearchResult.php new file mode 100644 index 0000000..358b3cc --- /dev/null +++ b/src/Domain/StoryAI/DTO/SearchResult.php @@ -0,0 +1,59 @@ + $metadata + */ + public function __construct( + public string $type, + public string $id, + public string $title, + public string $content, + public float $similarity, + public ?string $entityId = null, + public ?string $entityType = null, + public array $metadata = [], + ) { + } + + public function isStoryObject(): bool + { + return $this->type === self::TYPE_STORY_OBJECT; + } + + public function isLoreDocument(): bool + { + return $this->type === self::TYPE_LORE_DOCUMENT; + } + + /** + * Get a truncated version of the content for display. + */ + public function getContentPreview(int $maxLength = 200): string + { + if (strlen($this->content) <= $maxLength) { + return $this->content; + } + + return substr($this->content, 0, $maxLength) . '...'; + } + + /** + * Get similarity as a percentage. + */ + public function getSimilarityPercent(): float + { + return round($this->similarity * 100, 1); + } +} diff --git a/src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php b/src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php new file mode 100644 index 0000000..0fe17e5 --- /dev/null +++ b/src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php @@ -0,0 +1,54 @@ + 'Setting Overview', + self::WORLD_HISTORY => 'World History', + self::MAGIC_RULES => 'Magic/Power Rules', + self::TECHNOLOGY_RULES => 'Technology Rules', + self::CULTURE_NOTES => 'Culture Notes', + self::GEOGRAPHY => 'Geography', + self::POLITICS => 'Politics & Governance', + self::RELIGION => 'Religion & Beliefs', + self::ECONOMICS => 'Economics & Trade', + self::GENERAL => 'General', + }; + } + + public function getDescription(): string + { + return match ($this) { + self::SETTING_OVERVIEW => 'High-level overview of the game world and setting', + self::WORLD_HISTORY => 'Historical events, timeline, and past eras', + self::MAGIC_RULES => 'Rules and lore about magic, powers, or supernatural abilities', + self::TECHNOLOGY_RULES => 'Available technology level and special tech rules', + self::CULTURE_NOTES => 'Cultural practices, customs, and social norms', + self::GEOGRAPHY => 'Maps, locations, regions, and physical world details', + self::POLITICS => 'Political structures, factions, and governance', + self::RELIGION => 'Religious systems, deities, and spiritual beliefs', + self::ECONOMICS => 'Trade, currency, resources, and economic systems', + self::GENERAL => 'Other lore and setting information', + }; + } +} diff --git a/src/Domain/StoryAI/Entity/LarpLoreDocument.php b/src/Domain/StoryAI/Entity/LarpLoreDocument.php new file mode 100644 index 0000000..c940bce --- /dev/null +++ b/src/Domain/StoryAI/Entity/LarpLoreDocument.php @@ -0,0 +1,250 @@ + + */ + #[ORM\OneToMany( + targetEntity: LoreDocumentChunk::class, + mappedBy: 'document', + cascade: ['persist', 'remove'], + orphanRemoval: true + )] + #[ORM\OrderBy(['chunkIndex' => 'ASC'])] + private Collection $chunks; + + public function __construct() + { + $this->id = Uuid::v4(); + $this->chunks = new ArrayCollection(); + } + + public function getLarp(): ?Larp + { + return $this->larp; + } + + public function setLarp(?Larp $larp): self + { + $this->larp = $larp; + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + public function getType(): LoreDocumentType + { + return $this->type; + } + + public function setType(LoreDocumentType $type): self + { + $this->type = $type; + return $this; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): self + { + $this->content = $content; + return $this; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): self + { + $this->priority = $priority; + return $this; + } + + public function isAlwaysInclude(): bool + { + return $this->alwaysInclude; + } + + public function setAlwaysInclude(bool $alwaysInclude): self + { + $this->alwaysInclude = $alwaysInclude; + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + return $this; + } + + public function getCreatedBy(): ?User + { + return $this->createdBy; + } + + public function setCreatedBy(?User $createdBy): self + { + $this->createdBy = $createdBy; + return $this; + } + + /** + * @return Collection + */ + public function getChunks(): Collection + { + return $this->chunks; + } + + public function addChunk(LoreDocumentChunk $chunk): self + { + if (!$this->chunks->contains($chunk)) { + $this->chunks->add($chunk); + $chunk->setDocument($this); + } + return $this; + } + + public function removeChunk(LoreDocumentChunk $chunk): self + { + if ($this->chunks->removeElement($chunk)) { + if ($chunk->getDocument() === $this) { + $chunk->setDocument(null); + } + } + return $this; + } + + public function clearChunks(): self + { + $this->chunks->clear(); + return $this; + } + + /** + * Estimate character count (for chunking decisions). + */ + public function getContentLength(): int + { + return strlen($this->content); + } +} diff --git a/src/Domain/StoryAI/Entity/LoreDocumentChunk.php b/src/Domain/StoryAI/Entity/LoreDocumentChunk.php new file mode 100644 index 0000000..b45f3ce --- /dev/null +++ b/src/Domain/StoryAI/Entity/LoreDocumentChunk.php @@ -0,0 +1,198 @@ + + */ + #[ORM\Column(type: Types::JSON)] + private array $embedding = []; + + /** + * The model used to generate this embedding. + */ + #[ORM\Column(length: 100)] + private string $embeddingModel = 'text-embedding-3-small'; + + /** + * Dimensions of the embedding vector. + */ + #[ORM\Column(type: Types::INTEGER)] + private int $dimensions = 1536; + + /** + * Token count of this chunk. + */ + #[ORM\Column(type: Types::INTEGER, nullable: true)] + private ?int $tokenCount = null; + + public function __construct() + { + $this->id = Uuid::v4(); + } + + public function getDocument(): ?LarpLoreDocument + { + return $this->document; + } + + public function setDocument(?LarpLoreDocument $document): self + { + $this->document = $document; + if ($document) { + $this->larp = $document->getLarp(); + } + return $this; + } + + public function getLarp(): ?Larp + { + return $this->larp; + } + + public function setLarp(?Larp $larp): self + { + $this->larp = $larp; + return $this; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): self + { + $this->content = $content; + $this->contentHash = hash('sha256', $content); + return $this; + } + + public function getChunkIndex(): int + { + return $this->chunkIndex; + } + + public function setChunkIndex(int $chunkIndex): self + { + $this->chunkIndex = $chunkIndex; + return $this; + } + + public function getContentHash(): string + { + return $this->contentHash; + } + + /** + * @return array + */ + public function getEmbedding(): array + { + return $this->embedding; + } + + /** + * @param array $embedding + */ + public function setEmbedding(array $embedding): self + { + $this->embedding = $embedding; + $this->dimensions = count($embedding); + return $this; + } + + public function getEmbeddingModel(): string + { + return $this->embeddingModel; + } + + public function setEmbeddingModel(string $embeddingModel): self + { + $this->embeddingModel = $embeddingModel; + return $this; + } + + public function getDimensions(): int + { + return $this->dimensions; + } + + public function getTokenCount(): ?int + { + return $this->tokenCount; + } + + public function setTokenCount(?int $tokenCount): self + { + $this->tokenCount = $tokenCount; + return $this; + } + + /** + * Check if content has changed by comparing hashes. + */ + public function hasContentChanged(string $newContent): bool + { + return $this->contentHash !== hash('sha256', $newContent); + } +} diff --git a/src/Domain/StoryAI/Entity/StoryObjectEmbedding.php b/src/Domain/StoryAI/Entity/StoryObjectEmbedding.php new file mode 100644 index 0000000..75e7369 --- /dev/null +++ b/src/Domain/StoryAI/Entity/StoryObjectEmbedding.php @@ -0,0 +1,180 @@ + + */ + #[ORM\Column(type: Types::JSON)] + private array $embedding = []; + + /** + * The model used to generate this embedding. + */ + #[ORM\Column(length: 100)] + private string $embeddingModel = 'text-embedding-3-small'; + + /** + * Dimensions of the embedding vector. + */ + #[ORM\Column(type: Types::INTEGER)] + private int $dimensions = 1536; + + /** + * Token count of the serialized content. + */ + #[ORM\Column(type: Types::INTEGER, nullable: true)] + private ?int $tokenCount = null; + + public function __construct() + { + $this->id = Uuid::v4(); + } + + public function getLarp(): ?Larp + { + return $this->larp; + } + + public function setLarp(?Larp $larp): self + { + $this->larp = $larp; + return $this; + } + + public function getStoryObject(): ?StoryObject + { + return $this->storyObject; + } + + public function setStoryObject(?StoryObject $storyObject): self + { + $this->storyObject = $storyObject; + return $this; + } + + public function getSerializedContent(): string + { + return $this->serializedContent; + } + + public function setSerializedContent(string $serializedContent): self + { + $this->serializedContent = $serializedContent; + $this->contentHash = hash('sha256', $serializedContent); + return $this; + } + + public function getContentHash(): string + { + return $this->contentHash; + } + + /** + * @return array + */ + public function getEmbedding(): array + { + return $this->embedding; + } + + /** + * @param array $embedding + */ + public function setEmbedding(array $embedding): self + { + $this->embedding = $embedding; + $this->dimensions = count($embedding); + return $this; + } + + public function getEmbeddingModel(): string + { + return $this->embeddingModel; + } + + public function setEmbeddingModel(string $embeddingModel): self + { + $this->embeddingModel = $embeddingModel; + return $this; + } + + public function getDimensions(): int + { + return $this->dimensions; + } + + public function getTokenCount(): ?int + { + return $this->tokenCount; + } + + public function setTokenCount(?int $tokenCount): self + { + $this->tokenCount = $tokenCount; + return $this; + } + + /** + * Check if content has changed by comparing hashes. + */ + public function hasContentChanged(string $newContent): bool + { + return $this->contentHash !== hash('sha256', $newContent); + } +} diff --git a/src/Domain/StoryAI/EventSubscriber/StoryObjectIndexSubscriber.php b/src/Domain/StoryAI/EventSubscriber/StoryObjectIndexSubscriber.php new file mode 100644 index 0000000..12fdc1d --- /dev/null +++ b/src/Domain/StoryAI/EventSubscriber/StoryObjectIndexSubscriber.php @@ -0,0 +1,100 @@ +getObject(); + + if (!$entity instanceof StoryObject) { + return; + } + + if (!$this->autoIndexEnabled) { + $this->logger?->debug('Auto-indexing disabled, skipping', [ + 'story_object_id' => $entity->getId()->toRfc4122(), + ]); + return; + } + + $this->dispatchIndexMessage($entity); + } + + public function postUpdate(PostUpdateEventArgs $args): void + { + $entity = $args->getObject(); + + if (!$entity instanceof StoryObject) { + return; + } + + if (!$this->autoIndexEnabled) { + return; + } + + $this->dispatchIndexMessage($entity); + } + + public function preRemove(PreRemoveEventArgs $args): void + { + // Note: Embedding will be cascade-deleted via FK constraint + // This listener is for logging purposes only + $entity = $args->getObject(); + + if (!$entity instanceof StoryObject) { + return; + } + + $this->logger?->debug('Story object removed, embedding will be cascade-deleted', [ + 'story_object_id' => $entity->getId()->toRfc4122(), + ]); + } + + private function dispatchIndexMessage(StoryObject $storyObject): void + { + $larp = $storyObject->getLarp(); + if (!$larp) { + $this->logger?->warning('Story object has no LARP, skipping indexing', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + ]); + return; + } + + $this->logger?->debug('Dispatching index message', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + ]); + + $this->messageBus->dispatch( + new IndexStoryObjectMessage($storyObject->getId()) + ); + } +} diff --git a/src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php b/src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php new file mode 100644 index 0000000..56522ff --- /dev/null +++ b/src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php @@ -0,0 +1,18 @@ +entityManager + ->getRepository(LarpLoreDocument::class) + ->find($message->documentId); + + if (!$document) { + $this->logger?->warning('Lore document not found for indexing', [ + 'document_id' => $message->documentId->toRfc4122(), + ]); + return; + } + + try { + $this->embeddingService->indexLoreDocument($document); + $this->logger?->info('Lore document indexed via async handler', [ + 'document_id' => $message->documentId->toRfc4122(), + ]); + } catch (\Throwable $e) { + $this->logger?->error('Failed to index lore document', [ + 'document_id' => $message->documentId->toRfc4122(), + 'error' => $e->getMessage(), + ]); + throw $e; + } + } +} diff --git a/src/Domain/StoryAI/MessageHandler/IndexStoryObjectHandler.php b/src/Domain/StoryAI/MessageHandler/IndexStoryObjectHandler.php new file mode 100644 index 0000000..63ebd69 --- /dev/null +++ b/src/Domain/StoryAI/MessageHandler/IndexStoryObjectHandler.php @@ -0,0 +1,53 @@ +entityManager + ->getRepository(StoryObject::class) + ->find($message->storyObjectId); + + if (!$storyObject) { + $this->logger?->warning('Story object not found for indexing', [ + 'story_object_id' => $message->storyObjectId->toRfc4122(), + ]); + return; + } + + try { + $this->embeddingService->indexStoryObject($storyObject); + $this->logger?->info('Story object indexed via async handler', [ + 'story_object_id' => $message->storyObjectId->toRfc4122(), + ]); + } catch (\Throwable $e) { + $this->logger?->error('Failed to index story object', [ + 'story_object_id' => $message->storyObjectId->toRfc4122(), + 'error' => $e->getMessage(), + ]); + throw $e; + } + } +} diff --git a/src/Domain/StoryAI/MessageHandler/ReindexLarpHandler.php b/src/Domain/StoryAI/MessageHandler/ReindexLarpHandler.php new file mode 100644 index 0000000..c6d120d --- /dev/null +++ b/src/Domain/StoryAI/MessageHandler/ReindexLarpHandler.php @@ -0,0 +1,51 @@ +entityManager + ->getRepository(Larp::class) + ->find($message->larpId); + + if (!$larp) { + $this->logger?->warning('LARP not found for reindexing', [ + 'larp_id' => $message->larpId->toRfc4122(), + ]); + return; + } + + try { + $stats = $this->embeddingService->reindexLarp($larp); + $this->logger?->info('LARP reindexed via async handler', [ + 'larp_id' => $message->larpId->toRfc4122(), + 'stats' => $stats, + ]); + } catch (\Throwable $e) { + $this->logger?->error('Failed to reindex LARP', [ + 'larp_id' => $message->larpId->toRfc4122(), + 'error' => $e->getMessage(), + ]); + throw $e; + } + } +} diff --git a/src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php b/src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php new file mode 100644 index 0000000..c9c8b6b --- /dev/null +++ b/src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php @@ -0,0 +1,111 @@ + + * + * @method LarpLoreDocument|null find($id, $lockMode = null, $lockVersion = null) + * @method LarpLoreDocument|null findOneBy(array $criteria, array $orderBy = null) + * @method LarpLoreDocument[] findAll() + * @method LarpLoreDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class LarpLoreDocumentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, LarpLoreDocument::class); + } + + /** + * Get all active documents for a LARP ordered by priority. + * + * @return LarpLoreDocument[] + */ + public function findActiveByLarp(Larp $larp): array + { + return $this->createQueryBuilder('d') + ->where('d.larp = :larp') + ->andWhere('d.active = true') + ->setParameter('larp', $larp) + ->orderBy('d.priority', 'DESC') + ->addOrderBy('d.createdAt', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Get documents that should always be included in AI context. + * + * @return LarpLoreDocument[] + */ + public function findAlwaysInclude(Larp $larp): array + { + return $this->createQueryBuilder('d') + ->where('d.larp = :larp') + ->andWhere('d.active = true') + ->andWhere('d.alwaysInclude = true') + ->setParameter('larp', $larp) + ->orderBy('d.priority', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * Get documents by type. + * + * @return LarpLoreDocument[] + */ + public function findByType(Larp $larp, LoreDocumentType $type): array + { + return $this->createQueryBuilder('d') + ->where('d.larp = :larp') + ->andWhere('d.type = :type') + ->andWhere('d.active = true') + ->setParameter('larp', $larp) + ->setParameter('type', $type) + ->orderBy('d.priority', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * Get total content length for all active documents in a LARP. + */ + public function getTotalContentLength(Larp $larp): int + { + $result = $this->createQueryBuilder('d') + ->select('SUM(LENGTH(d.content)) as total') + ->where('d.larp = :larp') + ->andWhere('d.active = true') + ->setParameter('larp', $larp) + ->getQuery() + ->getSingleScalarResult(); + + return (int) ($result ?? 0); + } + + /** + * Get count of documents by LARP. + */ + public function countByLarp(Larp $larp): int + { + return $this->count(['larp' => $larp]); + } + + /** + * Get count of active documents by LARP. + */ + public function countActiveByLarp(Larp $larp): int + { + return $this->count(['larp' => $larp, 'active' => true]); + } +} diff --git a/src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php b/src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php new file mode 100644 index 0000000..8e4451f --- /dev/null +++ b/src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php @@ -0,0 +1,121 @@ + + * + * @method LoreDocumentChunk|null find($id, $lockMode = null, $lockVersion = null) + * @method LoreDocumentChunk|null findOneBy(array $criteria, array $orderBy = null) + * @method LoreDocumentChunk[] findAll() + * @method LoreDocumentChunk[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class LoreDocumentChunkRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, LoreDocumentChunk::class); + } + + /** + * Get all chunks for a document ordered by index. + * + * @return LoreDocumentChunk[] + */ + public function findByDocument(LarpLoreDocument $document): array + { + return $this->findBy( + ['document' => $document], + ['chunkIndex' => 'ASC'] + ); + } + + /** + * Perform a vector similarity search on lore chunks. + * + * @param array $queryEmbedding The query vector + * @param Larp $larp The LARP to search within + * @param int $limit Maximum number of results + * @param float $minSimilarity Minimum cosine similarity threshold (0-1) + * @return array + * @throws Exception + */ + public function findSimilar( + array $queryEmbedding, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5 + ): array { + $conn = $this->getEntityManager()->getConnection(); + + // Convert embedding to pgvector format + $vectorStr = '[' . implode(',', $queryEmbedding) . ']'; + + // Use cosine similarity (1 - cosine distance) + $sql = << :query_vector::vector) as similarity + FROM lore_document_chunk ldc + INNER JOIN larp_lore_document lld ON ldc.document_id = lld.id + WHERE ldc.larp_id = :larp_id + AND lld.active = true + AND 1 - (ldc.embedding::vector <=> :query_vector::vector) >= :min_similarity + ORDER BY ldc.embedding::vector <=> :query_vector::vector + LIMIT :limit + SQL; + + $stmt = $conn->prepare($sql); + $result = $stmt->executeQuery([ + 'query_vector' => $vectorStr, + 'larp_id' => $larp->getId()->toRfc4122(), + 'min_similarity' => $minSimilarity, + 'limit' => $limit, + ]); + + $rows = $result->fetchAllAssociative(); + $results = []; + + foreach ($rows as $row) { + $chunk = $this->find($row['id']); + if ($chunk) { + $results[] = [ + 'chunk' => $chunk, + 'similarity' => (float) $row['similarity'], + ]; + } + } + + return $results; + } + + /** + * Delete all chunks for a document. + */ + public function deleteByDocument(LarpLoreDocument $document): int + { + return $this->createQueryBuilder('c') + ->delete() + ->where('c.document = :document') + ->setParameter('document', $document) + ->getQuery() + ->execute(); + } + + /** + * Get count of chunks for a LARP. + */ + public function countByLarp(Larp $larp): int + { + return $this->count(['larp' => $larp]); + } +} diff --git a/src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php b/src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php new file mode 100644 index 0000000..cd60b0b --- /dev/null +++ b/src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php @@ -0,0 +1,140 @@ + + * + * @method StoryObjectEmbedding|null find($id, $lockMode = null, $lockVersion = null) + * @method StoryObjectEmbedding|null findOneBy(array $criteria, array $orderBy = null) + * @method StoryObjectEmbedding[] findAll() + * @method StoryObjectEmbedding[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class StoryObjectEmbeddingRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StoryObjectEmbedding::class); + } + + public function findByStoryObject(StoryObject $storyObject): ?StoryObjectEmbedding + { + return $this->findOneBy(['storyObject' => $storyObject]); + } + + /** + * @return StoryObjectEmbedding[] + */ + public function findByLarp(Larp $larp): array + { + return $this->findBy(['larp' => $larp]); + } + + /** + * Perform vector similarity search using pgvector. + * + * @param array $queryEmbedding The query vector + * @param Larp $larp The LARP to search within + * @param int $limit Maximum number of results + * @param float $minSimilarity Minimum cosine similarity threshold (0-1) + * @return array + */ + public function findSimilar( + array $queryEmbedding, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5 + ): array { + $conn = $this->getEntityManager()->getConnection(); + + // Convert embedding to pgvector format + $vectorStr = '[' . implode(',', $queryEmbedding) . ']'; + + // Use cosine similarity (1 - cosine distance) + // pgvector uses <=> for cosine distance, so similarity = 1 - distance + $sql = << :query_vector::vector) as similarity + FROM story_object_embedding soe + WHERE soe.larp_id = :larp_id + AND 1 - (soe.embedding::vector <=> :query_vector::vector) >= :min_similarity + ORDER BY soe.embedding::vector <=> :query_vector::vector + LIMIT :limit + SQL; + + $stmt = $conn->prepare($sql); + $result = $stmt->executeQuery([ + 'query_vector' => $vectorStr, + 'larp_id' => $larp->getId()->toRfc4122(), + 'min_similarity' => $minSimilarity, + 'limit' => $limit, + ]); + + $rows = $result->fetchAllAssociative(); + $results = []; + + foreach ($rows as $row) { + $embedding = $this->find($row['id']); + if ($embedding) { + $results[] = [ + 'embedding' => $embedding, + 'similarity' => (float) $row['similarity'], + ]; + } + } + + return $results; + } + + /** + * Find embeddings that need updating (content hash doesn't match). + * + * @return StoryObjectEmbedding[] + */ + public function findOutdated(Larp $larp): array + { + return $this->createQueryBuilder('e') + ->where('e.larp = :larp') + ->setParameter('larp', $larp) + ->getQuery() + ->getResult(); + } + + /** + * Delete all embeddings for a LARP. + */ + public function deleteByLarp(Larp $larp): int + { + return $this->createQueryBuilder('e') + ->delete() + ->where('e.larp = :larp') + ->setParameter('larp', $larp) + ->getQuery() + ->execute(); + } + + /** + * Check if an embedding exists for a story object. + */ + public function existsForStoryObject(StoryObject $storyObject): bool + { + return $this->count(['storyObject' => $storyObject]) > 0; + } + + /** + * Get count of embeddings for a LARP. + */ + public function countByLarp(Larp $larp): int + { + return $this->count(['larp' => $larp]); + } +} diff --git a/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php new file mode 100644 index 0000000..00d9dc9 --- /dev/null +++ b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php @@ -0,0 +1,319 @@ +getLarp(); + if (!$larp) { + throw new \InvalidArgumentException('Story object must belong to a LARP'); + } + + // Serialize the story object to text + $serializedContent = $this->serializer->serialize($storyObject); + + // Check if embedding already exists + $existingEmbedding = $this->embeddingRepository->findByStoryObject($storyObject); + + if ($existingEmbedding) { + // Check if content has changed + if (!$existingEmbedding->hasContentChanged($serializedContent)) { + $this->logger?->debug('Content unchanged, skipping re-embedding', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + ]); + return $existingEmbedding; + } + + // Update existing embedding + $embedding = $existingEmbedding; + $this->logger?->debug('Updating existing embedding', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + ]); + } else { + // Create new embedding + $embedding = new StoryObjectEmbedding(); + $embedding->setLarp($larp); + $embedding->setStoryObject($storyObject); + $this->logger?->debug('Creating new embedding', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + ]); + } + + // Generate embedding vector + $vector = $this->embeddingProvider->embed($serializedContent); + + // Update embedding + $embedding->setSerializedContent($serializedContent); + $embedding->setEmbedding($vector); + $embedding->setEmbeddingModel($this->embeddingProvider->getModelName()); + $embedding->setTokenCount($this->embeddingProvider->estimateTokenCount($serializedContent)); + + $this->entityManager->persist($embedding); + $this->entityManager->flush(); + + $this->logger?->info('Story object indexed successfully', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + 'type' => $storyObject::class, + 'token_count' => $embedding->getTokenCount(), + ]); + + return $embedding; + } + + /** + * Index a lore document by chunking and generating embeddings. + */ + public function indexLoreDocument(LarpLoreDocument $document): void + { + $larp = $document->getLarp(); + if (!$larp) { + throw new \InvalidArgumentException('Lore document must belong to a LARP'); + } + + // Clear existing chunks + $this->chunkRepository->deleteByDocument($document); + $document->clearChunks(); + + // Chunk the content + $chunks = $this->chunkContent($document->getContent()); + + $this->logger?->debug('Chunking lore document', [ + 'document_id' => $document->getId()->toRfc4122(), + 'chunk_count' => count($chunks), + ]); + + // Add document context to each chunk + $contextPrefix = sprintf( + "[%s] %s\n\n", + $document->getType()->getLabel(), + $document->getTitle() + ); + + // Generate embeddings in batch + $textsToEmbed = array_map( + fn (string $chunk) => $contextPrefix . $chunk, + $chunks + ); + + $embeddings = $this->embeddingProvider->embedBatch($textsToEmbed); + + // Create chunk entities + foreach ($chunks as $index => $chunkContent) { + $chunk = new LoreDocumentChunk(); + $chunk->setDocument($document); + $chunk->setLarp($larp); + $chunk->setContent($chunkContent); + $chunk->setChunkIndex($index); + $chunk->setEmbedding($embeddings[$index]); + $chunk->setEmbeddingModel($this->embeddingProvider->getModelName()); + $chunk->setTokenCount($this->embeddingProvider->estimateTokenCount($chunkContent)); + + $document->addChunk($chunk); + $this->entityManager->persist($chunk); + } + + $this->entityManager->flush(); + + $this->logger?->info('Lore document indexed successfully', [ + 'document_id' => $document->getId()->toRfc4122(), + 'chunk_count' => count($chunks), + ]); + } + + /** + * Reindex all story objects for a LARP. + * + * @return array{indexed: int, skipped: int, errors: int} + */ + public function reindexLarp(Larp $larp, callable $progressCallback = null): array + { + $stats = ['indexed' => 0, 'skipped' => 0, 'errors' => 0]; + + // Get all story objects for this LARP + $storyObjects = $this->entityManager + ->getRepository(StoryObject::class) + ->findBy(['larp' => $larp]); + + $total = count($storyObjects); + $this->logger?->info('Starting LARP reindex', [ + 'larp_id' => $larp->getId()->toRfc4122(), + 'total_objects' => $total, + ]); + + foreach ($storyObjects as $index => $storyObject) { + try { + $serializedContent = $this->serializer->serialize($storyObject); + $existingEmbedding = $this->embeddingRepository->findByStoryObject($storyObject); + + if ($existingEmbedding && !$existingEmbedding->hasContentChanged($serializedContent)) { + $stats['skipped']++; + } else { + $this->indexStoryObject($storyObject); + $stats['indexed']++; + } + + if ($progressCallback) { + $progressCallback($index + 1, $total, $storyObject); + } + } catch (\Throwable $e) { + $stats['errors']++; + $this->logger?->error('Error indexing story object', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + 'error' => $e->getMessage(), + ]); + } + } + + $this->logger?->info('LARP reindex completed', [ + 'larp_id' => $larp->getId()->toRfc4122(), + 'stats' => $stats, + ]); + + return $stats; + } + + /** + * Delete embedding for a story object. + */ + public function deleteStoryObjectEmbedding(StoryObject $storyObject): void + { + $embedding = $this->embeddingRepository->findByStoryObject($storyObject); + if ($embedding) { + $this->entityManager->remove($embedding); + $this->entityManager->flush(); + + $this->logger?->debug('Embedding deleted', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + ]); + } + } + + /** + * Generate embedding for arbitrary text (for queries). + * + * @return array + */ + public function generateQueryEmbedding(string $query): array + { + return $this->embeddingProvider->embed($query); + } + + /** + * Chunk content into overlapping segments. + * + * @return array + */ + private function chunkContent(string $content): array + { + $content = trim($content); + $length = strlen($content); + + if ($length <= self::CHUNK_SIZE) { + return [$content]; + } + + $chunks = []; + $position = 0; + + while ($position < $length) { + $chunkEnd = min($position + self::CHUNK_SIZE, $length); + + // Try to break at a sentence or paragraph boundary + if ($chunkEnd < $length) { + $breakpoint = $this->findBreakpoint($content, $chunkEnd); + if ($breakpoint > $position) { + $chunkEnd = $breakpoint; + } + } + + $chunk = substr($content, $position, $chunkEnd - $position); + $chunks[] = trim($chunk); + + // Move position with overlap + $position = $chunkEnd - self::CHUNK_OVERLAP; + if ($position <= 0 || $chunkEnd >= $length) { + break; + } + } + + return array_filter($chunks, fn ($chunk) => !empty($chunk)); + } + + /** + * Find a good breakpoint near the target position. + */ + private function findBreakpoint(string $content, int $targetPosition): int + { + // Look backwards for a paragraph or sentence break + $searchRange = min(200, $targetPosition); + $searchStart = $targetPosition - $searchRange; + + $substring = substr($content, $searchStart, $searchRange); + + // Try a paragraph break first + $lastParagraph = strrpos($substring, "\n\n"); + if ($lastParagraph !== false) { + return $searchStart + $lastParagraph + 2; + } + + // Try sentence break + $lastSentence = max( + strrpos($substring, '. ') ?: 0, + strrpos($substring, '! ') ?: 0, + strrpos($substring, '? ') ?: 0 + ); + if ($lastSentence > 0) { + return $searchStart + $lastSentence + 2; + } + + // Try newline + $lastNewline = strrpos($substring, "\n"); + if ($lastNewline !== false) { + return $searchStart + $lastNewline + 1; + } + + // Try space + $lastSpace = strrpos($substring, ' '); + if ($lastSpace !== false) { + return $searchStart + $lastSpace + 1; + } + + return $targetPosition; + } +} diff --git a/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php new file mode 100644 index 0000000..9ea5def --- /dev/null +++ b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php @@ -0,0 +1,461 @@ + $this->serializeCharacter($storyObject), + $storyObject instanceof Thread => $this->serializeThread($storyObject), + $storyObject instanceof Quest => $this->serializeQuest($storyObject), + $storyObject instanceof Faction => $this->serializeFaction($storyObject), + $storyObject instanceof Event => $this->serializeEvent($storyObject), + $storyObject instanceof Place => $this->serializePlace($storyObject), + $storyObject instanceof Item => $this->serializeItem($storyObject), + $storyObject instanceof Relation => $this->serializeRelation($storyObject), + default => $this->serializeGeneric($storyObject), + }; + } + + private function serializeCharacter(Character $character): string + { + $parts = []; + + // Header + $parts[] = sprintf('Character: %s', $character->getTitle()); + if ($character->getInGameName()) { + $parts[] = sprintf('In-game name: %s', $character->getInGameName()); + } + + // Type and gender + if ($character->getCharacterType()) { + $parts[] = sprintf('Type: %s', $character->getCharacterType()->value); + } + if ($character->getGender()) { + $parts[] = sprintf('Gender: %s', $character->getGender()->value); + } + + // Description + if ($character->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($character->getDescription())); + } + + // Notes (internal story notes) + if ($character->getNotes()) { + $parts[] = sprintf('Story notes: %s', $this->cleanHtml($character->getNotes())); + } + + // Factions + $factions = $character->getFactions(); + if (!$factions->isEmpty()) { + $factionNames = []; + foreach ($factions as $faction) { + $factionNames[] = $faction->getTitle(); + } + $parts[] = sprintf('Factions: %s', implode(', ', $factionNames)); + } + + // Threads + $threads = $character->getThreads(); + if (!$threads->isEmpty()) { + $threadNames = []; + foreach ($threads as $thread) { + $threadNames[] = $thread->getTitle(); + } + $parts[] = sprintf('Threads: %s', implode(', ', $threadNames)); + } + + // Quests + $quests = $character->getQuests(); + if (!$quests->isEmpty()) { + $questNames = []; + foreach ($quests as $quest) { + $questNames[] = $quest->getTitle(); + } + $parts[] = sprintf('Quests: %s', implode(', ', $questNames)); + } + + // Skills + $skills = $character->getSkills(); + if (!$skills->isEmpty()) { + $skillNames = []; + foreach ($skills as $characterSkill) { + $skill = $characterSkill->getSkill(); + if ($skill) { + $skillNames[] = $skill->getName(); + } + } + if (!empty($skillNames)) { + $parts[] = sprintf('Skills: %s', implode(', ', $skillNames)); + } + } + + // Items + $items = $character->getItems(); + if (!$items->isEmpty()) { + $itemNames = []; + foreach ($items as $characterItem) { + $item = $characterItem->getItem(); + if ($item) { + $itemNames[] = $item->getTitle(); + } + } + if (!empty($itemNames)) { + $parts[] = sprintf('Items: %s', implode(', ', $itemNames)); + } + } + + // Relations + $relationsFrom = $character->getRelationsFrom(); + $relationsTo = $character->getRelationsTo(); + $relationStrings = []; + + foreach ($relationsFrom as $relation) { + $target = $relation->getTo(); + $type = $relation->getRelationType()?->value ?? 'Related'; + if ($target) { + $relationStrings[] = sprintf('%s of %s', $type, $target->getTitle()); + } + } + + foreach ($relationsTo as $relation) { + $source = $relation->getFrom(); + $type = $relation->getRelationType()?->value ?? 'Related'; + if ($source) { + $relationStrings[] = sprintf('%s with %s', $type, $source->getTitle()); + } + } + + if (!empty($relationStrings)) { + $parts[] = sprintf('Relations: %s', implode('; ', $relationStrings)); + } + + // Tags + $tags = $character->getTags(); + if (!$tags->isEmpty()) { + $tagNames = []; + foreach ($tags as $tag) { + $tagNames[] = $tag->getTitle(); + } + $parts[] = sprintf('Tags: %s', implode(', ', $tagNames)); + } + + // Recruitment status + if ($character->isAvailableForRecruitment()) { + $parts[] = 'Status: Available for recruitment'; + } + + return implode("\n", $parts); + } + + private function serializeThread(Thread $thread): string + { + $parts = []; + + $parts[] = sprintf('Thread: %s', $thread->getTitle()); + + if ($thread->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($thread->getDescription())); + } + + // Involved characters + $characters = $thread->getInvolvedCharacters(); + if (!$characters->isEmpty()) { + $names = []; + foreach ($characters as $character) { + $names[] = $character->getTitle(); + } + $parts[] = sprintf('Involved characters: %s', implode(', ', $names)); + } + + // Involved factions + $factions = $thread->getInvolvedFactions(); + if (!$factions->isEmpty()) { + $names = []; + foreach ($factions as $faction) { + $names[] = $faction->getTitle(); + } + $parts[] = sprintf('Involved factions: %s', implode(', ', $names)); + } + + // Related quests + $quests = $thread->getQuests(); + if (!$quests->isEmpty()) { + $names = []; + foreach ($quests as $quest) { + $names[] = $quest->getTitle(); + } + $parts[] = sprintf('Quests: %s', implode(', ', $names)); + } + + // Decision tree summary + $decisionTree = $thread->getDecisionTree(); + if ($decisionTree) { + $summary = $this->summarizeDecisionTree($decisionTree); + if ($summary) { + $parts[] = sprintf('Decision tree: %s', $summary); + } + } + + // Tags + $tags = $thread->getTags(); + if (!$tags->isEmpty()) { + $tagNames = []; + foreach ($tags as $tag) { + $tagNames[] = $tag->getTitle(); + } + $parts[] = sprintf('Tags: %s', implode(', ', $tagNames)); + } + + return implode("\n", $parts); + } + + private function serializeQuest(Quest $quest): string + { + $parts = []; + + $parts[] = sprintf('Quest: %s', $quest->getTitle()); + + if ($quest->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($quest->getDescription())); + } + + // Parent thread + $thread = $quest->getThread(); + if ($thread) { + $parts[] = sprintf('Part of thread: %s', $thread->getTitle()); + } + + // Involved characters + $characters = $quest->getInvolvedCharacters(); + if (!$characters->isEmpty()) { + $names = []; + foreach ($characters as $character) { + $names[] = $character->getTitle(); + } + $parts[] = sprintf('Involved characters: %s', implode(', ', $names)); + } + + // Involved factions + $factions = $quest->getInvolvedFactions(); + if (!$factions->isEmpty()) { + $names = []; + foreach ($factions as $faction) { + $names[] = $faction->getTitle(); + } + $parts[] = sprintf('Involved factions: %s', implode(', ', $names)); + } + + // Decision tree summary + $decisionTree = $quest->getDecisionTree(); + if ($decisionTree) { + $summary = $this->summarizeDecisionTree($decisionTree); + if ($summary) { + $parts[] = sprintf('Decision tree: %s', $summary); + } + } + + // Tags + $tags = $quest->getTags(); + if (!$tags->isEmpty()) { + $tagNames = []; + foreach ($tags as $tag) { + $tagNames[] = $tag->getTitle(); + } + $parts[] = sprintf('Tags: %s', implode(', ', $tagNames)); + } + + return implode("\n", $parts); + } + + private function serializeFaction(Faction $faction): string + { + $parts = []; + + $parts[] = sprintf('Faction: %s', $faction->getTitle()); + + if ($faction->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($faction->getDescription())); + } + + // Members + $members = $faction->getMembers(); + if (!$members->isEmpty()) { + $names = []; + foreach ($members as $member) { + $names[] = $member->getTitle(); + } + $parts[] = sprintf('Members: %s', implode(', ', $names)); + } + + // Related threads + $threads = $faction->getThreads(); + if (!$threads->isEmpty()) { + $names = []; + foreach ($threads as $thread) { + $names[] = $thread->getTitle(); + } + $parts[] = sprintf('Threads: %s', implode(', ', $names)); + } + + // Related quests + $quests = $faction->getQuests(); + if (!$quests->isEmpty()) { + $names = []; + foreach ($quests as $quest) { + $names[] = $quest->getTitle(); + } + $parts[] = sprintf('Quests: %s', implode(', ', $names)); + } + + return implode("\n", $parts); + } + + private function serializeEvent(Event $event): string + { + $parts = []; + + $parts[] = sprintf('Event: %s', $event->getTitle()); + + if ($event->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($event->getDescription())); + } + + return implode("\n", $parts); + } + + private function serializePlace(Place $place): string + { + $parts = []; + + $parts[] = sprintf('Place: %s', $place->getTitle()); + + if ($place->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($place->getDescription())); + } + + return implode("\n", $parts); + } + + private function serializeItem(Item $item): string + { + $parts = []; + + $parts[] = sprintf('Item: %s', $item->getTitle()); + + if ($item->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($item->getDescription())); + } + + return implode("\n", $parts); + } + + private function serializeRelation(Relation $relation): string + { + $parts = []; + + $from = $relation->getFrom(); + $to = $relation->getTo(); + $type = $relation->getRelationType()?->getLabel() ?? 'Related'; + + if ($from && $to) { + $parts[] = sprintf( + 'Relation: %s is %s to %s', + $from->getTitle(), + $type, + $to->getTitle() + ); + } else { + $parts[] = sprintf('Relation: %s', $relation->getTitle()); + } + + if ($relation->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($relation->getDescription())); + } + + return implode("\n", $parts); + } + + private function serializeGeneric(StoryObject $storyObject): string + { + $parts = []; + + $parts[] = sprintf('Story Object: %s', $storyObject->getTitle()); + + if ($storyObject->getDescription()) { + $parts[] = sprintf('Description: %s', $this->cleanHtml($storyObject->getDescription())); + } + + return implode("\n", $parts); + } + + /** + * Clean HTML content for plain text embedding. + */ + private function cleanHtml(string $html): string + { + // Decode HTML entities + $text = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Remove HTML tags + $text = strip_tags($text); + + // Normalize whitespace + $text = preg_replace('/\s+/', ' ', $text); + + return trim($text); + } + + /** + * Summarize a decision tree for embedding. + * + * @param array|null $decisionTree + */ + private function summarizeDecisionTree(?array $decisionTree): ?string + { + if (!$decisionTree || empty($decisionTree)) { + return null; + } + + $nodes = $decisionTree['nodes'] ?? []; + if (empty($nodes)) { + return null; + } + + $summaryParts = []; + foreach ($nodes as $node) { + $label = $node['data']['label'] ?? null; + $type = $node['type'] ?? 'unknown'; + + if ($label) { + $summaryParts[] = sprintf('%s (%s)', $label, $type); + } + } + + if (empty($summaryParts)) { + return null; + } + + return implode(' -> ', array_slice($summaryParts, 0, 10)); + } +} diff --git a/src/Domain/StoryAI/Service/Provider/EmbeddingProviderInterface.php b/src/Domain/StoryAI/Service/Provider/EmbeddingProviderInterface.php new file mode 100644 index 0000000..0eb38dc --- /dev/null +++ b/src/Domain/StoryAI/Service/Provider/EmbeddingProviderInterface.php @@ -0,0 +1,46 @@ + The embedding vector + */ + public function embed(string $text): array; + + /** + * Generate embeddings for multiple texts in a batch. + * + * @param array $texts + * @return array> Array of embedding vectors + */ + public function embedBatch(array $texts): array; + + /** + * Get the model name being used. + */ + public function getModelName(): string; + + /** + * Get the dimension count of embeddings produced by this provider. + */ + public function getDimensions(): int; + + /** + * Estimate token count for a text (for chunking decisions). + */ + public function estimateTokenCount(string $text): int; + + /** + * Get maximum tokens per embedding request. + */ + public function getMaxTokens(): int; +} diff --git a/src/Domain/StoryAI/Service/Provider/LLMProviderInterface.php b/src/Domain/StoryAI/Service/Provider/LLMProviderInterface.php new file mode 100644 index 0000000..0209a1a --- /dev/null +++ b/src/Domain/StoryAI/Service/Provider/LLMProviderInterface.php @@ -0,0 +1,48 @@ + $messages The conversation messages + * @param array $options Additional options (temperature, max_tokens, etc.) + * @return string The completion text + */ + public function complete(array $messages, array $options = []): string; + + /** + * Generate a completion and return with metadata. + * + * @param array $messages The conversation messages + * @param array $options Additional options + * @return array{content: string, usage: array{prompt_tokens: int, completion_tokens: int, total_tokens: int}} + */ + public function completeWithMetadata(array $messages, array $options = []): array; + + /** + * Get the model name being used. + */ + public function getModelName(): string; + + /** + * Get maximum context length for this model. + */ + public function getMaxContextLength(): int; + + /** + * Estimate token count for messages (for context management). + * + * @param array $messages + */ + public function estimateMessageTokens(array $messages): int; +} diff --git a/src/Domain/StoryAI/Service/Provider/OpenAIProvider.php b/src/Domain/StoryAI/Service/Provider/OpenAIProvider.php new file mode 100644 index 0000000..3bdac42 --- /dev/null +++ b/src/Domain/StoryAI/Service/Provider/OpenAIProvider.php @@ -0,0 +1,196 @@ + 1536, + 'text-embedding-3-large' => 3072, + 'text-embedding-ada-002' => 1536, + ]; + + private const EMBEDDING_MAX_TOKENS = [ + 'text-embedding-3-small' => 8191, + 'text-embedding-3-large' => 8191, + 'text-embedding-ada-002' => 8191, + ]; + + // Completion model context lengths + private const COMPLETION_CONTEXT_LENGTH = [ + 'gpt-4o-mini' => 128000, + 'gpt-4o' => 128000, + 'gpt-4-turbo' => 128000, + 'gpt-3.5-turbo' => 16385, + ]; + + public function __construct( + private readonly string $apiKey, + private readonly string $embeddingModel = 'text-embedding-3-small', + private readonly string $completionModel = 'gpt-4o-mini', + private readonly ?LoggerInterface $logger = null, + ) { + } + + private function getClient(): Client + { + if ($this->client === null) { + $this->client = (new Factory()) + ->withApiKey($this->apiKey) + ->make(); + } + return $this->client; + } + + // ======================================== + // EmbeddingProviderInterface Implementation + // ======================================== + + public function embed(string $text): array + { + $this->logger?->debug('Generating embedding', [ + 'model' => $this->embeddingModel, + 'text_length' => strlen($text), + ]); + + $response = $this->getClient()->embeddings()->create([ + 'model' => $this->embeddingModel, + 'input' => $text, + ]); + + return $response->embeddings[0]->embedding; + } + + public function embedBatch(array $texts): array + { + if (empty($texts)) { + return []; + } + + $this->logger?->debug('Generating batch embeddings', [ + 'model' => $this->embeddingModel, + 'count' => count($texts), + ]); + + $response = $this->getClient()->embeddings()->create([ + 'model' => $this->embeddingModel, + 'input' => $texts, + ]); + + $embeddings = []; + foreach ($response->embeddings as $embedding) { + $embeddings[] = $embedding->embedding; + } + + return $embeddings; + } + + public function getModelName(): string + { + return $this->embeddingModel; + } + + public function getDimensions(): int + { + return self::EMBEDDING_DIMENSIONS[$this->embeddingModel] ?? 1536; + } + + public function estimateTokenCount(string $text): int + { + // Estimation: ~4 characters per token for English text + // This is a heuristic; for precise counts, use tiktoken library + return (int) ceil(strlen($text) / 4); + } + + public function getMaxTokens(): int + { + return self::EMBEDDING_MAX_TOKENS[$this->embeddingModel] ?? 8191; + } + + // ======================================== + // LLMProviderInterface Implementation + // ======================================== + + public function complete(array $messages, array $options = []): string + { + $result = $this->completeWithMetadata($messages, $options); + return $result['content']; + } + + public function completeWithMetadata(array $messages, array $options = []): array + { + $this->logger?->debug('Generating completion', [ + 'model' => $this->completionModel, + 'message_count' => count($messages), + ]); + + $payload = [ + 'model' => $this->completionModel, + 'messages' => array_map(fn (ChatMessage $m) => $m->toArray(), $messages), + 'temperature' => $options['temperature'] ?? 0.7, + ]; + + if (isset($options['max_tokens'])) { + $payload['max_tokens'] = $options['max_tokens']; + } + + if (isset($options['response_format'])) { + $payload['response_format'] = $options['response_format']; + } + + $response = $this->getClient()->chat()->create($payload); + + $content = $response->choices[0]->message->content ?? ''; + $usage = [ + 'prompt_tokens' => $response->usage->promptTokens, + 'completion_tokens' => $response->usage->completionTokens, + 'total_tokens' => $response->usage->totalTokens, + ]; + + $this->logger?->info('Completion generated', [ + 'model' => $this->completionModel, + 'usage' => $usage, + ]); + + return [ + 'content' => $content, + 'usage' => $usage, + ]; + } + + public function getMaxContextLength(): int + { + return self::COMPLETION_CONTEXT_LENGTH[$this->completionModel] ?? 16385; + } + + public function estimateMessageTokens(array $messages): int + { + $totalChars = 0; + foreach ($messages as $message) { + // Add overhead for message structure (~4 tokens per message) + $totalChars += strlen($message->content) + 16; + } + return (int) ceil($totalChars / 4); + } + + /** + * Get the completion model name. + */ + public function getCompletionModelName(): string + { + return $this->completionModel; + } +} diff --git a/src/Domain/StoryAI/Service/Query/ContextBuilder.php b/src/Domain/StoryAI/Service/Query/ContextBuilder.php new file mode 100644 index 0000000..e6edfaa --- /dev/null +++ b/src/Domain/StoryAI/Service/Query/ContextBuilder.php @@ -0,0 +1,144 @@ +loreDocumentRepository->findAlwaysInclude($larp); + + foreach ($alwaysIncludeDocs as $doc) { + $docContext = $this->formatLoreDocument($doc); + $docChars = strlen($docContext); + + if ($usedChars + $docChars <= $availableChars) { + $context[] = $docContext; + $usedChars += $docChars; + } + } + + // Add search results + foreach ($searchResults as $result) { + $resultContext = $this->formatSearchResult($result); + $resultChars = strlen($resultContext); + + if ($usedChars + $resultChars <= $availableChars) { + $context[] = $resultContext; + $usedChars += $resultChars; + } else { + // Try to add a truncated version + $remainingChars = $availableChars - $usedChars - 100; // Leave buffer + if ($remainingChars > 200) { + $truncated = $this->formatSearchResultTruncated($result, $remainingChars); + $context[] = $truncated; + } + break; + } + } + + return implode("\n\n---\n\n", $context); + } + + /** + * Build a system prompt for story assistance. + */ + public function buildSystemPrompt(Larp $larp): string + { + return <<getTitle()}" + +Your role is to: +- Help writers find connections between characters and plot elements +- Suggest story arcs, motivations, and relationships +- Identify potential plot holes or inconsistencies +- Provide creative suggestions that fit the established setting +- Maintain consistency with existing lore and character backgrounds + +Guidelines: +- Always base your suggestions on the provided context +- If information is missing, acknowledge it and ask for clarification +- Be creative but stay within the established setting +- Consider the interconnected nature of LARP stories where multiple characters interact +- Suggest ideas that create interesting player experiences +- Flag any potential conflicts with existing story elements + +The context below contains relevant information from the LARP's story database. +Use this information to provide informed, contextual suggestions. +PROMPT; + } + + private function formatLoreDocument($document): string + { + $type = $document->getType()->getLabel(); + $title = $document->getTitle(); + $content = $document->getContent(); + + return <<isStoryObject() ? $result->entityType : 'Lore'; + + return <<title} + +{$result->content} +CONTENT; + } + + private function formatSearchResultTruncated(SearchResult $result, int $maxChars): string + { + $typeLabel = $result->isStoryObject() ? $result->entityType : 'Lore'; + $headerLength = strlen("[{$typeLabel}] {$result->title}\n\n"); + $contentLength = $maxChars - $headerLength - 3; // -3 for "..." + + $truncatedContent = substr($result->content, 0, max(0, $contentLength)) . '...'; + + return <<title} + +{$truncatedContent} +CONTENT; + } +} diff --git a/src/Domain/StoryAI/Service/Query/RAGQueryService.php b/src/Domain/StoryAI/Service/Query/RAGQueryService.php new file mode 100644 index 0000000..623e51c --- /dev/null +++ b/src/Domain/StoryAI/Service/Query/RAGQueryService.php @@ -0,0 +1,211 @@ + $conversationHistory Previous messages in the conversation + */ + public function query( + string $userQuery, + Larp $larp, + array $conversationHistory = [], + int $maxSources = 10, + float $minSimilarity = 0.4, + ): AIQueryResult { + $startTime = microtime(true); + + $this->logger?->info('Processing RAG query', [ + 'larp_id' => $larp->getId()->toRfc4122(), + 'query_length' => strlen($userQuery), + ]); + + // Step 1: Search for relevant content + $searchResults = $this->vectorSearchService->searchByQuery( + $userQuery, + $larp, + $maxSources, + $minSimilarity + ); + + $this->logger?->debug('Search results found', [ + 'count' => count($searchResults), + ]); + + // Step 2: Build context from search results + $context = $this->contextBuilder->buildContext($searchResults, $larp); + + // Step 3: Build system prompt + $systemPrompt = $this->contextBuilder->buildSystemPrompt($larp); + + // Step 4: Compose messages for LLM + $messages = $this->composeMessages( + $systemPrompt, + $context, + $userQuery, + $conversationHistory + ); + + // Step 5: Get completion from LLM + $completionResult = $this->llmProvider->completeWithMetadata($messages, [ + 'temperature' => 0.7, + 'max_tokens' => 2000, + ]); + + $processingTime = microtime(true) - $startTime; + + $this->logger?->info('RAG query completed', [ + 'processing_time' => $processingTime, + 'sources_used' => count($searchResults), + 'tokens' => $completionResult['usage'], + ]); + + return new AIQueryResult( + response: $completionResult['content'], + sources: $searchResults, + usage: $completionResult['usage'], + model: $this->llmProvider->getModelName(), + processingTime: $processingTime, + ); + } + + /** + * Execute a simple search query (no LLM, just vector search). + * + * @return SearchResult[] + */ + public function search( + string $query, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.4, + ): array { + return $this->vectorSearchService->searchByQuery( + $query, + $larp, + $limit, + $minSimilarity + ); + } + + /** + * Get suggestions for a specific story element. + */ + public function suggestStoryArc( + string $elementType, + string $elementTitle, + string $elementContext, + Larp $larp, + ): AIQueryResult { + $query = sprintf( + 'Suggest a story arc for the %s named "%s". Consider their current situation: %s', + $elementType, + $elementTitle, + $elementContext + ); + + return $this->query($query, $larp); + } + + /** + * Analyze potential plot holes or inconsistencies. + */ + public function analyzePlotConsistency( + string $plotElement, + Larp $larp, + ): AIQueryResult { + $query = sprintf( + 'Analyze this plot element for potential inconsistencies or plot holes with the established story: %s. Identify any conflicts with existing characters, factions, or established lore.', + $plotElement + ); + + return $this->query($query, $larp, [], 15, 0.3); + } + + /** + * Suggest relationships for a character. + */ + public function suggestRelationships( + string $characterName, + string $characterContext, + Larp $larp, + ): AIQueryResult { + $query = sprintf( + 'Suggest potential relationships for the character "%s" based on their background: %s. Consider existing factions, other characters, and story threads. Suggest both allies and potential enemies or rivals.', + $characterName, + $characterContext + ); + + return $this->query($query, $larp); + } + + /** + * Find connections between multiple story elements. + */ + public function findConnections( + array $elementNames, + Larp $larp, + ): AIQueryResult { + $elements = implode('", "', $elementNames); + $query = sprintf( + 'Find or suggest connections between these story elements: "%s". How might they be related? What hidden plots could connect them?', + $elements + ); + + return $this->query($query, $larp, [], 15, 0.3); + } + + /** + * Compose the full message array for the LLM. + * + * @param ChatMessage[] $conversationHistory + * @return ChatMessage[] + */ + private function composeMessages( + string $systemPrompt, + string $context, + string $userQuery, + array $conversationHistory + ): array { + $messages = []; + + // System message with context + $fullSystemPrompt = $systemPrompt . "\n\n## Relevant Context\n\n" . $context; + $messages[] = ChatMessage::system($fullSystemPrompt); + + // Add conversation history (if any) + foreach ($conversationHistory as $message) { + $messages[] = $message; + } + + // Add current user query + $messages[] = ChatMessage::user($userQuery); + + return $messages; + } +} diff --git a/src/Domain/StoryAI/Service/Query/VectorSearchService.php b/src/Domain/StoryAI/Service/Query/VectorSearchService.php new file mode 100644 index 0000000..f25ba16 --- /dev/null +++ b/src/Domain/StoryAI/Service/Query/VectorSearchService.php @@ -0,0 +1,180 @@ +embeddingService->generateQueryEmbedding($query); + + $this->logger?->debug('Performing vector search', [ + 'query' => $query, + 'larp_id' => $larp->getId()->toRfc4122(), + 'limit' => $limit, + ]); + + // Search story objects + $storyResults = $this->searchStoryObjects($queryEmbedding, $larp, $limit, $minSimilarity); + + // Search lore documents + $loreResults = $this->searchLoreDocuments($queryEmbedding, $larp, $limit, $minSimilarity); + + // Merge and sort by similarity + $allResults = array_merge($storyResults, $loreResults); + usort($allResults, fn (SearchResult $a, SearchResult $b) => $b->similarity <=> $a->similarity); + + // Return top results up to the limit + return array_slice($allResults, 0, $limit); + } + + /** + * Search only story objects. + * + * @return SearchResult[] + */ + public function searchStoryObjects( + array $queryEmbedding, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5, + ): array { + $results = $this->storyObjectEmbeddingRepository->findSimilar( + $queryEmbedding, + $larp, + $limit, + $minSimilarity + ); + + return array_map( + fn (array $row) => $this->createStoryObjectResult($row['embedding'], $row['similarity']), + $results + ); + } + + /** + * Search only lore documents. + * + * @return SearchResult[] + */ + public function searchLoreDocuments( + array $queryEmbedding, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5, + ): array { + $results = $this->loreDocumentChunkRepository->findSimilar( + $queryEmbedding, + $larp, + $limit, + $minSimilarity + ); + + return array_map( + fn (array $row) => $this->createLoreChunkResult($row['chunk'], $row['similarity']), + $results + ); + } + + /** + * Search by query string (generates embedding internally). + * + * @return SearchResult[] + */ + public function searchByQuery( + string $query, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5, + bool $includeStoryObjects = true, + bool $includeLoreDocuments = true, + ): array { + $queryEmbedding = $this->embeddingService->generateQueryEmbedding($query); + + $results = []; + + if ($includeStoryObjects) { + $results = array_merge( + $results, + $this->searchStoryObjects($queryEmbedding, $larp, $limit, $minSimilarity) + ); + } + + if ($includeLoreDocuments) { + $results = array_merge( + $results, + $this->searchLoreDocuments($queryEmbedding, $larp, $limit, $minSimilarity) + ); + } + + // Sort by similarity + usort($results, fn (SearchResult $a, SearchResult $b) => $b->similarity <=> $a->similarity); + + return array_slice($results, 0, $limit); + } + + private function createStoryObjectResult(StoryObjectEmbedding $embedding, float $similarity): SearchResult + { + $storyObject = $embedding->getStoryObject(); + + return new SearchResult( + type: SearchResult::TYPE_STORY_OBJECT, + id: $embedding->getId()->toRfc4122(), + title: $storyObject?->getTitle() ?? 'Unknown', + content: $embedding->getSerializedContent(), + similarity: $similarity, + entityId: $storyObject?->getId()->toRfc4122(), + entityType: $storyObject ? (new \ReflectionClass($storyObject))->getShortName() : null, + ); + } + + private function createLoreChunkResult(LoreDocumentChunk $chunk, float $similarity): SearchResult + { + $document = $chunk->getDocument(); + + return new SearchResult( + type: SearchResult::TYPE_LORE_DOCUMENT, + id: $chunk->getId()->toRfc4122(), + title: $document?->getTitle() ?? 'Unknown Document', + content: $chunk->getContent(), + similarity: $similarity, + entityId: $document?->getId()->toRfc4122(), + entityType: $document?->getType()->getLabel(), + metadata: [ + 'chunk_index' => $chunk->getChunkIndex(), + 'document_type' => $document?->getType()->value, + ], + ); + } +} diff --git a/symfony.lock b/symfony.lock index 9fc7b33..8868793 100755 --- a/symfony.lock +++ b/symfony.lock @@ -108,6 +108,18 @@ "./config/packages/knpu_oauth2_client.yaml" ] }, + "php-http/discovery": { + "version": "1.20", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.18", + "ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02" + }, + "files": [ + "config/packages/http_discovery.yaml" + ] + }, "phpstan/phpstan": { "version": "2.1", "recipe": { From 74dbb293242ae7236a7202a197966e5394945a81 Mon Sep 17 00:00:00 2001 From: "tomasz.buczen" Date: Sun, 1 Feb 2026 21:05:18 +0100 Subject: [PATCH 2/5] Introduce `VectorStore`: Add `NullVectorStore` and `SupabaseVectorStore` implementations, setup documentation, and supporting DTO (`VectorDocument`) for AI-powered story management. --- .env | 7 + .serena/project.yml | 61 +++- config/services.yaml | 14 +- docs/technical/STORY_AI.md | 326 ++++++++++++++++++ docs/technical/VECTOR_STORE_SETUP.md | 226 ++++++++++++ src/Domain/StoryAI/DTO/VectorDocument.php | 155 +++++++++ src/Domain/StoryAI/DTO/VectorSearchResult.php | 91 +++++ .../Service/Embedding/EmbeddingService.php | 120 +++++-- .../Service/Query/VectorSearchService.php | 174 ++++++---- .../Service/VectorStore/NullVectorStore.php | 83 +++++ .../VectorStore/SupabaseVectorStore.php | 305 ++++++++++++++++ .../VectorStore/VectorStoreFactory.php | 98 ++++++ .../VectorStore/VectorStoreInterface.php | 80 +++++ 13 files changed, 1630 insertions(+), 110 deletions(-) create mode 100644 docs/technical/STORY_AI.md create mode 100644 docs/technical/VECTOR_STORE_SETUP.md create mode 100644 src/Domain/StoryAI/DTO/VectorDocument.php create mode 100644 src/Domain/StoryAI/DTO/VectorSearchResult.php create mode 100644 src/Domain/StoryAI/Service/VectorStore/NullVectorStore.php create mode 100644 src/Domain/StoryAI/Service/VectorStore/SupabaseVectorStore.php create mode 100644 src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php create mode 100644 src/Domain/StoryAI/Service/VectorStore/VectorStoreInterface.php diff --git a/.env b/.env index d01e037..29d0bc4 100755 --- a/.env +++ b/.env @@ -74,4 +74,11 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small OPENAI_COMPLETION_MODEL=gpt-4o-mini # Enable auto-indexing of story objects on save (false by default to save costs) STORY_AI_AUTO_INDEX=false + +# Vector Store for AI embeddings (CQRS - external database) +# Format: supabase://SERVICE_KEY@PROJECT_REF +# Example: supabase://eyJhbGciOiJIUzI1...@abc123def +# Leave empty to disable vector store (AI features will be limited) +# See docs/VECTOR_STORE_SETUP.md for setup instructions +VECTOR_STORE_DSN= ###< OpenAI Integration (StoryAI) \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml index 3ed5464..1591e7f 100755 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,9 +1,3 @@ -# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) -# * For C, use cpp -# * For JavaScript, use typescript -# Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: php # whether to use the project's gitignore file to ignore files # Added on 2025-04-07 @@ -63,5 +57,58 @@ excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" - +# the name by which the project can be referenced within Serena project_name: "LARPilot" + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: utf-8 + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# powershell python python_jedi r rego +# ruby ruby_solargraph rust scala swift +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- php diff --git a/config/services.yaml b/config/services.yaml index c8cdc1c..0af86ed 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -12,6 +12,8 @@ parameters: openai.api_key: '%env(OPENAI_API_KEY)%' openai.embedding_model: '%env(OPENAI_EMBEDDING_MODEL)%' openai.completion_model: '%env(OPENAI_COMPLETION_MODEL)%' + # Vector Store configuration (CQRS - external vector database) + vector_store.dsn: '%env(default::VECTOR_STORE_DSN)%' services: _defaults: autowire: true # Automatically injects dependencies in your services. @@ -72,4 +74,14 @@ services: alias: App\Domain\StoryAI\Service\Provider\OpenAIProvider App\Domain\StoryAI\Service\Provider\LLMProviderInterface: - alias: App\Domain\StoryAI\Service\Provider\OpenAIProvider \ No newline at end of file + alias: App\Domain\StoryAI\Service\Provider\OpenAIProvider + + # Vector Store (CQRS - external vector database for AI embeddings) + App\Domain\StoryAI\Service\VectorStore\VectorStoreFactory: + arguments: + $httpClient: '@http_client' + + App\Domain\StoryAI\Service\VectorStore\VectorStoreInterface: + factory: ['@App\Domain\StoryAI\Service\VectorStore\VectorStoreFactory', 'create'] + arguments: + $dsn: '%vector_store.dsn%' \ No newline at end of file diff --git a/docs/technical/STORY_AI.md b/docs/technical/STORY_AI.md new file mode 100644 index 0000000..1e32a8e --- /dev/null +++ b/docs/technical/STORY_AI.md @@ -0,0 +1,326 @@ +# StoryAI Domain + +AI-powered assistant for LARP story management using RAG (Retrieval-Augmented Generation). + +## Overview + +StoryAI provides intelligent querying and analysis of LARP story content by: +1. **Indexing** story objects and lore documents into vector embeddings +2. **Searching** content using semantic similarity +3. **Generating** AI responses with relevant context + +## Architecture + +``` +src/Domain/StoryAI/ +├── Entity/ +│ ├── StoryObjectEmbedding.php # Vector embedding for story objects +│ ├── LarpLoreDocument.php # Custom lore/setting documents +│ └── LoreDocumentChunk.php # Chunked document for embeddings +├── Service/ +│ ├── Embedding/ +│ │ ├── EmbeddingService.php # Indexing logic +│ │ └── StoryObjectSerializer.php +│ ├── Query/ +│ │ ├── RAGQueryService.php # Main query service +│ │ ├── VectorSearchService.php # Similarity search +│ │ └── ContextBuilder.php # Context assembly +│ └── Provider/ +│ ├── OpenAIProvider.php # LLM/embedding provider +│ ├── LLMProviderInterface.php +│ └── EmbeddingProviderInterface.php +├── Controller/API/ +│ └── AIAssistantController.php # REST API endpoints +├── Command/ +│ └── ReindexStoryAICommand.php # CLI reindexing +└── Message/ & MessageHandler/ # Async indexing via Messenger +``` + +## API Endpoints + +All endpoints are under `/api/larp/{larp}/ai/`: + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/query` | POST | Ask questions about the story | +| `/search` | POST | Semantic search for content | +| `/suggest-story-arc` | POST | Get story arc suggestions for a character | +| `/suggest-relationships` | POST | Get relationship suggestions for a character | +| `/find-connections` | POST | Find connections between two story objects | +| `/analyze-consistency` | POST | Check plot consistency | + +### Example: Query + +```bash +curl -X POST /api/larp/123/ai/query \ + -H "Content-Type: application/json" \ + -d '{"query": "What is the history of the Northern Kingdom?"}' +``` + +Response: +```json +{ + "answer": "The Northern Kingdom was founded in...", + "sources": [ + {"type": "character", "id": 1, "title": "King Aldric"}, + {"type": "lore_document", "id": 5, "title": "World History"} + ] +} +``` + +## Indexing + +### Automatic Indexing + +Story objects are automatically indexed when created/updated via `StoryObjectIndexSubscriber`. + +### Manual Reindex + +```bash +# Reindex all LARPs (async via Messenger) +php bin/console app:story-ai:reindex + +# Reindex specific LARP synchronously +php bin/console app:story-ai:reindex --larp=123 --sync +``` + +## Lore Documents + +Upload custom setting/lore content that AI uses for context. + +**Document Types:** +- Setting Overview +- World History +- Magic Rules +- Culture Notes +- Geography +- Politics +- Religion +- Economics +- General + +Documents are chunked (500 chars, 100 overlap) and embedded for retrieval. + +## Setup Guide + +### Prerequisites + +- PostgreSQL 15+ with **pgvector** extension +- OpenAI API key +- Symfony Messenger configured (for async indexing) + +--- + +### Step 1: Install pgvector Extension + +**Production (managed PostgreSQL):** + +Most managed PostgreSQL services (AWS RDS, Supabase, Neon) support pgvector. Enable it via their dashboard or run: + +```sql +CREATE EXTENSION IF NOT EXISTS vector; +``` + +**Local/Docker:** + +The project's Docker setup includes pgvector. If using a custom setup: + +```bash +# Ubuntu/Debian +sudo apt install postgresql-15-pgvector + +# Or build from source +git clone https://github.com/pgvector/pgvector.git +cd pgvector && make && sudo make install +``` + +--- + +### Step 2: Configure Environment Variables + +Add to `.env.local` (local) or your production secrets: + +```env +# Required: OpenAI API key +OPENAI_API_KEY=sk-your-api-key-here + +# Optional: Model configuration (defaults shown) +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +OPENAI_COMPLETION_MODEL=gpt-4o-mini +``` + +**Model Options:** + +| Model | Use Case | Cost | +|-------|----------|------| +| `text-embedding-3-small` | Embeddings (default) | Low | +| `text-embedding-3-large` | Higher quality embeddings | Medium | +| `gpt-4o-mini` | Completions (default) | Low | +| `gpt-4o` | Higher quality responses | High | + +--- + +### Step 3: Run Database Migrations + +```bash +# Local (Docker) +make migrate + +# Production +php bin/console doctrine:migrations:migrate --no-interaction +``` + +This creates: +- `story_object_embedding` - Vector embeddings for story objects +- `larp_lore_document` - Lore document metadata +- `lore_document_chunk` - Chunked document embeddings with HNSW index + +--- + +### Step 4: Configure Message Queue + +StoryAI uses Symfony Messenger for async indexing. The routing is pre-configured in `config/packages/messenger.yaml`. + +**Local Development:** + +Use Doctrine transport (default in `.env`): + +```env +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +``` + +Run the worker: + +```bash +# In a separate terminal +docker compose exec php php bin/console messenger:consume async -vv +``` + +**Production:** + +Use a dedicated message broker: + +```env +# Redis +MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages + +# RabbitMQ +MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages +``` + +Run workers via supervisor: + +```ini +[program:messenger-worker] +command=php /var/www/bin/console messenger:consume async --time-limit=3600 +numprocs=2 +autostart=true +autorestart=true +``` + +--- + +### Step 5: Initial Indexing + +Index existing story objects for a LARP: + +```bash +# Async (recommended for large LARPs) +php bin/console app:story-ai:reindex --larp= + +# Sync (for testing/small datasets) +php bin/console app:story-ai:reindex --larp= --sync + +# Reindex all LARPs +php bin/console app:story-ai:reindex +``` + +--- + +### Step 6: Verify Setup + +Test the API: + +```bash +curl -X POST http://localhost/api/larp//ai/search \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"query": "test"}' +``` + +--- + +### Local/Test Environment Notes + +**Test environment** (`.env.test`): + +```env +# Use sync transport for tests (no worker needed) +MESSENGER_TRANSPORT_DSN=sync:// + +# Use test API key or mock +OPENAI_API_KEY=test-key +``` + +**Disable AI in tests:** + +For unit/functional tests that don't need AI, mock the services: + +```php +$ragQueryService = $this->createMock(RAGQueryService::class); +$ragQueryService->method('query')->willReturn(new AIQueryResult('Mock answer', [])); +``` + +**Cost considerations:** + +- Embedding calls: ~$0.02 per 1M tokens (text-embedding-3-small) +- Completion calls: ~$0.15 per 1M input tokens (gpt-4o-mini) +- Use `--sync` flag sparingly in development to control costs + +--- + +## Configuration Reference + +Full environment variables: + +```env +# Required +OPENAI_API_KEY=sk-... + +# Optional (with defaults) +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +OPENAI_COMPLETION_MODEL=gpt-4o-mini + +# Message queue +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +``` + +## Key Services + +### RAGQueryService + +Main entry point for AI queries: + +```php +$result = $ragQueryService->query($larp, "Who are the main antagonists?"); +// Returns AIQueryResult with answer and sources +``` + +### EmbeddingService + +Handles indexing: + +```php +$embeddingService->indexStoryObject($character); +$embeddingService->indexLoreDocument($document); +$embeddingService->reindexLarp($larp); +``` + +### VectorSearchService + +Performs similarity search: + +```php +$results = $vectorSearchService->search($larp, $queryEmbedding, limit: 10); +// Returns SearchResult[] with scores +``` diff --git a/docs/technical/VECTOR_STORE_SETUP.md b/docs/technical/VECTOR_STORE_SETUP.md new file mode 100644 index 0000000..a0f204a --- /dev/null +++ b/docs/technical/VECTOR_STORE_SETUP.md @@ -0,0 +1,226 @@ +# Vector Store Setup Guide + +LARPilot uses an external vector database for AI-powered semantic search (RAG). This CQRS architecture separates the write side (main PostgreSQL on your hosting) from the read side (external vector store with pgvector). + +## Why External Vector Store? + +Many shared hosting providers (like Cyberfolks) don't support PostgreSQL extensions like pgvector. By using an external vector store: + +- **No hosting limitations**: Works with any PostgreSQL hosting +- **Free tier available**: Supabase offers 500MB free +- **Scalable**: Can upgrade independently of main database +- **CQRS benefits**: Read-optimized for semantic search + +## Supported Providers + +### Supabase (Recommended) + +**Free Tier**: 500MB database, 2 projects, pgvector included + +1. **Create Account**: Go to [supabase.com](https://supabase.com) and sign up +2. **Create Project**: + - Choose a region close to your server (EU for Poland-based hosting) + - Note your project reference (e.g., `abc123def`) +3. **Get Service Key**: + - Go to Project Settings > API + - Copy the `service_role` key (NOT the `anon` key) + +### Database Setup + +Run this SQL in Supabase SQL Editor (Database > SQL Editor): + +```sql +-- Enable pgvector extension +CREATE EXTENSION IF NOT EXISTS vector; + +-- Create embeddings table +CREATE TABLE larpilot_embeddings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_id UUID NOT NULL, + larp_id UUID NOT NULL, + entity_type VARCHAR(50) NOT NULL, + type VARCHAR(20) NOT NULL, + title TEXT NOT NULL, + serialized_content TEXT NOT NULL, + content_hash VARCHAR(64) NOT NULL, + embedding vector(1536) NOT NULL, + embedding_model VARCHAR(100) DEFAULT 'text-embedding-3-small', + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Create indexes for filtering +CREATE INDEX idx_embeddings_larp ON larpilot_embeddings(larp_id); +CREATE INDEX idx_embeddings_entity ON larpilot_embeddings(entity_id); +CREATE INDEX idx_embeddings_type ON larpilot_embeddings(type); +CREATE INDEX idx_embeddings_entity_type ON larpilot_embeddings(entity_type); +CREATE INDEX idx_embeddings_hash ON larpilot_embeddings(content_hash); + +-- Create vector similarity index (IVFFlat for performance) +-- Note: lists=100 is good for up to ~100k vectors; adjust for larger datasets +CREATE INDEX idx_embeddings_vector ON larpilot_embeddings + USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- Create unique constraint for upsert logic +CREATE UNIQUE INDEX idx_embeddings_entity_unique ON larpilot_embeddings(entity_id); + +-- Create RPC function for similarity search +CREATE OR REPLACE FUNCTION search_embeddings( + query_embedding vector(1536), + larp_id_filter UUID, + match_threshold FLOAT DEFAULT 0.5, + match_count INT DEFAULT 10, + type_filter VARCHAR DEFAULT NULL, + entity_type_filter VARCHAR DEFAULT NULL +) +RETURNS TABLE ( + entity_id UUID, + larp_id UUID, + entity_type VARCHAR, + type VARCHAR, + title TEXT, + serialized_content TEXT, + similarity FLOAT, + metadata JSONB +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + e.entity_id, + e.larp_id, + e.entity_type, + e.type, + e.title, + e.serialized_content, + 1 - (e.embedding <=> query_embedding) AS similarity, + e.metadata + FROM larpilot_embeddings e + WHERE e.larp_id = larp_id_filter + AND 1 - (e.embedding <=> query_embedding) >= match_threshold + AND (type_filter IS NULL OR e.type = type_filter) + AND (entity_type_filter IS NULL OR e.entity_type = entity_type_filter) + ORDER BY e.embedding <=> query_embedding + LIMIT match_count; +END; +$$; + +-- Grant permissions to the API +GRANT EXECUTE ON FUNCTION search_embeddings TO anon, authenticated, service_role; +GRANT ALL ON larpilot_embeddings TO anon, authenticated, service_role; + +-- Create updated_at trigger +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_update_timestamp + BEFORE UPDATE ON larpilot_embeddings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at(); +``` + +## Configuration + +Add to your `.env.local`: + +```bash +# Format: supabase://SERVICE_KEY@PROJECT_REF +VECTOR_STORE_DSN=supabase://eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...@abc123def +``` + +Where: +- `SERVICE_KEY` is your Supabase service_role key +- `PROJECT_REF` is your project reference (from the project URL) + +## Verifying Setup + +1. **Check Symfony config**: + ```bash + php bin/console debug:container VectorStoreInterface + ``` + +2. **Test connection** (create a simple test command or use the existing reindex command): + ```bash + php bin/console app:story-ai:reindex --larp= + ``` + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WRITE SIDE │ +│ (Cyberfolks PostgreSQL) │ +│ │ +│ StoryObject ──▶ Doctrine Event ──▶ Messenger ──▶ Handler │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ READ SIDE │ +│ (Supabase + pgvector) │ +│ │ +│ EmbeddingService ──▶ VectorStoreInterface ──▶ Supabase API │ +│ │ +│ VectorSearchService ◀── search_embeddings() RPC function │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Cost Considerations + +### Supabase Free Tier Limits +- 500 MB database storage +- 2 GB bandwidth per month +- Unlimited API requests +- 2 projects + +### Estimated Usage +- Each embedding: ~6KB (1536 dimensions × 4 bytes) +- With metadata: ~10KB per document +- 500MB ≈ 50,000 embeddings + +For beta testing, this should be more than sufficient. Upgrade to paid plan ($25/month) when you exceed these limits. + +## Migrating Existing Data + +If you have existing embeddings in your local database: + +1. Export from local: + ```sql + SELECT entity_id, larp_id, entity_type, serialized_content, embedding + FROM story_object_embedding; + ``` + +2. Run reindex command to populate Supabase: + ```bash + php bin/console app:story-ai:reindex --all + ``` + +## Troubleshooting + +### "Function search_embeddings does not exist" +Run the SQL setup script again - the function may not have been created. + +### "Permission denied" +Ensure you're using the `service_role` key, not the `anon` key. + +### Slow searches +- Check if the IVFFlat index was created +- For large datasets (>100k vectors), recreate with more lists: + ```sql + DROP INDEX idx_embeddings_vector; + CREATE INDEX idx_embeddings_vector ON larpilot_embeddings + USING ivfflat (embedding vector_cosine_ops) WITH (lists = 500); + ``` + +### Connection timeouts +- Check if your hosting allows outbound HTTPS connections +- Verify the Supabase URL is correct diff --git a/src/Domain/StoryAI/DTO/VectorDocument.php b/src/Domain/StoryAI/DTO/VectorDocument.php new file mode 100644 index 0000000..6d5277f --- /dev/null +++ b/src/Domain/StoryAI/DTO/VectorDocument.php @@ -0,0 +1,155 @@ + $embedding The vector embedding + * @param string $embeddingModel The model used to generate the embedding + * @param array $metadata Additional metadata + */ + public function __construct( + public Uuid $entityId, + public Uuid $larpId, + public string $entityType, + public string $type, + public string $title, + public string $serializedContent, + public string $contentHash, + public array $embedding, + public string $embeddingModel = 'text-embedding-3-small', + public array $metadata = [], + ) { + } + + /** + * Create from a story object embedding context. + * + * @param array $embedding + * @param array $metadata + */ + public static function forStoryObject( + Uuid $entityId, + Uuid $larpId, + string $entityType, + string $title, + string $serializedContent, + array $embedding, + string $embeddingModel = 'text-embedding-3-small', + array $metadata = [], + ): self { + return new self( + entityId: $entityId, + larpId: $larpId, + entityType: $entityType, + type: self::TYPE_STORY_OBJECT, + title: $title, + serializedContent: $serializedContent, + contentHash: hash('sha256', $serializedContent), + embedding: $embedding, + embeddingModel: $embeddingModel, + metadata: $metadata, + ); + } + + /** + * Create from a lore document chunk context. + * + * @param array $embedding + * @param array $metadata + */ + public static function forLoreChunk( + Uuid $entityId, + Uuid $larpId, + string $documentTitle, + string $chunkContent, + int $chunkIndex, + array $embedding, + string $embeddingModel = 'text-embedding-3-small', + array $metadata = [], + ): self { + return new self( + entityId: $entityId, + larpId: $larpId, + entityType: 'LoreDocumentChunk', + type: self::TYPE_LORE_CHUNK, + title: sprintf('%s (chunk %d)', $documentTitle, $chunkIndex + 1), + serializedContent: $chunkContent, + contentHash: hash('sha256', $chunkContent), + embedding: $embedding, + embeddingModel: $embeddingModel, + metadata: array_merge($metadata, ['chunk_index' => $chunkIndex]), + ); + } + + /** + * Convert to array for API transmission. + * + * @return array + */ + public function toArray(): array + { + return [ + 'entity_id' => $this->entityId->toRfc4122(), + 'larp_id' => $this->larpId->toRfc4122(), + 'entity_type' => $this->entityType, + 'type' => $this->type, + 'title' => $this->title, + 'serialized_content' => $this->serializedContent, + 'content_hash' => $this->contentHash, + 'embedding' => $this->embedding, + 'embedding_model' => $this->embeddingModel, + 'metadata' => $this->metadata, + ]; + } + + /** + * Create from array (for hydration from API response). + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + entityId: Uuid::fromString($data['entity_id']), + larpId: Uuid::fromString($data['larp_id']), + entityType: $data['entity_type'], + type: $data['type'], + title: $data['title'], + serializedContent: $data['serialized_content'], + contentHash: $data['content_hash'], + embedding: $data['embedding'], + embeddingModel: $data['embedding_model'] ?? 'text-embedding-3-small', + metadata: $data['metadata'] ?? [], + ); + } + + public function isStoryObject(): bool + { + return $this->type === self::TYPE_STORY_OBJECT; + } + + public function isLoreChunk(): bool + { + return $this->type === self::TYPE_LORE_CHUNK; + } +} diff --git a/src/Domain/StoryAI/DTO/VectorSearchResult.php b/src/Domain/StoryAI/DTO/VectorSearchResult.php new file mode 100644 index 0000000..1adfa52 --- /dev/null +++ b/src/Domain/StoryAI/DTO/VectorSearchResult.php @@ -0,0 +1,91 @@ + $metadata Additional metadata + */ + public function __construct( + public Uuid $entityId, + public Uuid $larpId, + public string $entityType, + public string $type, + public string $title, + public string $content, + public float $similarity, + public array $metadata = [], + ) { + } + + /** + * Create from array (for hydration from API response). + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + entityId: Uuid::fromString($data['entity_id']), + larpId: Uuid::fromString($data['larp_id']), + entityType: $data['entity_type'], + type: $data['type'], + title: $data['title'], + content: $data['serialized_content'] ?? $data['content'] ?? '', + similarity: (float) $data['similarity'], + metadata: $data['metadata'] ?? [], + ); + } + + /** + * Convert to the richer SearchResult DTO. + */ + public function toSearchResult(): SearchResult + { + return new SearchResult( + type: $this->type, + id: $this->entityId->toRfc4122(), + title: $this->title, + content: $this->content, + similarity: $this->similarity, + entityId: $this->entityId->toRfc4122(), + entityType: $this->entityType, + metadata: $this->metadata, + ); + } + + public function isStoryObject(): bool + { + return $this->type === VectorDocument::TYPE_STORY_OBJECT; + } + + public function isLoreChunk(): bool + { + return $this->type === VectorDocument::TYPE_LORE_CHUNK; + } + + /** + * Get similarity as percentage. + */ + public function getSimilarityPercent(): float + { + return round($this->similarity * 100, 1); + } +} diff --git a/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php index 00d9dc9..dd48497 100644 --- a/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php +++ b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php @@ -5,18 +5,25 @@ namespace App\Domain\StoryAI\Service\Embedding; use App\Domain\Core\Entity\Larp; +use App\Domain\StoryAI\DTO\VectorDocument; use App\Domain\StoryAI\Entity\LarpLoreDocument; use App\Domain\StoryAI\Entity\LoreDocumentChunk; use App\Domain\StoryAI\Entity\StoryObjectEmbedding; use App\Domain\StoryAI\Repository\LoreDocumentChunkRepository; use App\Domain\StoryAI\Repository\StoryObjectEmbeddingRepository; use App\Domain\StoryAI\Service\Provider\EmbeddingProviderInterface; +use App\Domain\StoryAI\Service\VectorStore\VectorStoreInterface; use App\Domain\StoryObject\Entity\StoryObject; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; +use Symfony\Component\Uid\Uuid; /** * Service for generating and managing embeddings. + * + * This service follows CQRS principles: + * - Write operations go to external vector store (Supabase, etc.) + * - Local StoryObjectEmbedding entities are kept for tracking/metadata only */ class EmbeddingService { @@ -28,6 +35,7 @@ class EmbeddingService public function __construct( private readonly EmbeddingProviderInterface $embeddingProvider, private readonly StoryObjectSerializer $serializer, + private readonly VectorStoreInterface $vectorStore, private readonly StoryObjectEmbeddingRepository $embeddingRepository, private readonly LoreDocumentChunkRepository $chunkRepository, private readonly EntityManagerInterface $entityManager, @@ -47,38 +55,42 @@ public function indexStoryObject(StoryObject $storyObject): StoryObjectEmbedding // Serialize the story object to text $serializedContent = $this->serializer->serialize($storyObject); + $contentHash = hash('sha256', $serializedContent); - // Check if embedding already exists + // Check if embedding already exists and content hasn't changed $existingEmbedding = $this->embeddingRepository->findByStoryObject($storyObject); - if ($existingEmbedding) { - // Check if content has changed - if (!$existingEmbedding->hasContentChanged($serializedContent)) { - $this->logger?->debug('Content unchanged, skipping re-embedding', [ - 'story_object_id' => $storyObject->getId()->toRfc4122(), - ]); - return $existingEmbedding; - } - - // Update existing embedding - $embedding = $existingEmbedding; - $this->logger?->debug('Updating existing embedding', [ - 'story_object_id' => $storyObject->getId()->toRfc4122(), - ]); - } else { - // Create new embedding - $embedding = new StoryObjectEmbedding(); - $embedding->setLarp($larp); - $embedding->setStoryObject($storyObject); - $this->logger?->debug('Creating new embedding', [ + if ($existingEmbedding && $existingEmbedding->getContentHash() === $contentHash) { + $this->logger?->debug('Content unchanged, skipping re-embedding', [ 'story_object_id' => $storyObject->getId()->toRfc4122(), ]); + return $existingEmbedding; } // Generate embedding vector $vector = $this->embeddingProvider->embed($serializedContent); - // Update embedding + // Create VectorDocument and upsert to external store + $entityType = (new \ReflectionClass($storyObject))->getShortName(); + $vectorDocument = VectorDocument::forStoryObject( + entityId: $storyObject->getId(), + larpId: $larp->getId(), + entityType: $entityType, + title: $storyObject->getTitle(), + serializedContent: $serializedContent, + embedding: $vector, + embeddingModel: $this->embeddingProvider->getModelName(), + ); + + $this->vectorStore->upsert($vectorDocument); + + // Update or create local tracking entity + $embedding = $existingEmbedding ?? new StoryObjectEmbedding(); + if (!$existingEmbedding) { + $embedding->setLarp($larp); + $embedding->setStoryObject($storyObject); + } + $embedding->setSerializedContent($serializedContent); $embedding->setEmbedding($vector); $embedding->setEmbeddingModel($this->embeddingProvider->getModelName()); @@ -89,8 +101,9 @@ public function indexStoryObject(StoryObject $storyObject): StoryObjectEmbedding $this->logger?->info('Story object indexed successfully', [ 'story_object_id' => $storyObject->getId()->toRfc4122(), - 'type' => $storyObject::class, + 'type' => $entityType, 'token_count' => $embedding->getTokenCount(), + 'vector_store' => $this->vectorStore->getProviderName(), ]); return $embedding; @@ -106,7 +119,14 @@ public function indexLoreDocument(LarpLoreDocument $document): void throw new \InvalidArgumentException('Lore document must belong to a LARP'); } - // Clear existing chunks + // Delete existing chunks from vector store + $this->vectorStore->deleteByFilter([ + 'larp_id' => $larp->getId(), + 'type' => VectorDocument::TYPE_LORE_CHUNK, + 'metadata->>document_id' => $document->getId()->toRfc4122(), + ]); + + // Clear local chunks $this->chunkRepository->deleteByDocument($document); $document->clearChunks(); @@ -133,8 +153,14 @@ public function indexLoreDocument(LarpLoreDocument $document): void $embeddings = $this->embeddingProvider->embedBatch($textsToEmbed); - // Create chunk entities + // Prepare vector documents for batch upsert + $vectorDocuments = []; + + // Create chunk entities and vector documents foreach ($chunks as $index => $chunkContent) { + $chunkId = Uuid::v4(); + + // Create local chunk entity $chunk = new LoreDocumentChunk(); $chunk->setDocument($document); $chunk->setLarp($larp); @@ -146,13 +172,32 @@ public function indexLoreDocument(LarpLoreDocument $document): void $document->addChunk($chunk); $this->entityManager->persist($chunk); + + // Create vector document for external store + $vectorDocuments[] = VectorDocument::forLoreChunk( + entityId: $chunk->getId(), + larpId: $larp->getId(), + documentTitle: $document->getTitle(), + chunkContent: $chunkContent, + chunkIndex: $index, + embedding: $embeddings[$index], + embeddingModel: $this->embeddingProvider->getModelName(), + metadata: [ + 'document_id' => $document->getId()->toRfc4122(), + 'document_type' => $document->getType()->value, + ], + ); } + // Batch upsert to vector store + $this->vectorStore->upsertBatch($vectorDocuments); + $this->entityManager->flush(); $this->logger?->info('Lore document indexed successfully', [ 'document_id' => $document->getId()->toRfc4122(), 'chunk_count' => count($chunks), + 'vector_store' => $this->vectorStore->getProviderName(), ]); } @@ -174,14 +219,16 @@ public function reindexLarp(Larp $larp, callable $progressCallback = null): arra $this->logger?->info('Starting LARP reindex', [ 'larp_id' => $larp->getId()->toRfc4122(), 'total_objects' => $total, + 'vector_store' => $this->vectorStore->getProviderName(), ]); foreach ($storyObjects as $index => $storyObject) { try { $serializedContent = $this->serializer->serialize($storyObject); + $contentHash = hash('sha256', $serializedContent); $existingEmbedding = $this->embeddingRepository->findByStoryObject($storyObject); - if ($existingEmbedding && !$existingEmbedding->hasContentChanged($serializedContent)) { + if ($existingEmbedding && $existingEmbedding->getContentHash() === $contentHash) { $stats['skipped']++; } else { $this->indexStoryObject($storyObject); @@ -213,6 +260,10 @@ public function reindexLarp(Larp $larp, callable $progressCallback = null): arra */ public function deleteStoryObjectEmbedding(StoryObject $storyObject): void { + // Delete from external vector store + $this->vectorStore->delete($storyObject->getId()); + + // Delete local tracking entity $embedding = $this->embeddingRepository->findByStoryObject($storyObject); if ($embedding) { $this->entityManager->remove($embedding); @@ -220,6 +271,7 @@ public function deleteStoryObjectEmbedding(StoryObject $storyObject): void $this->logger?->debug('Embedding deleted', [ 'story_object_id' => $storyObject->getId()->toRfc4122(), + 'vector_store' => $this->vectorStore->getProviderName(), ]); } } @@ -234,6 +286,22 @@ public function generateQueryEmbedding(string $query): array return $this->embeddingProvider->embed($query); } + /** + * Check if vector store is available. + */ + public function isVectorStoreAvailable(): bool + { + return $this->vectorStore->isAvailable(); + } + + /** + * Get the vector store provider name. + */ + public function getVectorStoreProvider(): string + { + return $this->vectorStore->getProviderName(); + } + /** * Chunk content into overlapping segments. * diff --git a/src/Domain/StoryAI/Service/Query/VectorSearchService.php b/src/Domain/StoryAI/Service/Query/VectorSearchService.php index f25ba16..1a69f50 100644 --- a/src/Domain/StoryAI/Service/Query/VectorSearchService.php +++ b/src/Domain/StoryAI/Service/Query/VectorSearchService.php @@ -6,23 +6,23 @@ use App\Domain\Core\Entity\Larp; use App\Domain\StoryAI\DTO\SearchResult; -use App\Domain\StoryAI\Entity\LoreDocumentChunk; -use App\Domain\StoryAI\Entity\StoryObjectEmbedding; -use App\Domain\StoryAI\Repository\LoreDocumentChunkRepository; -use App\Domain\StoryAI\Repository\StoryObjectEmbeddingRepository; +use App\Domain\StoryAI\DTO\VectorDocument; +use App\Domain\StoryAI\DTO\VectorSearchResult; use App\Domain\StoryAI\Service\Embedding\EmbeddingService; +use App\Domain\StoryAI\Service\VectorStore\VectorStoreInterface; use Psr\Log\LoggerInterface; /** * Service for performing vector similarity searches across indexed content. + * + * This service follows CQRS principles by reading from the external vector store. */ readonly class VectorSearchService { public function __construct( - private EmbeddingService $embeddingService, - private StoryObjectEmbeddingRepository $storyObjectEmbeddingRepository, - private LoreDocumentChunkRepository $loreDocumentChunkRepository, - private ?LoggerInterface $logger = null, + private EmbeddingService $embeddingService, + private VectorStoreInterface $vectorStore, + private ?LoggerInterface $logger = null, ) { } @@ -43,20 +43,20 @@ public function search( 'query' => $query, 'larp_id' => $larp->getId()->toRfc4122(), 'limit' => $limit, + 'vector_store' => $this->vectorStore->getProviderName(), ]); - // Search story objects - $storyResults = $this->searchStoryObjects($queryEmbedding, $larp, $limit, $minSimilarity); - - // Search lore documents - $loreResults = $this->searchLoreDocuments($queryEmbedding, $larp, $limit, $minSimilarity); - - // Merge and sort by similarity - $allResults = array_merge($storyResults, $loreResults); - usort($allResults, fn (SearchResult $a, SearchResult $b) => $b->similarity <=> $a->similarity); + $results = $this->vectorStore->search( + embedding: $queryEmbedding, + larpId: $larp->getId(), + limit: $limit, + minSimilarity: $minSimilarity, + ); - // Return top results up to the limit - return array_slice($allResults, 0, $limit); + return array_map( + fn (VectorSearchResult $result) => $result->toSearchResult(), + $results + ); } /** @@ -65,20 +65,23 @@ public function search( * @return SearchResult[] */ public function searchStoryObjects( - array $queryEmbedding, + string $query, Larp $larp, int $limit = 10, float $minSimilarity = 0.5, ): array { - $results = $this->storyObjectEmbeddingRepository->findSimilar( - $queryEmbedding, - $larp, - $limit, - $minSimilarity + $queryEmbedding = $this->embeddingService->generateQueryEmbedding($query); + + $results = $this->vectorStore->search( + embedding: $queryEmbedding, + larpId: $larp->getId(), + limit: $limit, + minSimilarity: $minSimilarity, + filters: ['type' => VectorDocument::TYPE_STORY_OBJECT], ); return array_map( - fn (array $row) => $this->createStoryObjectResult($row['embedding'], $row['similarity']), + fn (VectorSearchResult $result) => $result->toSearchResult(), $results ); } @@ -89,26 +92,29 @@ public function searchStoryObjects( * @return SearchResult[] */ public function searchLoreDocuments( - array $queryEmbedding, + string $query, Larp $larp, int $limit = 10, float $minSimilarity = 0.5, ): array { - $results = $this->loreDocumentChunkRepository->findSimilar( - $queryEmbedding, - $larp, - $limit, - $minSimilarity + $queryEmbedding = $this->embeddingService->generateQueryEmbedding($query); + + $results = $this->vectorStore->search( + embedding: $queryEmbedding, + larpId: $larp->getId(), + limit: $limit, + minSimilarity: $minSimilarity, + filters: ['type' => VectorDocument::TYPE_LORE_CHUNK], ); return array_map( - fn (array $row) => $this->createLoreChunkResult($row['chunk'], $row['similarity']), + fn (VectorSearchResult $result) => $result->toSearchResult(), $results ); } /** - * Search by query string (generates embedding internally). + * Search with custom options. * * @return SearchResult[] */ @@ -119,62 +125,78 @@ public function searchByQuery( float $minSimilarity = 0.5, bool $includeStoryObjects = true, bool $includeLoreDocuments = true, + ?string $entityType = null, ): array { $queryEmbedding = $this->embeddingService->generateQueryEmbedding($query); - $results = []; + $filters = []; - if ($includeStoryObjects) { - $results = array_merge( - $results, - $this->searchStoryObjects($queryEmbedding, $larp, $limit, $minSimilarity) - ); + // Filter by document type + if ($includeStoryObjects && !$includeLoreDocuments) { + $filters['type'] = VectorDocument::TYPE_STORY_OBJECT; + } elseif (!$includeStoryObjects && $includeLoreDocuments) { + $filters['type'] = VectorDocument::TYPE_LORE_CHUNK; } - if ($includeLoreDocuments) { - $results = array_merge( - $results, - $this->searchLoreDocuments($queryEmbedding, $larp, $limit, $minSimilarity) - ); + // Filter by entity type (Character, Thread, Quest, etc.) + if ($entityType) { + $filters['entity_type'] = $entityType; } - // Sort by similarity - usort($results, fn (SearchResult $a, SearchResult $b) => $b->similarity <=> $a->similarity); + $results = $this->vectorStore->search( + embedding: $queryEmbedding, + larpId: $larp->getId(), + limit: $limit, + minSimilarity: $minSimilarity, + filters: $filters, + ); - return array_slice($results, 0, $limit); + return array_map( + fn (VectorSearchResult $result) => $result->toSearchResult(), + $results + ); } - private function createStoryObjectResult(StoryObjectEmbedding $embedding, float $similarity): SearchResult - { - $storyObject = $embedding->getStoryObject(); - - return new SearchResult( - type: SearchResult::TYPE_STORY_OBJECT, - id: $embedding->getId()->toRfc4122(), - title: $storyObject?->getTitle() ?? 'Unknown', - content: $embedding->getSerializedContent(), - similarity: $similarity, - entityId: $storyObject?->getId()->toRfc4122(), - entityType: $storyObject ? (new \ReflectionClass($storyObject))->getShortName() : null, + /** + * Search with a pre-computed embedding vector. + * + * @param array $embedding + * @return SearchResult[] + */ + public function searchByEmbedding( + array $embedding, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5, + array $filters = [], + ): array { + $results = $this->vectorStore->search( + embedding: $embedding, + larpId: $larp->getId(), + limit: $limit, + minSimilarity: $minSimilarity, + filters: $filters, + ); + + return array_map( + fn (VectorSearchResult $result) => $result->toSearchResult(), + $results ); } - private function createLoreChunkResult(LoreDocumentChunk $chunk, float $similarity): SearchResult + /** + * Check if the vector store is available for searches. + */ + public function isAvailable(): bool { - $document = $chunk->getDocument(); - - return new SearchResult( - type: SearchResult::TYPE_LORE_DOCUMENT, - id: $chunk->getId()->toRfc4122(), - title: $document?->getTitle() ?? 'Unknown Document', - content: $chunk->getContent(), - similarity: $similarity, - entityId: $document?->getId()->toRfc4122(), - entityType: $document?->getType()->getLabel(), - metadata: [ - 'chunk_index' => $chunk->getChunkIndex(), - 'document_type' => $document?->getType()->value, - ], - ); + return $this->vectorStore->isAvailable(); + } + + /** + * Get the vector store provider name. + */ + public function getProviderName(): string + { + return $this->vectorStore->getProviderName(); } } diff --git a/src/Domain/StoryAI/Service/VectorStore/NullVectorStore.php b/src/Domain/StoryAI/Service/VectorStore/NullVectorStore.php new file mode 100644 index 0000000..f52ade8 --- /dev/null +++ b/src/Domain/StoryAI/Service/VectorStore/NullVectorStore.php @@ -0,0 +1,83 @@ +logger?->debug('NullVectorStore: upsert ignored (vector store not configured)', [ + 'entity_id' => $document->entityId->toRfc4122(), + ]); + } + + public function upsertBatch(array $documents): void + { + $this->logger?->debug('NullVectorStore: upsertBatch ignored (vector store not configured)', [ + 'count' => count($documents), + ]); + } + + /** + * @return VectorSearchResult[] + */ + public function search( + array $embedding, + Uuid $larpId, + int $limit = 10, + float $minSimilarity = 0.5, + array $filters = [], + ): array { + $this->logger?->debug('NullVectorStore: search returned empty (vector store not configured)'); + return []; + } + + public function delete(Uuid $entityId): void + { + $this->logger?->debug('NullVectorStore: delete ignored (vector store not configured)', [ + 'entity_id' => $entityId->toRfc4122(), + ]); + } + + public function deleteByFilter(array $filter): int + { + $this->logger?->debug('NullVectorStore: deleteByFilter ignored (vector store not configured)'); + return 0; + } + + public function exists(Uuid $entityId): bool + { + return false; + } + + public function findByEntityId(Uuid $entityId): ?VectorDocument + { + return null; + } + + public function isAvailable(): bool + { + return false; + } + + public function getProviderName(): string + { + return 'null'; + } +} diff --git a/src/Domain/StoryAI/Service/VectorStore/SupabaseVectorStore.php b/src/Domain/StoryAI/Service/VectorStore/SupabaseVectorStore.php new file mode 100644 index 0000000..c350df3 --- /dev/null +++ b/src/Domain/StoryAI/Service/VectorStore/SupabaseVectorStore.php @@ -0,0 +1,305 @@ +upsertBatch([$document]); + } + + public function upsertBatch(array $documents): void + { + if (empty($documents)) { + return; + } + + $rows = array_map( + fn (VectorDocument $doc) => $this->documentToRow($doc), + $documents + ); + + $response = $this->request('POST', '/rest/v1/' . self::TABLE_NAME, [ + 'headers' => [ + 'Prefer' => 'resolution=merge-duplicates', + ], + 'json' => $rows, + ]); + + $this->logger?->debug('Upserted documents to Supabase', [ + 'count' => count($documents), + 'status' => $response['status'] ?? 'unknown', + ]); + } + + public function search( + array $embedding, + Uuid $larpId, + int $limit = 10, + float $minSimilarity = 0.5, + array $filters = [], + ): array { + // Call the RPC function for vector similarity search + $response = $this->request('POST', '/rest/v1/rpc/' . self::SEARCH_FUNCTION, [ + 'json' => [ + 'query_embedding' => $embedding, + 'larp_id_filter' => $larpId->toRfc4122(), + 'match_threshold' => $minSimilarity, + 'match_count' => $limit, + 'type_filter' => $filters['type'] ?? null, + 'entity_type_filter' => $filters['entity_type'] ?? null, + ], + ]); + + if (!isset($response['data']) || !is_array($response['data'])) { + $this->logger?->warning('Empty or invalid search response from Supabase', [ + 'response' => $response, + ]); + return []; + } + + return array_map( + fn (array $row) => $this->rowToSearchResult($row), + $response['data'] + ); + } + + public function delete(Uuid $entityId): void + { + $this->request('DELETE', '/rest/v1/' . self::TABLE_NAME, [ + 'query' => [ + 'entity_id' => 'eq.' . $entityId->toRfc4122(), + ], + ]); + + $this->logger?->debug('Deleted document from Supabase', [ + 'entity_id' => $entityId->toRfc4122(), + ]); + } + + public function deleteByFilter(array $filter): int + { + $query = []; + foreach ($filter as $key => $value) { + if ($value instanceof Uuid) { + $query[$key] = 'eq.' . $value->toRfc4122(); + } else { + $query[$key] = 'eq.' . $value; + } + } + + $response = $this->request('DELETE', '/rest/v1/' . self::TABLE_NAME, [ + 'headers' => [ + 'Prefer' => 'return=representation', + ], + 'query' => $query, + ]); + + $count = is_array($response['data'] ?? null) ? count($response['data']) : 0; + + $this->logger?->debug('Deleted documents by filter from Supabase', [ + 'filter' => $filter, + 'count' => $count, + ]); + + return $count; + } + + public function exists(Uuid $entityId): bool + { + $response = $this->request('GET', '/rest/v1/' . self::TABLE_NAME, [ + 'headers' => [ + 'Prefer' => 'count=exact', + ], + 'query' => [ + 'entity_id' => 'eq.' . $entityId->toRfc4122(), + 'select' => 'entity_id', + ], + ]); + + return ($response['count'] ?? 0) > 0; + } + + public function findByEntityId(Uuid $entityId): ?VectorDocument + { + $response = $this->request('GET', '/rest/v1/' . self::TABLE_NAME, [ + 'query' => [ + 'entity_id' => 'eq.' . $entityId->toRfc4122(), + 'select' => '*', + ], + ]); + + if (empty($response['data']) || !is_array($response['data'])) { + return null; + } + + $row = $response['data'][0] ?? null; + if (!$row) { + return null; + } + + return $this->rowToDocument($row); + } + + public function isAvailable(): bool + { + return !empty($this->supabaseUrl) && !empty($this->supabaseServiceKey); + } + + public function getProviderName(): string + { + return 'supabase'; + } + + /** + * @param array $options + * @return array + */ + private function request(string $method, string $path, array $options = []): array + { + $url = rtrim($this->supabaseUrl, '/') . $path; + + $defaultHeaders = [ + 'apikey' => $this->supabaseServiceKey, + 'Authorization' => 'Bearer ' . $this->supabaseServiceKey, + 'Content-Type' => 'application/json', + ]; + + $options['headers'] = array_merge($defaultHeaders, $options['headers'] ?? []); + + try { + $response = $this->httpClient->request($method, $url, $options); + $statusCode = $response->getStatusCode(); + $content = $response->getContent(false); + + $data = json_decode($content, true); + + // Check for Supabase error response + if ($statusCode >= 400) { + $this->logger?->error('Supabase API error', [ + 'status' => $statusCode, + 'url' => $url, + 'error' => $data['message'] ?? $content, + ]); + throw new \RuntimeException( + sprintf('Supabase API error: %s', $data['message'] ?? $content) + ); + } + + // Parse count header if present + $countHeader = $response->getHeaders(false)['content-range'][0] ?? null; + $count = null; + if ($countHeader && preg_match('/\/(\d+)$/', $countHeader, $matches)) { + $count = (int) $matches[1]; + } + + return [ + 'status' => $statusCode, + 'data' => $data, + 'count' => $count, + ]; + } catch (\Throwable $e) { + $this->logger?->error('Supabase request failed', [ + 'url' => $url, + 'method' => $method, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * @return array + */ + private function documentToRow(VectorDocument $document): array + { + return [ + 'id' => Uuid::v4()->toRfc4122(), + 'entity_id' => $document->entityId->toRfc4122(), + 'larp_id' => $document->larpId->toRfc4122(), + 'entity_type' => $document->entityType, + 'type' => $document->type, + 'title' => $document->title, + 'serialized_content' => $document->serializedContent, + 'content_hash' => $document->contentHash, + 'embedding' => '[' . implode(',', $document->embedding) . ']', + 'embedding_model' => $document->embeddingModel, + 'metadata' => json_encode($document->metadata), + ]; + } + + /** + * @param array $row + */ + private function rowToDocument(array $row): VectorDocument + { + $embedding = $row['embedding']; + if (is_string($embedding)) { + // Parse pgvector string format: [0.1,0.2,0.3] + $embedding = json_decode($embedding, true) ?? []; + } + + return new VectorDocument( + entityId: Uuid::fromString($row['entity_id']), + larpId: Uuid::fromString($row['larp_id']), + entityType: $row['entity_type'], + type: $row['type'], + title: $row['title'], + serializedContent: $row['serialized_content'], + contentHash: $row['content_hash'], + embedding: $embedding, + embeddingModel: $row['embedding_model'] ?? 'text-embedding-3-small', + metadata: is_string($row['metadata']) ? json_decode($row['metadata'], true) : ($row['metadata'] ?? []), + ); + } + + /** + * @param array $row + */ + private function rowToSearchResult(array $row): VectorSearchResult + { + return new VectorSearchResult( + entityId: Uuid::fromString($row['entity_id']), + larpId: Uuid::fromString($row['larp_id']), + entityType: $row['entity_type'], + type: $row['type'], + title: $row['title'], + content: $row['serialized_content'], + similarity: (float) $row['similarity'], + metadata: is_string($row['metadata']) ? json_decode($row['metadata'], true) : ($row['metadata'] ?? []), + ); + } +} diff --git a/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php b/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php new file mode 100644 index 0000000..3e68918 --- /dev/null +++ b/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php @@ -0,0 +1,98 @@ + SupabaseVectorStore + * - null:// -> NullVectorStore (disabled) + * - (empty/not set) -> NullVectorStore + */ +final readonly class VectorStoreFactory +{ + public function __construct( + private HttpClientInterface $httpClient, + private ?LoggerInterface $logger = null, + ) { + } + + public function create(string $dsn): VectorStoreInterface + { + if (empty($dsn) || $dsn === 'null://') { + $this->logger?->info('Vector store disabled (DSN not configured)'); + return new NullVectorStore($this->logger); + } + + $parsed = $this->parseDsn($dsn); + + return match ($parsed['scheme']) { + 'supabase' => $this->createSupabase($parsed), + 'null' => new NullVectorStore($this->logger), + default => throw new \InvalidArgumentException( + sprintf('Unknown vector store provider: %s', $parsed['scheme']) + ), + }; + } + + /** + * @param array $parsed + */ + private function createSupabase(array $parsed): SupabaseVectorStore + { + $serviceKey = $parsed['user'] ?? ''; + $projectRef = $parsed['host'] ?? ''; + + if (empty($serviceKey) || empty($projectRef)) { + throw new \InvalidArgumentException( + 'Supabase DSN must include service key and project reference: supabase://SERVICE_KEY@PROJECT_REF' + ); + } + + // Support both full URL and just project reference + $url = str_contains($projectRef, '.') + ? 'https://' . $projectRef + : 'https://' . $projectRef . '.supabase.co'; + + $this->logger?->info('Creating Supabase vector store', [ + 'url' => $url, + ]); + + return new SupabaseVectorStore( + httpClient: $this->httpClient, + supabaseUrl: $url, + supabaseServiceKey: $serviceKey, + logger: $this->logger, + ); + } + + /** + * @return array + */ + private function parseDsn(string $dsn): array + { + $parts = parse_url($dsn); + + if ($parts === false) { + throw new \InvalidArgumentException( + sprintf('Invalid vector store DSN: %s', $dsn) + ); + } + + return [ + 'scheme' => $parts['scheme'] ?? '', + 'user' => isset($parts['user']) ? urldecode($parts['user']) : null, + 'pass' => isset($parts['pass']) ? urldecode($parts['pass']) : null, + 'host' => $parts['host'] ?? null, + 'port' => isset($parts['port']) ? (string) $parts['port'] : null, + 'path' => $parts['path'] ?? null, + 'query' => $parts['query'] ?? null, + ]; + } +} diff --git a/src/Domain/StoryAI/Service/VectorStore/VectorStoreInterface.php b/src/Domain/StoryAI/Service/VectorStore/VectorStoreInterface.php new file mode 100644 index 0000000..a27d41a --- /dev/null +++ b/src/Domain/StoryAI/Service/VectorStore/VectorStoreInterface.php @@ -0,0 +1,80 @@ + $embedding Query embedding vector + * @param Uuid $larpId Filter by LARP + * @param int $limit Maximum results to return + * @param float $minSimilarity Minimum cosine similarity threshold (0-1) + * @param array $filters Additional metadata filters + * @return VectorSearchResult[] + */ + public function search( + array $embedding, + Uuid $larpId, + int $limit = 10, + float $minSimilarity = 0.5, + array $filters = [], + ): array; + + /** + * Delete a document by its entity ID. + */ + public function delete(Uuid $entityId): void; + + /** + * Delete all documents matching a filter. + * + * @param array $filter Filter criteria (e.g., ['larp_id' => $uuid]) + */ + public function deleteByFilter(array $filter): int; + + /** + * Check if a document exists for the given entity. + */ + public function exists(Uuid $entityId): bool; + + /** + * Get document by entity ID (for cache checking). + */ + public function findByEntityId(Uuid $entityId): ?VectorDocument; + + /** + * Check if the vector store is available/configured. + */ + public function isAvailable(): bool; + + /** + * Get the name of the vector store provider. + */ + public function getProviderName(): string; +} From 41ebf9a3b7c452dab0565d10190540e45f8fa681 Mon Sep 17 00:00:00 2001 From: TomaszB Date: Sun, 1 Feb 2026 21:05:47 +0100 Subject: [PATCH 3/5] Remove unused `LarpLoreDocument` import in `ReindexStoryAICommand` and update `.serena/project.yml` with refined configurations. --- src/Domain/StoryAI/Command/ReindexStoryAICommand.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Domain/StoryAI/Command/ReindexStoryAICommand.php b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php index def8671..04c5517 100644 --- a/src/Domain/StoryAI/Command/ReindexStoryAICommand.php +++ b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php @@ -5,7 +5,6 @@ namespace App\Domain\StoryAI\Command; use App\Domain\Core\Entity\Larp; -use App\Domain\StoryAI\Entity\LarpLoreDocument; use App\Domain\StoryAI\Message\ReindexLarpMessage; use App\Domain\StoryAI\Repository\LarpLoreDocumentRepository; use App\Domain\StoryAI\Repository\StoryObjectEmbeddingRepository; From 2f521604db6236945f9081840eb4212453ade480 Mon Sep 17 00:00:00 2001 From: TomaszB Date: Mon, 2 Feb 2026 09:20:50 +0100 Subject: [PATCH 4/5] Remove StoryAI-related entities, services, and handlers no longer in use. --- config/packages/messenger.yaml | 1 - docs/technical/STORY_AI.md | 283 ++++++++---------- .../StoryAI/Command/ReindexStoryAICommand.php | 75 ++--- src/Domain/StoryAI/DTO/VectorDocument.php | 2 +- .../StoryAI/Entity/Enum/LoreDocumentType.php | 54 ---- .../StoryAI/Entity/LarpLoreDocument.php | 250 ---------------- .../StoryAI/Entity/LoreDocumentChunk.php | 198 ------------ .../StoryAI/Entity/StoryObjectEmbedding.php | 180 ----------- .../Message/IndexLoreDocumentMessage.php | 18 -- .../IndexLoreDocumentHandler.php | 53 ---- .../Repository/LarpLoreDocumentRepository.php | 111 ------- .../LoreDocumentChunkRepository.php | 121 -------- .../StoryObjectEmbeddingRepository.php | 140 --------- .../Service/Embedding/EmbeddingService.php | 268 ++--------------- .../Embedding/StoryObjectSerializer.php | 16 +- .../StoryAI/Service/Query/ContextBuilder.php | 40 +-- src/PHPStan/Rules/DomainBoundaryRule.php | 1 + 17 files changed, 181 insertions(+), 1630 deletions(-) delete mode 100644 src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php delete mode 100644 src/Domain/StoryAI/Entity/LarpLoreDocument.php delete mode 100644 src/Domain/StoryAI/Entity/LoreDocumentChunk.php delete mode 100644 src/Domain/StoryAI/Entity/StoryObjectEmbedding.php delete mode 100644 src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php delete mode 100644 src/Domain/StoryAI/MessageHandler/IndexLoreDocumentHandler.php delete mode 100644 src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php delete mode 100644 src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php delete mode 100644 src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index bee14fd..ff32a4a 100755 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -28,4 +28,3 @@ framework: # StoryAI embedding messages App\Domain\StoryAI\Message\IndexStoryObjectMessage: async App\Domain\StoryAI\Message\ReindexLarpMessage: async - App\Domain\StoryAI\Message\IndexLoreDocumentMessage: async diff --git a/docs/technical/STORY_AI.md b/docs/technical/STORY_AI.md index 1e32a8e..1a7a160 100644 --- a/docs/technical/STORY_AI.md +++ b/docs/technical/STORY_AI.md @@ -5,28 +5,52 @@ AI-powered assistant for LARP story management using RAG (Retrieval-Augmented Ge ## Overview StoryAI provides intelligent querying and analysis of LARP story content by: -1. **Indexing** story objects and lore documents into vector embeddings +1. **Indexing** story objects into vector embeddings (stored in Supabase) 2. **Searching** content using semantic similarity 3. **Generating** AI responses with relevant context ## Architecture +``` +┌─────────────────────────┐ ┌─────────────────────────┐ +│ Main PostgreSQL │ │ Supabase │ +├─────────────────────────┤ ├─────────────────────────┤ +│ • LARPs │ │ larpilot_embeddings │ +│ • StoryObjects │──── index ──▶│ ├─ entity_id │ +│ • Users │ │ ├─ larp_id │ +│ • Participants │ │ ├─ embedding (vector) │ +│ • ... │ │ ├─ serialized_content │ +└─────────────────────────┘ │ ├─ content_hash │ + │ └─ metadata │ + └─────────────────────────┘ +``` + +**Key principle:** All AI/embedding data lives in Supabase. The main application database has no knowledge of AI features. + +### Directory Structure + ``` src/Domain/StoryAI/ -├── Entity/ -│ ├── StoryObjectEmbedding.php # Vector embedding for story objects -│ ├── LarpLoreDocument.php # Custom lore/setting documents -│ └── LoreDocumentChunk.php # Chunked document for embeddings +├── DTO/ +│ ├── VectorDocument.php # Document for vector store +│ ├── VectorSearchResult.php # Search result from vector store +│ ├── SearchResult.php # Unified search result +│ └── AIQueryResult.php # RAG query response ├── Service/ │ ├── Embedding/ -│ │ ├── EmbeddingService.php # Indexing logic -│ │ └── StoryObjectSerializer.php +│ │ ├── EmbeddingService.php # Indexing logic +│ │ └── StoryObjectSerializer.php # Converts StoryObjects to text │ ├── Query/ -│ │ ├── RAGQueryService.php # Main query service -│ │ ├── VectorSearchService.php # Similarity search -│ │ └── ContextBuilder.php # Context assembly +│ │ ├── RAGQueryService.php # Main query service +│ │ ├── VectorSearchService.php # Similarity search +│ │ └── ContextBuilder.php # Context assembly for LLM +│ ├── VectorStore/ +│ │ ├── VectorStoreInterface.php # Vector store abstraction +│ │ ├── SupabaseVectorStore.php # Supabase implementation +│ │ ├── NullVectorStore.php # No-op for testing/disabled +│ │ └── VectorStoreFactory.php # Creates appropriate store │ └── Provider/ -│ ├── OpenAIProvider.php # LLM/embedding provider +│ ├── OpenAIProvider.php # LLM/embedding provider │ ├── LLMProviderInterface.php │ └── EmbeddingProviderInterface.php ├── Controller/API/ @@ -52,7 +76,7 @@ All endpoints are under `/api/larp/{larp}/ai/`: ### Example: Query ```bash -curl -X POST /api/larp/123/ai/query \ +curl -X POST /api/larp/{larp-uuid}/ai/query \ -H "Content-Type: application/json" \ -d '{"query": "What is the history of the Northern Kingdom?"}' ``` @@ -62,8 +86,8 @@ Response: { "answer": "The Northern Kingdom was founded in...", "sources": [ - {"type": "character", "id": 1, "title": "King Aldric"}, - {"type": "lore_document", "id": 5, "title": "World History"} + {"type": "Character", "id": "uuid", "title": "King Aldric"}, + {"type": "Thread", "id": "uuid", "title": "The Northern Wars"} ] } ``` @@ -77,114 +101,61 @@ Story objects are automatically indexed when created/updated via `StoryObjectInd ### Manual Reindex ```bash -# Reindex all LARPs (async via Messenger) -php bin/console app:story-ai:reindex - -# Reindex specific LARP synchronously -php bin/console app:story-ai:reindex --larp=123 --sync -``` - -## Lore Documents - -Upload custom setting/lore content that AI uses for context. +# Reindex a specific LARP (synchronous) +php bin/console app:story-ai:reindex -**Document Types:** -- Setting Overview -- World History -- Magic Rules -- Culture Notes -- Geography -- Politics -- Religion -- Economics -- General +# Force reindex (even if content unchanged) +php bin/console app:story-ai:reindex --force -Documents are chunked (500 chars, 100 overlap) and embedded for retrieval. +# Async via Messenger +php bin/console app:story-ai:reindex --async +``` ## Setup Guide ### Prerequisites -- PostgreSQL 15+ with **pgvector** extension - OpenAI API key +- Supabase project with pgvector extension - Symfony Messenger configured (for async indexing) --- -### Step 1: Install pgvector Extension - -**Production (managed PostgreSQL):** - -Most managed PostgreSQL services (AWS RDS, Supabase, Neon) support pgvector. Enable it via their dashboard or run: - -```sql -CREATE EXTENSION IF NOT EXISTS vector; -``` - -**Local/Docker:** - -The project's Docker setup includes pgvector. If using a custom setup: - -```bash -# Ubuntu/Debian -sudo apt install postgresql-15-pgvector +### Step 1: Set Up Supabase -# Or build from source -git clone https://github.com/pgvector/pgvector.git -cd pgvector && make && sudo make install -``` +See [VECTOR_STORE_SETUP.md](VECTOR_STORE_SETUP.md) for detailed instructions on: +- Creating the `larpilot_embeddings` table +- Setting up the `search_embeddings` RPC function +- Configuring pgvector indexes --- ### Step 2: Configure Environment Variables -Add to `.env.local` (local) or your production secrets: +Add to `.env.local`: ```env -# Required: OpenAI API key +# OpenAI OPENAI_API_KEY=sk-your-api-key-here - -# Optional: Model configuration (defaults shown) OPENAI_EMBEDDING_MODEL=text-embedding-3-small OPENAI_COMPLETION_MODEL=gpt-4o-mini -``` - -**Model Options:** - -| Model | Use Case | Cost | -|-------|----------|------| -| `text-embedding-3-small` | Embeddings (default) | Low | -| `text-embedding-3-large` | Higher quality embeddings | Medium | -| `gpt-4o-mini` | Completions (default) | Low | -| `gpt-4o` | Higher quality responses | High | ---- - -### Step 3: Run Database Migrations - -```bash -# Local (Docker) -make migrate +# Supabase Vector Store +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_KEY=your-service-role-key -# Production -php bin/console doctrine:migrations:migrate --no-interaction +# Vector store provider (supabase or null) +VECTOR_STORE_PROVIDER=supabase ``` -This creates: -- `story_object_embedding` - Vector embeddings for story objects -- `larp_lore_document` - Lore document metadata -- `lore_document_chunk` - Chunked document embeddings with HNSW index - --- -### Step 4: Configure Message Queue +### Step 3: Configure Message Queue -StoryAI uses Symfony Messenger for async indexing. The routing is pre-configured in `config/packages/messenger.yaml`. +StoryAI uses Symfony Messenger for async indexing. **Local Development:** -Use Doctrine transport (default in `.env`): - ```env MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ``` @@ -192,57 +163,36 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 Run the worker: ```bash -# In a separate terminal docker compose exec php php bin/console messenger:consume async -vv ``` **Production:** -Use a dedicated message broker: - ```env # Redis MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages - -# RabbitMQ -MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages -``` - -Run workers via supervisor: - -```ini -[program:messenger-worker] -command=php /var/www/bin/console messenger:consume async --time-limit=3600 -numprocs=2 -autostart=true -autorestart=true ``` --- -### Step 5: Initial Indexing +### Step 4: Initial Indexing -Index existing story objects for a LARP: +Index existing story objects: ```bash -# Async (recommended for large LARPs) -php bin/console app:story-ai:reindex --larp= +# Get your LARP UUID +php bin/console doctrine:query:sql "SELECT id, title FROM larp LIMIT 5" -# Sync (for testing/small datasets) -php bin/console app:story-ai:reindex --larp= --sync - -# Reindex all LARPs -php bin/console app:story-ai:reindex +# Index the LARP +php bin/console app:story-ai:reindex ``` --- -### Step 6: Verify Setup - -Test the API: +### Step 5: Verify Setup ```bash -curl -X POST http://localhost/api/larp//ai/search \ +curl -X POST http://localhost/api/larp//ai/search \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"query": "test"}' @@ -250,53 +200,41 @@ curl -X POST http://localhost/api/larp//ai/search \ --- -### Local/Test Environment Notes - -**Test environment** (`.env.test`): - -```env -# Use sync transport for tests (no worker needed) -MESSENGER_TRANSPORT_DSN=sync:// - -# Use test API key or mock -OPENAI_API_KEY=test-key -``` +## Key Services -**Disable AI in tests:** +### EmbeddingService -For unit/functional tests that don't need AI, mock the services: +Handles indexing of story objects: ```php -$ragQueryService = $this->createMock(RAGQueryService::class); -$ragQueryService->method('query')->willReturn(new AIQueryResult('Mock answer', [])); -``` - -**Cost considerations:** - -- Embedding calls: ~$0.02 per 1M tokens (text-embedding-3-small) -- Completion calls: ~$0.15 per 1M input tokens (gpt-4o-mini) -- Use `--sync` flag sparingly in development to control costs +// Index a single story object +$embeddingService->indexStoryObject($character); ---- +// Reindex all story objects in a LARP +$stats = $embeddingService->reindexLarp($larp); +// Returns: ['indexed' => 42, 'skipped' => 10, 'errors' => 0] -## Configuration Reference +// Delete embedding when story object is deleted +$embeddingService->deleteStoryObjectEmbedding($character); -Full environment variables: +// Generate embedding for a query +$vector = $embeddingService->generateQueryEmbedding("Who is the king?"); +``` -```env -# Required -OPENAI_API_KEY=sk-... +### VectorSearchService -# Optional (with defaults) -OPENAI_EMBEDDING_MODEL=text-embedding-3-small -OPENAI_COMPLETION_MODEL=gpt-4o-mini +Performs similarity search: -# Message queue -MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +```php +$results = $vectorSearchService->search( + larp: $larp, + query: "characters involved in the rebellion", + limit: 10, + minSimilarity: 0.5 +); +// Returns SearchResult[] with similarity scores ``` -## Key Services - ### RAGQueryService Main entry point for AI queries: @@ -306,21 +244,38 @@ $result = $ragQueryService->query($larp, "Who are the main antagonists?"); // Returns AIQueryResult with answer and sources ``` -### EmbeddingService +### ContextBuilder -Handles indexing: +Assembles context for LLM prompts: ```php -$embeddingService->indexStoryObject($character); -$embeddingService->indexLoreDocument($document); -$embeddingService->reindexLarp($larp); +$context = $contextBuilder->buildContext($searchResults, $larp, maxTokens: 12000); +$systemPrompt = $contextBuilder->buildSystemPrompt($larp); ``` -### VectorSearchService +--- -Performs similarity search: +## Cost Considerations -```php -$results = $vectorSearchService->search($larp, $queryEmbedding, limit: 10); -// Returns SearchResult[] with scores -``` +| Operation | Model | Cost (approx) | +|-----------|-------|---------------| +| Embedding | text-embedding-3-small | $0.02 / 1M tokens | +| Embedding | text-embedding-3-large | $0.13 / 1M tokens | +| Completion | gpt-4o-mini | $0.15 / 1M input tokens | +| Completion | gpt-4o | $2.50 / 1M input tokens | + +**Tips:** +- Use `--force` flag sparingly (avoids unnecessary re-embeddings) +- Content hash comparison prevents redundant API calls +- Supabase free tier: 500MB database, sufficient for most LARPs + +--- + +## Future: Lore Documents + +Custom lore/setting documents (world history, magic rules, etc.) can be added later by: +1. Uploading text content via a simple form +2. Chunking the content (the chunking logic exists in git history) +3. Storing chunks directly in Supabase as `type: 'lore_chunk'` + +No additional database tables needed - the `larpilot_embeddings` table handles both story objects and lore chunks via the `type` field. diff --git a/src/Domain/StoryAI/Command/ReindexStoryAICommand.php b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php index 04c5517..5611bd1 100644 --- a/src/Domain/StoryAI/Command/ReindexStoryAICommand.php +++ b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php @@ -6,9 +6,8 @@ use App\Domain\Core\Entity\Larp; use App\Domain\StoryAI\Message\ReindexLarpMessage; -use App\Domain\StoryAI\Repository\LarpLoreDocumentRepository; -use App\Domain\StoryAI\Repository\StoryObjectEmbeddingRepository; use App\Domain\StoryAI\Service\Embedding\EmbeddingService; +use App\Domain\StoryAI\Service\VectorStore\VectorStoreInterface; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -22,15 +21,14 @@ #[AsCommand( name: 'app:story-ai:reindex', - description: 'Reindex LARP content for AI search', + description: 'Reindex LARP story objects for AI search', )] final class ReindexStoryAICommand extends Command { public function __construct( private readonly EmbeddingService $embeddingService, private readonly EntityManagerInterface $entityManager, - private readonly StoryObjectEmbeddingRepository $embeddingRepository, - private readonly LarpLoreDocumentRepository $loreDocumentRepository, + private readonly VectorStoreInterface $vectorStore, private readonly MessageBusInterface $messageBus, ) { parent::__construct(); @@ -41,7 +39,6 @@ protected function configure(): void $this ->addArgument('larp-id', InputArgument::REQUIRED, 'The LARP ID to reindex (UUID)') ->addOption('async', 'a', InputOption::VALUE_NONE, 'Process indexing asynchronously via messenger') - ->addOption('include-lore', 'l', InputOption::VALUE_NONE, 'Also reindex lore documents') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force reindex even if content unchanged'); } @@ -67,37 +64,35 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->title(sprintf('Reindexing LARP: %s', $larp->getTitle())); - // Show current stats - $existingCount = $this->embeddingRepository->countByLarp($larp); - $loreDocCount = $this->loreDocumentRepository->countActiveByLarp($larp); - + // Show vector store status $io->info([ - sprintf('Existing embeddings: %d', $existingCount), - sprintf('Lore documents: %d', $loreDocCount), + sprintf('Vector store: %s', $this->vectorStore->getProviderName()), + sprintf('Available: %s', $this->vectorStore->isAvailable() ? 'Yes' : 'No'), ]); + if (!$this->vectorStore->isAvailable()) { + $io->error('Vector store is not available. Check your configuration.'); + return Command::FAILURE; + } + if ($input->getOption('async')) { - return $this->processAsync($io, $larp, $input->getOption('include-lore')); + return $this->processAsync($io, $larp); } - return $this->processSync($io, $larp, $input->getOption('include-lore'), $input->getOption('force')); + return $this->processSync($io, $larp, $input->getOption('force')); } - private function processAsync(SymfonyStyle $io, Larp $larp, bool $includeLore): int + private function processAsync(SymfonyStyle $io, Larp $larp): int { $io->section('Dispatching async reindex message'); $this->messageBus->dispatch(new ReindexLarpMessage($larp->getId())); $io->success('Reindex message dispatched. Run messenger:consume to process.'); - if ($includeLore) { - $io->note('Lore documents will need to be reindexed separately in async mode.'); - } - return Command::SUCCESS; } - private function processSync(SymfonyStyle $io, Larp $larp, bool $includeLore, bool $force): int + private function processSync(SymfonyStyle $io, Larp $larp, bool $force): int { $io->section('Indexing story objects'); @@ -110,7 +105,8 @@ function (int $current, int $total, $storyObject) use ($progressBar) { $progressBar->setMaxSteps($total); $progressBar->setProgress($current); $progressBar->setMessage($storyObject->getTitle()); - } + }, + $force ); $progressBar->finish(); @@ -122,43 +118,6 @@ function (int $current, int $total, $storyObject) use ($progressBar) { sprintf('Errors: %d', $stats['errors']), ]); - if ($includeLore) { - $io->section('Indexing lore documents'); - - $documents = $this->loreDocumentRepository->findActiveByLarp($larp); - - if (empty($documents)) { - $io->note('No active lore documents to index.'); - } else { - $progressBar = $io->createProgressBar(count($documents)); - $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%'); - - $loreStats = ['indexed' => 0, 'errors' => 0]; - - foreach ($documents as $document) { - $progressBar->setMessage($document->getTitle()); - - try { - $this->embeddingService->indexLoreDocument($document); - $loreStats['indexed']++; - } catch (\Throwable $e) { - $loreStats['errors']++; - $io->warning(sprintf('Error indexing "%s": %s', $document->getTitle(), $e->getMessage())); - } - - $progressBar->advance(); - } - - $progressBar->finish(); - $io->newLine(2); - - $io->success([ - sprintf('Lore documents indexed: %d', $loreStats['indexed']), - sprintf('Errors: %d', $loreStats['errors']), - ]); - } - } - return $stats['errors'] > 0 ? Command::FAILURE : Command::SUCCESS; } } diff --git a/src/Domain/StoryAI/DTO/VectorDocument.php b/src/Domain/StoryAI/DTO/VectorDocument.php index 6d5277f..5048a05 100644 --- a/src/Domain/StoryAI/DTO/VectorDocument.php +++ b/src/Domain/StoryAI/DTO/VectorDocument.php @@ -16,7 +16,7 @@ public const TYPE_LORE_CHUNK = 'lore_chunk'; /** - * @param Uuid $entityId The ID of the source entity (StoryObject or LoreDocumentChunk) + * @param Uuid $entityId The ID of the source entity (StoryObject UUID or generated UUID for lore chunks) * @param Uuid $larpId The LARP this document belongs to * @param string $entityType The type of entity (Character, Thread, Quest, etc.) * @param string $type Document type (story_object or lore_chunk) diff --git a/src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php b/src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php deleted file mode 100644 index 0fe17e5..0000000 --- a/src/Domain/StoryAI/Entity/Enum/LoreDocumentType.php +++ /dev/null @@ -1,54 +0,0 @@ - 'Setting Overview', - self::WORLD_HISTORY => 'World History', - self::MAGIC_RULES => 'Magic/Power Rules', - self::TECHNOLOGY_RULES => 'Technology Rules', - self::CULTURE_NOTES => 'Culture Notes', - self::GEOGRAPHY => 'Geography', - self::POLITICS => 'Politics & Governance', - self::RELIGION => 'Religion & Beliefs', - self::ECONOMICS => 'Economics & Trade', - self::GENERAL => 'General', - }; - } - - public function getDescription(): string - { - return match ($this) { - self::SETTING_OVERVIEW => 'High-level overview of the game world and setting', - self::WORLD_HISTORY => 'Historical events, timeline, and past eras', - self::MAGIC_RULES => 'Rules and lore about magic, powers, or supernatural abilities', - self::TECHNOLOGY_RULES => 'Available technology level and special tech rules', - self::CULTURE_NOTES => 'Cultural practices, customs, and social norms', - self::GEOGRAPHY => 'Maps, locations, regions, and physical world details', - self::POLITICS => 'Political structures, factions, and governance', - self::RELIGION => 'Religious systems, deities, and spiritual beliefs', - self::ECONOMICS => 'Trade, currency, resources, and economic systems', - self::GENERAL => 'Other lore and setting information', - }; - } -} diff --git a/src/Domain/StoryAI/Entity/LarpLoreDocument.php b/src/Domain/StoryAI/Entity/LarpLoreDocument.php deleted file mode 100644 index c940bce..0000000 --- a/src/Domain/StoryAI/Entity/LarpLoreDocument.php +++ /dev/null @@ -1,250 +0,0 @@ - - */ - #[ORM\OneToMany( - targetEntity: LoreDocumentChunk::class, - mappedBy: 'document', - cascade: ['persist', 'remove'], - orphanRemoval: true - )] - #[ORM\OrderBy(['chunkIndex' => 'ASC'])] - private Collection $chunks; - - public function __construct() - { - $this->id = Uuid::v4(); - $this->chunks = new ArrayCollection(); - } - - public function getLarp(): ?Larp - { - return $this->larp; - } - - public function setLarp(?Larp $larp): self - { - $this->larp = $larp; - return $this; - } - - public function getTitle(): string - { - return $this->title; - } - - public function setTitle(string $title): self - { - $this->title = $title; - return $this; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function setDescription(?string $description): self - { - $this->description = $description; - return $this; - } - - public function getType(): LoreDocumentType - { - return $this->type; - } - - public function setType(LoreDocumentType $type): self - { - $this->type = $type; - return $this; - } - - public function getContent(): string - { - return $this->content; - } - - public function setContent(string $content): self - { - $this->content = $content; - return $this; - } - - public function getPriority(): int - { - return $this->priority; - } - - public function setPriority(int $priority): self - { - $this->priority = $priority; - return $this; - } - - public function isAlwaysInclude(): bool - { - return $this->alwaysInclude; - } - - public function setAlwaysInclude(bool $alwaysInclude): self - { - $this->alwaysInclude = $alwaysInclude; - return $this; - } - - public function isActive(): bool - { - return $this->active; - } - - public function setActive(bool $active): self - { - $this->active = $active; - return $this; - } - - public function getCreatedBy(): ?User - { - return $this->createdBy; - } - - public function setCreatedBy(?User $createdBy): self - { - $this->createdBy = $createdBy; - return $this; - } - - /** - * @return Collection - */ - public function getChunks(): Collection - { - return $this->chunks; - } - - public function addChunk(LoreDocumentChunk $chunk): self - { - if (!$this->chunks->contains($chunk)) { - $this->chunks->add($chunk); - $chunk->setDocument($this); - } - return $this; - } - - public function removeChunk(LoreDocumentChunk $chunk): self - { - if ($this->chunks->removeElement($chunk)) { - if ($chunk->getDocument() === $this) { - $chunk->setDocument(null); - } - } - return $this; - } - - public function clearChunks(): self - { - $this->chunks->clear(); - return $this; - } - - /** - * Estimate character count (for chunking decisions). - */ - public function getContentLength(): int - { - return strlen($this->content); - } -} diff --git a/src/Domain/StoryAI/Entity/LoreDocumentChunk.php b/src/Domain/StoryAI/Entity/LoreDocumentChunk.php deleted file mode 100644 index b45f3ce..0000000 --- a/src/Domain/StoryAI/Entity/LoreDocumentChunk.php +++ /dev/null @@ -1,198 +0,0 @@ - - */ - #[ORM\Column(type: Types::JSON)] - private array $embedding = []; - - /** - * The model used to generate this embedding. - */ - #[ORM\Column(length: 100)] - private string $embeddingModel = 'text-embedding-3-small'; - - /** - * Dimensions of the embedding vector. - */ - #[ORM\Column(type: Types::INTEGER)] - private int $dimensions = 1536; - - /** - * Token count of this chunk. - */ - #[ORM\Column(type: Types::INTEGER, nullable: true)] - private ?int $tokenCount = null; - - public function __construct() - { - $this->id = Uuid::v4(); - } - - public function getDocument(): ?LarpLoreDocument - { - return $this->document; - } - - public function setDocument(?LarpLoreDocument $document): self - { - $this->document = $document; - if ($document) { - $this->larp = $document->getLarp(); - } - return $this; - } - - public function getLarp(): ?Larp - { - return $this->larp; - } - - public function setLarp(?Larp $larp): self - { - $this->larp = $larp; - return $this; - } - - public function getContent(): string - { - return $this->content; - } - - public function setContent(string $content): self - { - $this->content = $content; - $this->contentHash = hash('sha256', $content); - return $this; - } - - public function getChunkIndex(): int - { - return $this->chunkIndex; - } - - public function setChunkIndex(int $chunkIndex): self - { - $this->chunkIndex = $chunkIndex; - return $this; - } - - public function getContentHash(): string - { - return $this->contentHash; - } - - /** - * @return array - */ - public function getEmbedding(): array - { - return $this->embedding; - } - - /** - * @param array $embedding - */ - public function setEmbedding(array $embedding): self - { - $this->embedding = $embedding; - $this->dimensions = count($embedding); - return $this; - } - - public function getEmbeddingModel(): string - { - return $this->embeddingModel; - } - - public function setEmbeddingModel(string $embeddingModel): self - { - $this->embeddingModel = $embeddingModel; - return $this; - } - - public function getDimensions(): int - { - return $this->dimensions; - } - - public function getTokenCount(): ?int - { - return $this->tokenCount; - } - - public function setTokenCount(?int $tokenCount): self - { - $this->tokenCount = $tokenCount; - return $this; - } - - /** - * Check if content has changed by comparing hashes. - */ - public function hasContentChanged(string $newContent): bool - { - return $this->contentHash !== hash('sha256', $newContent); - } -} diff --git a/src/Domain/StoryAI/Entity/StoryObjectEmbedding.php b/src/Domain/StoryAI/Entity/StoryObjectEmbedding.php deleted file mode 100644 index 75e7369..0000000 --- a/src/Domain/StoryAI/Entity/StoryObjectEmbedding.php +++ /dev/null @@ -1,180 +0,0 @@ - - */ - #[ORM\Column(type: Types::JSON)] - private array $embedding = []; - - /** - * The model used to generate this embedding. - */ - #[ORM\Column(length: 100)] - private string $embeddingModel = 'text-embedding-3-small'; - - /** - * Dimensions of the embedding vector. - */ - #[ORM\Column(type: Types::INTEGER)] - private int $dimensions = 1536; - - /** - * Token count of the serialized content. - */ - #[ORM\Column(type: Types::INTEGER, nullable: true)] - private ?int $tokenCount = null; - - public function __construct() - { - $this->id = Uuid::v4(); - } - - public function getLarp(): ?Larp - { - return $this->larp; - } - - public function setLarp(?Larp $larp): self - { - $this->larp = $larp; - return $this; - } - - public function getStoryObject(): ?StoryObject - { - return $this->storyObject; - } - - public function setStoryObject(?StoryObject $storyObject): self - { - $this->storyObject = $storyObject; - return $this; - } - - public function getSerializedContent(): string - { - return $this->serializedContent; - } - - public function setSerializedContent(string $serializedContent): self - { - $this->serializedContent = $serializedContent; - $this->contentHash = hash('sha256', $serializedContent); - return $this; - } - - public function getContentHash(): string - { - return $this->contentHash; - } - - /** - * @return array - */ - public function getEmbedding(): array - { - return $this->embedding; - } - - /** - * @param array $embedding - */ - public function setEmbedding(array $embedding): self - { - $this->embedding = $embedding; - $this->dimensions = count($embedding); - return $this; - } - - public function getEmbeddingModel(): string - { - return $this->embeddingModel; - } - - public function setEmbeddingModel(string $embeddingModel): self - { - $this->embeddingModel = $embeddingModel; - return $this; - } - - public function getDimensions(): int - { - return $this->dimensions; - } - - public function getTokenCount(): ?int - { - return $this->tokenCount; - } - - public function setTokenCount(?int $tokenCount): self - { - $this->tokenCount = $tokenCount; - return $this; - } - - /** - * Check if content has changed by comparing hashes. - */ - public function hasContentChanged(string $newContent): bool - { - return $this->contentHash !== hash('sha256', $newContent); - } -} diff --git a/src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php b/src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php deleted file mode 100644 index 56522ff..0000000 --- a/src/Domain/StoryAI/Message/IndexLoreDocumentMessage.php +++ /dev/null @@ -1,18 +0,0 @@ -entityManager - ->getRepository(LarpLoreDocument::class) - ->find($message->documentId); - - if (!$document) { - $this->logger?->warning('Lore document not found for indexing', [ - 'document_id' => $message->documentId->toRfc4122(), - ]); - return; - } - - try { - $this->embeddingService->indexLoreDocument($document); - $this->logger?->info('Lore document indexed via async handler', [ - 'document_id' => $message->documentId->toRfc4122(), - ]); - } catch (\Throwable $e) { - $this->logger?->error('Failed to index lore document', [ - 'document_id' => $message->documentId->toRfc4122(), - 'error' => $e->getMessage(), - ]); - throw $e; - } - } -} diff --git a/src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php b/src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php deleted file mode 100644 index c9c8b6b..0000000 --- a/src/Domain/StoryAI/Repository/LarpLoreDocumentRepository.php +++ /dev/null @@ -1,111 +0,0 @@ - - * - * @method LarpLoreDocument|null find($id, $lockMode = null, $lockVersion = null) - * @method LarpLoreDocument|null findOneBy(array $criteria, array $orderBy = null) - * @method LarpLoreDocument[] findAll() - * @method LarpLoreDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class LarpLoreDocumentRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, LarpLoreDocument::class); - } - - /** - * Get all active documents for a LARP ordered by priority. - * - * @return LarpLoreDocument[] - */ - public function findActiveByLarp(Larp $larp): array - { - return $this->createQueryBuilder('d') - ->where('d.larp = :larp') - ->andWhere('d.active = true') - ->setParameter('larp', $larp) - ->orderBy('d.priority', 'DESC') - ->addOrderBy('d.createdAt', 'ASC') - ->getQuery() - ->getResult(); - } - - /** - * Get documents that should always be included in AI context. - * - * @return LarpLoreDocument[] - */ - public function findAlwaysInclude(Larp $larp): array - { - return $this->createQueryBuilder('d') - ->where('d.larp = :larp') - ->andWhere('d.active = true') - ->andWhere('d.alwaysInclude = true') - ->setParameter('larp', $larp) - ->orderBy('d.priority', 'DESC') - ->getQuery() - ->getResult(); - } - - /** - * Get documents by type. - * - * @return LarpLoreDocument[] - */ - public function findByType(Larp $larp, LoreDocumentType $type): array - { - return $this->createQueryBuilder('d') - ->where('d.larp = :larp') - ->andWhere('d.type = :type') - ->andWhere('d.active = true') - ->setParameter('larp', $larp) - ->setParameter('type', $type) - ->orderBy('d.priority', 'DESC') - ->getQuery() - ->getResult(); - } - - /** - * Get total content length for all active documents in a LARP. - */ - public function getTotalContentLength(Larp $larp): int - { - $result = $this->createQueryBuilder('d') - ->select('SUM(LENGTH(d.content)) as total') - ->where('d.larp = :larp') - ->andWhere('d.active = true') - ->setParameter('larp', $larp) - ->getQuery() - ->getSingleScalarResult(); - - return (int) ($result ?? 0); - } - - /** - * Get count of documents by LARP. - */ - public function countByLarp(Larp $larp): int - { - return $this->count(['larp' => $larp]); - } - - /** - * Get count of active documents by LARP. - */ - public function countActiveByLarp(Larp $larp): int - { - return $this->count(['larp' => $larp, 'active' => true]); - } -} diff --git a/src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php b/src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php deleted file mode 100644 index 8e4451f..0000000 --- a/src/Domain/StoryAI/Repository/LoreDocumentChunkRepository.php +++ /dev/null @@ -1,121 +0,0 @@ - - * - * @method LoreDocumentChunk|null find($id, $lockMode = null, $lockVersion = null) - * @method LoreDocumentChunk|null findOneBy(array $criteria, array $orderBy = null) - * @method LoreDocumentChunk[] findAll() - * @method LoreDocumentChunk[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class LoreDocumentChunkRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, LoreDocumentChunk::class); - } - - /** - * Get all chunks for a document ordered by index. - * - * @return LoreDocumentChunk[] - */ - public function findByDocument(LarpLoreDocument $document): array - { - return $this->findBy( - ['document' => $document], - ['chunkIndex' => 'ASC'] - ); - } - - /** - * Perform a vector similarity search on lore chunks. - * - * @param array $queryEmbedding The query vector - * @param Larp $larp The LARP to search within - * @param int $limit Maximum number of results - * @param float $minSimilarity Minimum cosine similarity threshold (0-1) - * @return array - * @throws Exception - */ - public function findSimilar( - array $queryEmbedding, - Larp $larp, - int $limit = 10, - float $minSimilarity = 0.5 - ): array { - $conn = $this->getEntityManager()->getConnection(); - - // Convert embedding to pgvector format - $vectorStr = '[' . implode(',', $queryEmbedding) . ']'; - - // Use cosine similarity (1 - cosine distance) - $sql = << :query_vector::vector) as similarity - FROM lore_document_chunk ldc - INNER JOIN larp_lore_document lld ON ldc.document_id = lld.id - WHERE ldc.larp_id = :larp_id - AND lld.active = true - AND 1 - (ldc.embedding::vector <=> :query_vector::vector) >= :min_similarity - ORDER BY ldc.embedding::vector <=> :query_vector::vector - LIMIT :limit - SQL; - - $stmt = $conn->prepare($sql); - $result = $stmt->executeQuery([ - 'query_vector' => $vectorStr, - 'larp_id' => $larp->getId()->toRfc4122(), - 'min_similarity' => $minSimilarity, - 'limit' => $limit, - ]); - - $rows = $result->fetchAllAssociative(); - $results = []; - - foreach ($rows as $row) { - $chunk = $this->find($row['id']); - if ($chunk) { - $results[] = [ - 'chunk' => $chunk, - 'similarity' => (float) $row['similarity'], - ]; - } - } - - return $results; - } - - /** - * Delete all chunks for a document. - */ - public function deleteByDocument(LarpLoreDocument $document): int - { - return $this->createQueryBuilder('c') - ->delete() - ->where('c.document = :document') - ->setParameter('document', $document) - ->getQuery() - ->execute(); - } - - /** - * Get count of chunks for a LARP. - */ - public function countByLarp(Larp $larp): int - { - return $this->count(['larp' => $larp]); - } -} diff --git a/src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php b/src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php deleted file mode 100644 index cd60b0b..0000000 --- a/src/Domain/StoryAI/Repository/StoryObjectEmbeddingRepository.php +++ /dev/null @@ -1,140 +0,0 @@ - - * - * @method StoryObjectEmbedding|null find($id, $lockMode = null, $lockVersion = null) - * @method StoryObjectEmbedding|null findOneBy(array $criteria, array $orderBy = null) - * @method StoryObjectEmbedding[] findAll() - * @method StoryObjectEmbedding[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) - */ -class StoryObjectEmbeddingRepository extends ServiceEntityRepository -{ - public function __construct(ManagerRegistry $registry) - { - parent::__construct($registry, StoryObjectEmbedding::class); - } - - public function findByStoryObject(StoryObject $storyObject): ?StoryObjectEmbedding - { - return $this->findOneBy(['storyObject' => $storyObject]); - } - - /** - * @return StoryObjectEmbedding[] - */ - public function findByLarp(Larp $larp): array - { - return $this->findBy(['larp' => $larp]); - } - - /** - * Perform vector similarity search using pgvector. - * - * @param array $queryEmbedding The query vector - * @param Larp $larp The LARP to search within - * @param int $limit Maximum number of results - * @param float $minSimilarity Minimum cosine similarity threshold (0-1) - * @return array - */ - public function findSimilar( - array $queryEmbedding, - Larp $larp, - int $limit = 10, - float $minSimilarity = 0.5 - ): array { - $conn = $this->getEntityManager()->getConnection(); - - // Convert embedding to pgvector format - $vectorStr = '[' . implode(',', $queryEmbedding) . ']'; - - // Use cosine similarity (1 - cosine distance) - // pgvector uses <=> for cosine distance, so similarity = 1 - distance - $sql = << :query_vector::vector) as similarity - FROM story_object_embedding soe - WHERE soe.larp_id = :larp_id - AND 1 - (soe.embedding::vector <=> :query_vector::vector) >= :min_similarity - ORDER BY soe.embedding::vector <=> :query_vector::vector - LIMIT :limit - SQL; - - $stmt = $conn->prepare($sql); - $result = $stmt->executeQuery([ - 'query_vector' => $vectorStr, - 'larp_id' => $larp->getId()->toRfc4122(), - 'min_similarity' => $minSimilarity, - 'limit' => $limit, - ]); - - $rows = $result->fetchAllAssociative(); - $results = []; - - foreach ($rows as $row) { - $embedding = $this->find($row['id']); - if ($embedding) { - $results[] = [ - 'embedding' => $embedding, - 'similarity' => (float) $row['similarity'], - ]; - } - } - - return $results; - } - - /** - * Find embeddings that need updating (content hash doesn't match). - * - * @return StoryObjectEmbedding[] - */ - public function findOutdated(Larp $larp): array - { - return $this->createQueryBuilder('e') - ->where('e.larp = :larp') - ->setParameter('larp', $larp) - ->getQuery() - ->getResult(); - } - - /** - * Delete all embeddings for a LARP. - */ - public function deleteByLarp(Larp $larp): int - { - return $this->createQueryBuilder('e') - ->delete() - ->where('e.larp = :larp') - ->setParameter('larp', $larp) - ->getQuery() - ->execute(); - } - - /** - * Check if an embedding exists for a story object. - */ - public function existsForStoryObject(StoryObject $storyObject): bool - { - return $this->count(['storyObject' => $storyObject]) > 0; - } - - /** - * Get count of embeddings for a LARP. - */ - public function countByLarp(Larp $larp): int - { - return $this->count(['larp' => $larp]); - } -} diff --git a/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php index dd48497..f2e7224 100644 --- a/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php +++ b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php @@ -6,47 +6,33 @@ use App\Domain\Core\Entity\Larp; use App\Domain\StoryAI\DTO\VectorDocument; -use App\Domain\StoryAI\Entity\LarpLoreDocument; -use App\Domain\StoryAI\Entity\LoreDocumentChunk; -use App\Domain\StoryAI\Entity\StoryObjectEmbedding; -use App\Domain\StoryAI\Repository\LoreDocumentChunkRepository; -use App\Domain\StoryAI\Repository\StoryObjectEmbeddingRepository; use App\Domain\StoryAI\Service\Provider\EmbeddingProviderInterface; use App\Domain\StoryAI\Service\VectorStore\VectorStoreInterface; use App\Domain\StoryObject\Entity\StoryObject; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use Symfony\Component\Uid\Uuid; /** * Service for generating and managing embeddings. * - * This service follows CQRS principles: - * - Write operations go to external vector store (Supabase, etc.) - * - Local StoryObjectEmbedding entities are kept for tracking/metadata only + * All embedding data is stored in the external vector store (Supabase). + * No local database entities are used for AI/embedding tracking. */ class EmbeddingService { - // Maximum characters per chunk for lore documents - private const CHUNK_SIZE = 2000; - // Overlap between chunks for context continuity - private const CHUNK_OVERLAP = 200; - public function __construct( private readonly EmbeddingProviderInterface $embeddingProvider, private readonly StoryObjectSerializer $serializer, private readonly VectorStoreInterface $vectorStore, - private readonly StoryObjectEmbeddingRepository $embeddingRepository, - private readonly LoreDocumentChunkRepository $chunkRepository, private readonly EntityManagerInterface $entityManager, private readonly ?LoggerInterface $logger = null, ) { } /** - * Index a story object by generating and storing its embedding. + * Index a story object by generating and storing its embedding in the vector store. */ - public function indexStoryObject(StoryObject $storyObject): StoryObjectEmbedding + public function indexStoryObject(StoryObject $storyObject, bool $force = false): void { $larp = $storyObject->getLarp(); if (!$larp) { @@ -57,14 +43,15 @@ public function indexStoryObject(StoryObject $storyObject): StoryObjectEmbedding $serializedContent = $this->serializer->serialize($storyObject); $contentHash = hash('sha256', $serializedContent); - // Check if embedding already exists and content hasn't changed - $existingEmbedding = $this->embeddingRepository->findByStoryObject($storyObject); - - if ($existingEmbedding && $existingEmbedding->getContentHash() === $contentHash) { - $this->logger?->debug('Content unchanged, skipping re-embedding', [ - 'story_object_id' => $storyObject->getId()->toRfc4122(), - ]); - return $existingEmbedding; + // Check if content has changed (unless forced) + if (!$force) { + $existing = $this->vectorStore->findByEntityId($storyObject->getId()); + if ($existing && $existing->contentHash === $contentHash) { + $this->logger?->debug('Content unchanged, skipping re-embedding', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + ]); + return; + } } // Generate embedding vector @@ -84,119 +71,9 @@ public function indexStoryObject(StoryObject $storyObject): StoryObjectEmbedding $this->vectorStore->upsert($vectorDocument); - // Update or create local tracking entity - $embedding = $existingEmbedding ?? new StoryObjectEmbedding(); - if (!$existingEmbedding) { - $embedding->setLarp($larp); - $embedding->setStoryObject($storyObject); - } - - $embedding->setSerializedContent($serializedContent); - $embedding->setEmbedding($vector); - $embedding->setEmbeddingModel($this->embeddingProvider->getModelName()); - $embedding->setTokenCount($this->embeddingProvider->estimateTokenCount($serializedContent)); - - $this->entityManager->persist($embedding); - $this->entityManager->flush(); - $this->logger?->info('Story object indexed successfully', [ 'story_object_id' => $storyObject->getId()->toRfc4122(), 'type' => $entityType, - 'token_count' => $embedding->getTokenCount(), - 'vector_store' => $this->vectorStore->getProviderName(), - ]); - - return $embedding; - } - - /** - * Index a lore document by chunking and generating embeddings. - */ - public function indexLoreDocument(LarpLoreDocument $document): void - { - $larp = $document->getLarp(); - if (!$larp) { - throw new \InvalidArgumentException('Lore document must belong to a LARP'); - } - - // Delete existing chunks from vector store - $this->vectorStore->deleteByFilter([ - 'larp_id' => $larp->getId(), - 'type' => VectorDocument::TYPE_LORE_CHUNK, - 'metadata->>document_id' => $document->getId()->toRfc4122(), - ]); - - // Clear local chunks - $this->chunkRepository->deleteByDocument($document); - $document->clearChunks(); - - // Chunk the content - $chunks = $this->chunkContent($document->getContent()); - - $this->logger?->debug('Chunking lore document', [ - 'document_id' => $document->getId()->toRfc4122(), - 'chunk_count' => count($chunks), - ]); - - // Add document context to each chunk - $contextPrefix = sprintf( - "[%s] %s\n\n", - $document->getType()->getLabel(), - $document->getTitle() - ); - - // Generate embeddings in batch - $textsToEmbed = array_map( - fn (string $chunk) => $contextPrefix . $chunk, - $chunks - ); - - $embeddings = $this->embeddingProvider->embedBatch($textsToEmbed); - - // Prepare vector documents for batch upsert - $vectorDocuments = []; - - // Create chunk entities and vector documents - foreach ($chunks as $index => $chunkContent) { - $chunkId = Uuid::v4(); - - // Create local chunk entity - $chunk = new LoreDocumentChunk(); - $chunk->setDocument($document); - $chunk->setLarp($larp); - $chunk->setContent($chunkContent); - $chunk->setChunkIndex($index); - $chunk->setEmbedding($embeddings[$index]); - $chunk->setEmbeddingModel($this->embeddingProvider->getModelName()); - $chunk->setTokenCount($this->embeddingProvider->estimateTokenCount($chunkContent)); - - $document->addChunk($chunk); - $this->entityManager->persist($chunk); - - // Create vector document for external store - $vectorDocuments[] = VectorDocument::forLoreChunk( - entityId: $chunk->getId(), - larpId: $larp->getId(), - documentTitle: $document->getTitle(), - chunkContent: $chunkContent, - chunkIndex: $index, - embedding: $embeddings[$index], - embeddingModel: $this->embeddingProvider->getModelName(), - metadata: [ - 'document_id' => $document->getId()->toRfc4122(), - 'document_type' => $document->getType()->value, - ], - ); - } - - // Batch upsert to vector store - $this->vectorStore->upsertBatch($vectorDocuments); - - $this->entityManager->flush(); - - $this->logger?->info('Lore document indexed successfully', [ - 'document_id' => $document->getId()->toRfc4122(), - 'chunk_count' => count($chunks), 'vector_store' => $this->vectorStore->getProviderName(), ]); } @@ -206,7 +83,7 @@ public function indexLoreDocument(LarpLoreDocument $document): void * * @return array{indexed: int, skipped: int, errors: int} */ - public function reindexLarp(Larp $larp, callable $progressCallback = null): array + public function reindexLarp(Larp $larp, ?callable $progressCallback = null, bool $force = false): array { $stats = ['indexed' => 0, 'skipped' => 0, 'errors' => 0]; @@ -226,13 +103,19 @@ public function reindexLarp(Larp $larp, callable $progressCallback = null): arra try { $serializedContent = $this->serializer->serialize($storyObject); $contentHash = hash('sha256', $serializedContent); - $existingEmbedding = $this->embeddingRepository->findByStoryObject($storyObject); - if ($existingEmbedding && $existingEmbedding->getContentHash() === $contentHash) { - $stats['skipped']++; - } else { - $this->indexStoryObject($storyObject); + // Check if content has changed + $shouldIndex = $force; + if (!$force) { + $existing = $this->vectorStore->findByEntityId($storyObject->getId()); + $shouldIndex = !$existing || $existing->contentHash !== $contentHash; + } + + if ($shouldIndex) { + $this->indexStoryObject($storyObject, true); $stats['indexed']++; + } else { + $stats['skipped']++; } if ($progressCallback) { @@ -256,24 +139,16 @@ public function reindexLarp(Larp $larp, callable $progressCallback = null): arra } /** - * Delete embedding for a story object. + * Delete embedding for a story object from the vector store. */ public function deleteStoryObjectEmbedding(StoryObject $storyObject): void { - // Delete from external vector store $this->vectorStore->delete($storyObject->getId()); - // Delete local tracking entity - $embedding = $this->embeddingRepository->findByStoryObject($storyObject); - if ($embedding) { - $this->entityManager->remove($embedding); - $this->entityManager->flush(); - - $this->logger?->debug('Embedding deleted', [ - 'story_object_id' => $storyObject->getId()->toRfc4122(), - 'vector_store' => $this->vectorStore->getProviderName(), - ]); - } + $this->logger?->debug('Embedding deleted', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + 'vector_store' => $this->vectorStore->getProviderName(), + ]); } /** @@ -301,87 +176,4 @@ public function getVectorStoreProvider(): string { return $this->vectorStore->getProviderName(); } - - /** - * Chunk content into overlapping segments. - * - * @return array - */ - private function chunkContent(string $content): array - { - $content = trim($content); - $length = strlen($content); - - if ($length <= self::CHUNK_SIZE) { - return [$content]; - } - - $chunks = []; - $position = 0; - - while ($position < $length) { - $chunkEnd = min($position + self::CHUNK_SIZE, $length); - - // Try to break at a sentence or paragraph boundary - if ($chunkEnd < $length) { - $breakpoint = $this->findBreakpoint($content, $chunkEnd); - if ($breakpoint > $position) { - $chunkEnd = $breakpoint; - } - } - - $chunk = substr($content, $position, $chunkEnd - $position); - $chunks[] = trim($chunk); - - // Move position with overlap - $position = $chunkEnd - self::CHUNK_OVERLAP; - if ($position <= 0 || $chunkEnd >= $length) { - break; - } - } - - return array_filter($chunks, fn ($chunk) => !empty($chunk)); - } - - /** - * Find a good breakpoint near the target position. - */ - private function findBreakpoint(string $content, int $targetPosition): int - { - // Look backwards for a paragraph or sentence break - $searchRange = min(200, $targetPosition); - $searchStart = $targetPosition - $searchRange; - - $substring = substr($content, $searchStart, $searchRange); - - // Try a paragraph break first - $lastParagraph = strrpos($substring, "\n\n"); - if ($lastParagraph !== false) { - return $searchStart + $lastParagraph + 2; - } - - // Try sentence break - $lastSentence = max( - strrpos($substring, '. ') ?: 0, - strrpos($substring, '! ') ?: 0, - strrpos($substring, '? ') ?: 0 - ); - if ($lastSentence > 0) { - return $searchStart + $lastSentence + 2; - } - - // Try newline - $lastNewline = strrpos($substring, "\n"); - if ($lastNewline !== false) { - return $searchStart + $lastNewline + 1; - } - - // Try space - $lastSpace = strrpos($substring, ' '); - if ($lastSpace !== false) { - return $searchStart + $lastSpace + 1; - } - - return $targetPosition; - } } diff --git a/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php index 9ea5def..3f2f4aa 100644 --- a/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php +++ b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php @@ -48,9 +48,7 @@ private function serializeCharacter(Character $character): string } // Type and gender - if ($character->getCharacterType()) { - $parts[] = sprintf('Type: %s', $character->getCharacterType()->value); - } + $parts[] = sprintf('Type: %s', $character->getCharacterType()->value); if ($character->getGender()) { $parts[] = sprintf('Gender: %s', $character->getGender()->value); } @@ -132,7 +130,7 @@ private function serializeCharacter(Character $character): string foreach ($relationsFrom as $relation) { $target = $relation->getTo(); - $type = $relation->getRelationType()?->value ?? 'Related'; + $type = $relation->getRelationType()->value; if ($target) { $relationStrings[] = sprintf('%s of %s', $type, $target->getTitle()); } @@ -140,7 +138,7 @@ private function serializeCharacter(Character $character): string foreach ($relationsTo as $relation) { $source = $relation->getFrom(); - $type = $relation->getRelationType()?->value ?? 'Related'; + $type = $relation->getRelationType()->value; if ($source) { $relationStrings[] = sprintf('%s with %s', $type, $source->getTitle()); } @@ -376,13 +374,12 @@ private function serializeRelation(Relation $relation): string $from = $relation->getFrom(); $to = $relation->getTo(); - $type = $relation->getRelationType()?->getLabel() ?? 'Related'; if ($from && $to) { $parts[] = sprintf( 'Relation: %s is %s to %s', $from->getTitle(), - $type, + $relation->getRelationType()->value, $to->getTitle() ); } else { @@ -429,11 +426,12 @@ private function cleanHtml(string $html): string /** * Summarize a decision tree for embedding. * - * @param array|null $decisionTree + * @param array|null $decisionTree + * @return string|null */ private function summarizeDecisionTree(?array $decisionTree): ?string { - if (!$decisionTree || empty($decisionTree)) { + if (empty($decisionTree)) { return null; } diff --git a/src/Domain/StoryAI/Service/Query/ContextBuilder.php b/src/Domain/StoryAI/Service/Query/ContextBuilder.php index e6edfaa..63ba47e 100644 --- a/src/Domain/StoryAI/Service/Query/ContextBuilder.php +++ b/src/Domain/StoryAI/Service/Query/ContextBuilder.php @@ -6,10 +6,13 @@ use App\Domain\Core\Entity\Larp; use App\Domain\StoryAI\DTO\SearchResult; -use App\Domain\StoryAI\Repository\LarpLoreDocumentRepository; /** - * Builds context for LLM prompts from search results and lore documents. + * Builds context for LLM prompts from search results. + * + * Note: Lore document support has been removed pending migration to Supabase. + * Once lore documents are stored in Supabase, this class can query them + * via VectorStoreInterface. */ class ContextBuilder { @@ -18,13 +21,8 @@ class ContextBuilder // Approximate characters per token private const CHARS_PER_TOKEN = 4; - public function __construct( - private readonly LarpLoreDocumentRepository $loreDocumentRepository, - ) { - } - /** - * Build context string from search results and always-include documents. + * Build context string from search results. * * @param SearchResult[] $searchResults * @param int $maxTokens Maximum tokens for context @@ -38,19 +36,6 @@ public function buildContext( $context = []; $usedChars = 0; - // First, add always-include lore documents - $alwaysIncludeDocs = $this->loreDocumentRepository->findAlwaysInclude($larp); - - foreach ($alwaysIncludeDocs as $doc) { - $docContext = $this->formatLoreDocument($doc); - $docChars = strlen($docContext); - - if ($usedChars + $docChars <= $availableChars) { - $context[] = $docContext; - $usedChars += $docChars; - } - } - // Add search results foreach ($searchResults as $result) { $resultContext = $this->formatSearchResult($result); @@ -103,19 +88,6 @@ public function buildSystemPrompt(Larp $larp): string PROMPT; } - private function formatLoreDocument($document): string - { - $type = $document->getType()->getLabel(); - $title = $document->getTitle(); - $content = $document->getContent(); - - return <<isStoryObject() ? $result->entityType : 'Lore'; diff --git a/src/PHPStan/Rules/DomainBoundaryRule.php b/src/PHPStan/Rules/DomainBoundaryRule.php index 6a3dbab..8f6aff0 100755 --- a/src/PHPStan/Rules/DomainBoundaryRule.php +++ b/src/PHPStan/Rules/DomainBoundaryRule.php @@ -43,6 +43,7 @@ final class DomainBoundaryRule implements Rule 'Public' => ['Infrastructure', 'Core', 'Account', 'Larp', 'StoryObject', 'Application'], 'Larp' => ['Infrastructure', 'Core', 'Account'], 'StoryObject' => ['Infrastructure', 'Core', 'Larp', 'Integrations', 'Account', 'StoryMarketplace', 'Application'], + 'StoryAI' => ['StoryObject', 'Infrastructure', 'Core', 'Larp', 'Integrations', 'Account', 'StoryMarketplace', 'Application'], 'Application' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Participant', 'Account'], 'Participant' => ['Infrastructure', 'Core', 'Account', 'Larp'], 'StoryMarketplace' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Account', 'Application'], From 457cac990b623a4718d13025ed7fc9bf3092bd51 Mon Sep 17 00:00:00 2001 From: TomaszB Date: Wed, 11 Feb 2026 09:45:37 +0100 Subject: [PATCH 5/5] Add initial AI Assistant functionality with chat interface, security rules, and backend support: - Built AI Assistant page with chat interface using Stimulus controller and custom styles. - Added `AIAssistantPageController` for rendering assistant chat view. - Implemented `AIAssistantVoter` for access control based on LARP participation and roles. - Created `ai_assistant_controller.js` for managing chat interactions, message history, and API communication. - Styled AI Assistant components (`_ai_assistant.scss`) for user and assistant messages, typing indicators, and error handling. - Extended LARP-related templates and security configurations to integrate AI Assistant page. - Added entities and logic for lore document indexing, embedding integration, and AI-driven functionalities. --- assets/bootstrap.js | 2 + assets/controllers/ai_assistant_controller.js | 199 ++++++++++++++++++ assets/styles/app.scss | 1 + assets/styles/components/_ai_assistant.scss | 145 +++++++++++++ assets/styles/components/_dark_mode.scss | 119 ++++++++--- config/routes.yaml | 11 + importmap.php | 3 + migrations/Version20260129120000.php | 125 ----------- migrations/Version20260202213728.php | 45 ++++ .../Core/Security/Voter/LarpViewVoter.php | 38 ++++ .../StoryAI/Command/ReindexStoryAICommand.php | 86 +++++++- .../Backoffice/AIAssistantPageController.php | 23 ++ .../Security/Voter/AIAssistantVoter.php | 42 ++++ .../Embedding/StoryObjectSerializer.php | 44 ++++ .../VectorStore/VectorStoreFactory.php | 13 +- .../Backoffice/LoreDocumentController.php | 111 ++++++++++ .../Entity/Enum/LoreDocumentCategory.php | 40 ++++ .../StoryObject/Entity/Enum/TargetType.php | 3 + .../StoryObject/Entity/LoreDocument.php | 152 +++++++++++++ src/Domain/StoryObject/Entity/StoryObject.php | 12 +- .../Form/Filter/LoreDocumentFilterType.php | 46 ++++ .../StoryObject/Form/LoreDocumentType.php | 100 +++++++++ .../Repository/LoreDocumentRepository.php | 107 ++++++++++ templates/backoffice/larp/_menu.html.twig | 16 ++ .../domain/story_ai/assistant/chat.html.twig | 80 +++++++ .../story_object/lore_document/list.html.twig | 82 ++++++++ .../lore_document/modify.html.twig | 46 ++++ .../includes/story_object_tabs.html.twig | 4 + translations/forms.en.yaml | 17 ++ translations/messages.en.yaml | 18 ++ 30 files changed, 1577 insertions(+), 153 deletions(-) create mode 100644 assets/controllers/ai_assistant_controller.js create mode 100644 assets/styles/components/_ai_assistant.scss delete mode 100644 migrations/Version20260129120000.php create mode 100644 migrations/Version20260202213728.php create mode 100644 src/Domain/Core/Security/Voter/LarpViewVoter.php create mode 100644 src/Domain/StoryAI/Controller/Backoffice/AIAssistantPageController.php create mode 100644 src/Domain/StoryAI/Security/Voter/AIAssistantVoter.php create mode 100644 src/Domain/StoryObject/Controller/Backoffice/LoreDocumentController.php create mode 100644 src/Domain/StoryObject/Entity/Enum/LoreDocumentCategory.php create mode 100644 src/Domain/StoryObject/Entity/LoreDocument.php create mode 100644 src/Domain/StoryObject/Form/Filter/LoreDocumentFilterType.php create mode 100644 src/Domain/StoryObject/Form/LoreDocumentType.php create mode 100644 src/Domain/StoryObject/Repository/LoreDocumentRepository.php create mode 100644 templates/domain/story_ai/assistant/chat.html.twig create mode 100644 templates/domain/story_object/lore_document/list.html.twig create mode 100644 templates/domain/story_object/lore_document/modify.html.twig diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 655a598..f97f03c 100755 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -13,6 +13,7 @@ import SortableCharacterChoicesController from "./controllers/sortable_character import TimelineController from "./controllers/timeline_controller.js"; import DeleteConfirmController from "./controllers/delete-confirm_controller.js"; import GooglePlacesAutocompleteController from "./controllers/google-places-autocomplete_controller.js"; +import AIAssistantController from "./controllers/ai_assistant_controller.js"; const app = startStimulusApp(); app.register('live', LiveController); @@ -28,3 +29,4 @@ app.register("sortable-character-choices", SortableCharacterChoicesController); app.register("timeline", TimelineController); app.register("delete-confirm", DeleteConfirmController); app.register("google-places-autocomplete", GooglePlacesAutocompleteController); +app.register("ai-assistant", AIAssistantController); diff --git a/assets/controllers/ai_assistant_controller.js b/assets/controllers/ai_assistant_controller.js new file mode 100644 index 0000000..f525b74 --- /dev/null +++ b/assets/controllers/ai_assistant_controller.js @@ -0,0 +1,199 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = [ + 'messagesContainer', + 'messageInput', + 'sendButton', + 'typingIndicator', + 'errorDisplay', + 'errorMessage', + 'sourcesPanel', + 'sourcesList', + ]; + + static values = { + queryUrl: String, + larpTitle: String, + }; + + connect() { + this.conversationHistory = []; + this.isProcessing = false; + this.scrollToBottom(); + } + + async sendMessage(event) { + event.preventDefault(); + + if (this.isProcessing || !this.hasMessageInputTarget) { + return; + } + + const query = this.messageInputTarget.value.trim(); + if (!query) { + return; + } + + // Clear input and disable + this.messageInputTarget.value = ''; + this.isProcessing = true; + this.sendButtonTarget.disabled = true; + this.hideError(); + + // Append user message + this.appendMessage('user', query); + this.conversationHistory.push({ role: 'user', content: query }); + + // Show typing indicator + this.typingIndicatorTarget.style.display = 'flex'; + this.scrollToBottom(); + + try { + const response = await fetch(this.queryUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query: query, + history: this.conversationHistory.slice(0, -1), // exclude current query + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Request failed (${response.status})`); + } + + const data = await response.json(); + + // Append assistant response + this.appendMessage('assistant', data.response); + this.conversationHistory.push({ role: 'assistant', content: data.response }); + + // Render sources + if (data.sources && data.sources.length > 0) { + this.renderSources(data.sources); + } else { + this.sourcesPanelTarget.style.display = 'none'; + } + } catch (error) { + this.showError(error.message || 'Failed to get a response. Please try again.'); + } finally { + this.typingIndicatorTarget.style.display = 'none'; + this.isProcessing = false; + this.sendButtonTarget.disabled = false; + this.messageInputTarget.focus(); + this.scrollToBottom(); + } + } + + appendMessage(role, content) { + const messageDiv = document.createElement('div'); + messageDiv.className = `ai-chat-message ai-chat-message--${role}`; + + const avatarDiv = document.createElement('div'); + avatarDiv.className = 'ai-chat-avatar'; + + if (role === 'user') { + avatarDiv.innerHTML = ''; + } else { + avatarDiv.innerHTML = ''; + } + + const bubbleDiv = document.createElement('div'); + bubbleDiv.className = 'ai-chat-bubble'; + + if (role === 'user') { + bubbleDiv.textContent = content; + } else { + bubbleDiv.innerHTML = this.renderMarkdown(content); + } + + messageDiv.appendChild(avatarDiv); + messageDiv.appendChild(bubbleDiv); + this.messagesContainerTarget.appendChild(messageDiv); + this.scrollToBottom(); + } + + renderMarkdown(text) { + // Escape HTML first + let html = this.escapeHtml(text); + + // Code blocks (``` ... ```) + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
'); + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + + // Italic + html = html.replace(/\*(.+?)\*/g, '$1'); + + // Line breaks + html = html.replace(/\n/g, '
'); + + return html; + } + + renderSources(sources) { + this.sourcesListTarget.innerHTML = ''; + sources.forEach(source => { + const badge = document.createElement('span'); + badge.className = 'badge bg-secondary me-1 mb-1'; + badge.title = source.preview || ''; + badge.textContent = `${source.type}: ${source.title} (${source.similarity}%)`; + this.sourcesListTarget.appendChild(badge); + }); + this.sourcesPanelTarget.style.display = 'block'; + } + + clearHistory() { + this.conversationHistory = []; + + // Remove all messages except the welcome message + const messages = this.messagesContainerTarget.querySelectorAll('.ai-chat-message'); + messages.forEach(msg => { + if (!msg.hasAttribute('data-welcome-message')) { + msg.remove(); + } + }); + + this.sourcesPanelTarget.style.display = 'none'; + this.hideError(); + this.scrollToBottom(); + } + + handleKeyDown(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(event); + } + } + + showError(message) { + this.errorMessageTarget.textContent = message; + this.errorDisplayTarget.style.display = 'block'; + this.scrollToBottom(); + } + + hideError() { + this.errorDisplayTarget.style.display = 'none'; + } + + scrollToBottom() { + if (this.hasMessagesContainerTarget) { + this.messagesContainerTarget.scrollTop = this.messagesContainerTarget.scrollHeight; + } + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} diff --git a/assets/styles/app.scss b/assets/styles/app.scss index f25013a..1e9fa82 100755 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -12,6 +12,7 @@ @import "./components/timeline"; @import "./components/map_preview"; @import "./components/speech_to_text"; +@import "./components/ai_assistant"; @import "./vendors/quill.snow"; @import "./vendors/vis-timeline-graph2d.min"; diff --git a/assets/styles/components/_ai_assistant.scss b/assets/styles/components/_ai_assistant.scss new file mode 100644 index 0000000..3297b8b --- /dev/null +++ b/assets/styles/components/_ai_assistant.scss @@ -0,0 +1,145 @@ +.ai-chat-messages { + height: 60vh; + overflow-y: auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.ai-chat-message { + display: flex; + gap: 0.5rem; + max-width: 85%; + + &--user { + align-self: flex-end; + flex-direction: row-reverse; + + .ai-chat-bubble { + background-color: var(--bs-primary); + color: #fff; + border-radius: 1rem 1rem 0.25rem 1rem; + } + + .ai-chat-avatar { + background-color: var(--bs-primary); + color: #fff; + } + } + + &--assistant { + align-self: flex-start; + + .ai-chat-bubble { + background-color: var(--bs-tertiary-bg); + color: var(--bs-body-color); + border-radius: 1rem 1rem 1rem 0.25rem; + } + + .ai-chat-avatar { + background-color: var(--bs-tertiary-bg); + color: var(--bs-body-color); + } + } +} + +.ai-chat-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 0.875rem; +} + +.ai-chat-bubble { + padding: 0.625rem 0.875rem; + word-break: break-word; + font-size: 0.9375rem; + line-height: 1.5; + + pre { + background-color: rgba(0, 0, 0, 0.05); + padding: 0.5rem; + border-radius: 0.25rem; + overflow-x: auto; + margin: 0.5rem 0; + } + + code { + font-size: 0.85em; + } +} + +.ai-chat-typing { + display: flex; + gap: 0.5rem; + padding: 0 1rem 0.5rem; + + .ai-chat-bubble { + background-color: var(--bs-tertiary-bg); + border-radius: 1rem 1rem 1rem 0.25rem; + padding: 0.625rem 1rem; + } + + .ai-chat-avatar { + background-color: var(--bs-tertiary-bg); + color: var(--bs-body-color); + } +} + +.ai-typing-dots { + display: flex; + gap: 4px; + align-items: center; + height: 1.25rem; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--bs-secondary); + animation: ai-dot-bounce 1.4s infinite ease-in-out both; + + &:nth-child(1) { animation-delay: 0s; } + &:nth-child(2) { animation-delay: 0.16s; } + &:nth-child(3) { animation-delay: 0.32s; } + } +} + +@keyframes ai-dot-bounce { + 0%, 80%, 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +.ai-chat-error { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; +} + +.ai-chat-sources { + border-top: 1px solid var(--bs-border-color); +} + +.ai-chat-sources-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + + .badge { + font-weight: normal; + font-size: 0.75rem; + cursor: default; + } +} diff --git a/assets/styles/components/_dark_mode.scss b/assets/styles/components/_dark_mode.scss index 750092c..0fa39ec 100644 --- a/assets/styles/components/_dark_mode.scss +++ b/assets/styles/components/_dark_mode.scss @@ -1,9 +1,14 @@ // Apply dark mode styles to various components html.dark-mode { - .larp-backoffice-header{ - background: var(--bg-tertiary); - border-bottom: 2px solid #0d6efd; - } + + .form-text{ + color: var(--text-secondary); + } + + .larp-backoffice-header{ + background: var(--bg-tertiary); + border-bottom: 2px solid #0d6efd; + } .larp-header-title { border-bottom: 1px solid var(--bo-header-border); @@ -142,7 +147,6 @@ html.dark-mode { background-color: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); - background: var(--bs-primary-rgb); } .card-body { @@ -163,11 +167,46 @@ html.dark-mode { --bs-table-color: var(--text-primary); --bs-table-border-color: var(--border-color); color: var(--text-primary); + + // Ensure all table cells use proper text color + th, + td { + color: var(--text-primary); + } + + // Striped rows - ensure text stays readable + &.table-striped > tbody > tr:nth-of-type(odd) > * { + --bs-table-color-state: var(--text-primary); + color: var(--text-primary); + } + + &.table-striped > tbody > tr:nth-of-type(even) > * { + --bs-table-color-state: var(--text-primary); + color: var(--text-primary); + } + + // Hover rows + &.table-hover > tbody > tr:hover > * { + --bs-table-color-state: var(--text-primary); + color: var(--text-primary); + } + + // Secondary text within tables + .text-muted, + .text-secondary, + small { + color: var(--text-secondary) !important; + } } .table-light { --bs-table-bg: var(--bg-secondary); --bs-table-color: var(--text-primary); + + th, + td { + color: var(--text-primary); + } } // Modals @@ -333,17 +372,13 @@ html.dark-mode { } } -.card { - - - .card-header { - border-bottom: 1px solid rgba(var(--bo-black), 0.08); - background: linear-gradient(180deg, #2d2d2d 0%, var(--bo-gray-50) 100%); - } -} - // Google Maps dark mode styling html.dark-mode { + // Card header gradient (dark mode only) + .card .card-header { + border-bottom: 1px solid var(--border-color); + background: linear-gradient(180deg, var(--card-bg) 0%, var(--bg-secondary) 100%); + } // Invert map colors for dark mode effect #map, .google-map, @@ -386,17 +421,53 @@ html.dark-mode { filter: invert(100%); } - .card-header-tabs .nav-link.active { - background: var(--bo-nav-bg-active); - border-color: var(--input-border); - border-bottom: 1px solid var(--card-bg); - font-weight: normal; - color: var(--text-primary); - } + // Generic nav-tabs styling (for story_object_tabs and other standalone tabs) + .nav-tabs { + border-bottom-color: var(--border-color); - .card-header-tabs { - border-bottom: 1px solid var(--input-border);; + .nav-link { + color: var(--text-secondary); - } + &:hover, + &:focus { + border-color: var(--border-color); + color: var(--text-primary); + } + + &.active { + background-color: var(--card-bg); + border-color: var(--border-color); + border-bottom-color: var(--card-bg); + color: var(--text-primary); + } + } + } + + // Card header tabs (more specific styling) + .card-header-tabs .nav-link.active { + background: var(--bo-nav-bg-active); + border-color: var(--input-border); + border-bottom: 1px solid var(--card-bg); + font-weight: normal; + color: var(--text-primary); + } + .card-header-tabs { + border-bottom: 1px solid var(--input-border); + } + + // Quill mentions dropdown + .ql-mention-list-container { + background-color: var(--card-bg); + border-color: var(--input-border); + } + + .ql-mention-list-item { + color: var(--text-primary); + + &:hover, + &.selected { + background-color: var(--bg-secondary); + } + } } \ No newline at end of file diff --git a/config/routes.yaml b/config/routes.yaml index aedc30a..cd1be0e 100755 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -135,4 +135,15 @@ super_admin_backoffice: mailing_backoffice: resource: '../src/Domain/Mailing/Controller/Backoffice/' type: attribute + prefix: /backoffice + +# StoryAI Domain +story_ai_api: + resource: '../src/Domain/StoryAI/Controller/API/' + type: attribute + prefix: /api + +story_ai_backoffice: + resource: '../src/Domain/StoryAI/Controller/Backoffice/' + type: attribute prefix: /backoffice \ No newline at end of file diff --git a/importmap.php b/importmap.php index a56991f..7485c52 100755 --- a/importmap.php +++ b/importmap.php @@ -62,6 +62,9 @@ './controllers/google-places-autocomplete_controller.js' => [ 'path' => './assets/controllers/google-places-autocomplete_controller.js', ], + './controllers/ai_assistant_controller.js' => [ + 'path' => './assets/controllers/ai_assistant_controller.js', + ], './utils/factionGroupLayout.js' => [ 'path' => './assets/utils/factionGroupLayout.js', ], diff --git a/migrations/Version20260129120000.php b/migrations/Version20260129120000.php deleted file mode 100644 index b8a5369..0000000 --- a/migrations/Version20260129120000.php +++ /dev/null @@ -1,125 +0,0 @@ -addSql('CREATE EXTENSION IF NOT EXISTS vector'); - - // Create story_object_embedding table - $this->addSql(' - CREATE TABLE story_object_embedding ( - id UUID NOT NULL, - larp_id UUID NOT NULL, - story_object_id UUID NOT NULL, - serialized_content TEXT NOT NULL, - content_hash VARCHAR(64) NOT NULL, - embedding vector(1536) NOT NULL, - embedding_model VARCHAR(100) NOT NULL, - dimensions INT NOT NULL, - token_count INT DEFAULT NULL, - created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, - updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, - PRIMARY KEY(id) - ) - '); - - $this->addSql('CREATE INDEX idx_embedding_larp ON story_object_embedding (larp_id)'); - $this->addSql('CREATE INDEX idx_embedding_story_object ON story_object_embedding (story_object_id)'); - $this->addSql('CREATE INDEX idx_embedding_content_hash ON story_object_embedding (content_hash)'); - - // Create HNSW index for fast approximate nearest neighbor search - $this->addSql('CREATE INDEX idx_embedding_vector ON story_object_embedding USING hnsw (embedding vector_cosine_ops)'); - - $this->addSql('ALTER TABLE story_object_embedding ADD CONSTRAINT FK_embedding_larp FOREIGN KEY (larp_id) REFERENCES larp (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); - $this->addSql('ALTER TABLE story_object_embedding ADD CONSTRAINT FK_embedding_story_object FOREIGN KEY (story_object_id) REFERENCES story_object (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); - - // Create larp_lore_document table - $this->addSql(' - CREATE TABLE larp_lore_document ( - id UUID NOT NULL, - larp_id UUID NOT NULL, - created_by_id UUID DEFAULT NULL, - title VARCHAR(255) NOT NULL, - description TEXT DEFAULT NULL, - type VARCHAR(50) NOT NULL, - content TEXT NOT NULL, - priority INT NOT NULL, - always_include BOOLEAN NOT NULL, - active BOOLEAN NOT NULL, - created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, - updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, - PRIMARY KEY(id) - ) - '); - - $this->addSql('CREATE INDEX idx_lore_doc_larp ON larp_lore_document (larp_id)'); - $this->addSql('CREATE INDEX idx_lore_doc_priority ON larp_lore_document (priority)'); - $this->addSql('CREATE INDEX idx_lore_doc_type ON larp_lore_document (type)'); - - $this->addSql('ALTER TABLE larp_lore_document ADD CONSTRAINT FK_lore_doc_larp FOREIGN KEY (larp_id) REFERENCES larp (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); - $this->addSql('ALTER TABLE larp_lore_document ADD CONSTRAINT FK_lore_doc_created_by FOREIGN KEY (created_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); - - // Create lore_document_chunk table - $this->addSql(' - CREATE TABLE lore_document_chunk ( - id UUID NOT NULL, - document_id UUID NOT NULL, - larp_id UUID NOT NULL, - content TEXT NOT NULL, - chunk_index INT NOT NULL, - content_hash VARCHAR(64) NOT NULL, - embedding vector(1536) NOT NULL, - embedding_model VARCHAR(100) NOT NULL, - dimensions INT NOT NULL, - token_count INT DEFAULT NULL, - created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, - updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, - PRIMARY KEY(id) - ) - '); - - $this->addSql('CREATE INDEX idx_chunk_document ON lore_document_chunk (document_id)'); - $this->addSql('CREATE INDEX idx_chunk_larp ON lore_document_chunk (larp_id)'); - $this->addSql('CREATE INDEX idx_chunk_index ON lore_document_chunk (chunk_index)'); - - // Create HNSW index for fast approximate nearest neighbor search on chunks - $this->addSql('CREATE INDEX idx_chunk_vector ON lore_document_chunk USING hnsw (embedding vector_cosine_ops)'); - - $this->addSql('ALTER TABLE lore_document_chunk ADD CONSTRAINT FK_chunk_document FOREIGN KEY (document_id) REFERENCES larp_lore_document (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); - $this->addSql('ALTER TABLE lore_document_chunk ADD CONSTRAINT FK_chunk_larp FOREIGN KEY (larp_id) REFERENCES larp (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); - } - - public function down(Schema $schema): void - { - $this->addSql('ALTER TABLE lore_document_chunk DROP CONSTRAINT FK_chunk_document'); - $this->addSql('ALTER TABLE lore_document_chunk DROP CONSTRAINT FK_chunk_larp'); - $this->addSql('DROP TABLE lore_document_chunk'); - - $this->addSql('ALTER TABLE larp_lore_document DROP CONSTRAINT FK_lore_doc_larp'); - $this->addSql('ALTER TABLE larp_lore_document DROP CONSTRAINT FK_lore_doc_created_by'); - $this->addSql('DROP TABLE larp_lore_document'); - - $this->addSql('ALTER TABLE story_object_embedding DROP CONSTRAINT FK_embedding_larp'); - $this->addSql('ALTER TABLE story_object_embedding DROP CONSTRAINT FK_embedding_story_object'); - $this->addSql('DROP TABLE story_object_embedding'); - - // Note: We don't drop the pgvector extension as it might be used by other tables - } -} diff --git a/migrations/Version20260202213728.php b/migrations/Version20260202213728.php new file mode 100644 index 0000000..5339046 --- /dev/null +++ b/migrations/Version20260202213728.php @@ -0,0 +1,45 @@ +addSql('CREATE TABLE lore_document (id UUID NOT NULL, category VARCHAR(30) NOT NULL, priority INT DEFAULT 50 NOT NULL, always_include_in_context BOOLEAN DEFAULT false NOT NULL, active BOOLEAN DEFAULT true NOT NULL, summary TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN lore_document.id IS \'(DC2Type:uuid)\''); + $this->addSql('CREATE TABLE lore_document_tags (lore_document_id UUID NOT NULL, tag_id UUID NOT NULL, PRIMARY KEY(lore_document_id, tag_id))'); + $this->addSql('CREATE INDEX IDX_B6D256829AE48167 ON lore_document_tags (lore_document_id)'); + $this->addSql('CREATE INDEX IDX_B6D25682BAD26311 ON lore_document_tags (tag_id)'); + $this->addSql('COMMENT ON COLUMN lore_document_tags.lore_document_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN lore_document_tags.tag_id IS \'(DC2Type:uuid)\''); + $this->addSql('ALTER TABLE lore_document ADD CONSTRAINT FK_40DB29E0BF396750 FOREIGN KEY (id) REFERENCES story_object (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE lore_document_tags ADD CONSTRAINT FK_B6D256829AE48167 FOREIGN KEY (lore_document_id) REFERENCES lore_document (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE lore_document_tags ADD CONSTRAINT FK_B6D25682BAD26311 FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE lore_document DROP CONSTRAINT FK_40DB29E0BF396750'); + $this->addSql('ALTER TABLE lore_document_tags DROP CONSTRAINT FK_B6D256829AE48167'); + $this->addSql('ALTER TABLE lore_document_tags DROP CONSTRAINT FK_B6D25682BAD26311'); + $this->addSql('DROP TABLE lore_document'); + $this->addSql('DROP TABLE lore_document_tags'); + } +} diff --git a/src/Domain/Core/Security/Voter/LarpViewVoter.php b/src/Domain/Core/Security/Voter/LarpViewVoter.php new file mode 100644 index 0000000..8da73ab --- /dev/null +++ b/src/Domain/Core/Security/Voter/LarpViewVoter.php @@ -0,0 +1,38 @@ +getUser(); + if (!$user instanceof User) { + return false; + } + + $participants = $subject->getParticipants(); + /** @var LarpParticipant|false $participant */ + $participant = $participants->filter( + fn (LarpParticipant $participant): bool => $participant->getUser()->getId() === $user->getId() + )->first(); + + return $participant instanceof LarpParticipant; + } +} diff --git a/src/Domain/StoryAI/Command/ReindexStoryAICommand.php b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php index 5611bd1..f4074b9 100644 --- a/src/Domain/StoryAI/Command/ReindexStoryAICommand.php +++ b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php @@ -7,7 +7,10 @@ use App\Domain\Core\Entity\Larp; use App\Domain\StoryAI\Message\ReindexLarpMessage; use App\Domain\StoryAI\Service\Embedding\EmbeddingService; +use App\Domain\StoryAI\Service\Embedding\StoryObjectSerializer; +use App\Domain\StoryAI\Service\Provider\EmbeddingProviderInterface; use App\Domain\StoryAI\Service\VectorStore\VectorStoreInterface; +use App\Domain\StoryObject\Entity\StoryObject; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -30,6 +33,8 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly VectorStoreInterface $vectorStore, private readonly MessageBusInterface $messageBus, + private readonly StoryObjectSerializer $serializer, + private readonly EmbeddingProviderInterface $embeddingProvider, ) { parent::__construct(); } @@ -39,7 +44,9 @@ protected function configure(): void $this ->addArgument('larp-id', InputArgument::REQUIRED, 'The LARP ID to reindex (UUID)') ->addOption('async', 'a', InputOption::VALUE_NONE, 'Process indexing asynchronously via messenger') - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force reindex even if content unchanged'); + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force reindex even if content unchanged') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Test serialization and embedding without storing (for local testing)') + ->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit number of objects to process (useful with --dry-run)', '3'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -64,6 +71,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->title(sprintf('Reindexing LARP: %s', $larp->getTitle())); + // Dry-run mode: test pipeline without vector store + if ($input->getOption('dry-run')) { + return $this->processDryRun($io, $larp, (int) $input->getOption('limit')); + } + // Show vector store status $io->info([ sprintf('Vector store: %s', $this->vectorStore->getProviderName()), @@ -72,6 +84,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$this->vectorStore->isAvailable()) { $io->error('Vector store is not available. Check your configuration.'); + $io->note('Use --dry-run to test serialization and embedding generation locally.'); return Command::FAILURE; } @@ -82,6 +95,77 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->processSync($io, $larp, $input->getOption('force')); } + private function processDryRun(SymfonyStyle $io, Larp $larp, int $limit): int + { + $io->section('DRY RUN: Testing serialization and embedding generation'); + $io->note('This will call OpenAI for embeddings but will NOT store anything.'); + + $storyObjects = $this->entityManager + ->getRepository(StoryObject::class) + ->findBy(['larp' => $larp], limit: $limit); + + if (empty($storyObjects)) { + $io->warning('No story objects found for this LARP.'); + return Command::SUCCESS; + } + + $io->text(sprintf('Processing %d story objects...', count($storyObjects))); + $io->newLine(); + + $success = 0; + $errors = 0; + + foreach ($storyObjects as $storyObject) { + $type = (new \ReflectionClass($storyObject))->getShortName(); + $io->section(sprintf('[%s] %s', $type, $storyObject->getTitle())); + + try { + // Step 1: Serialize + $io->text('1. Serializing...'); + $serialized = $this->serializer->serialize($storyObject); + $charCount = strlen($serialized); + $io->text(sprintf(' Serialized: %d characters', $charCount)); + + // Show preview + $preview = substr($serialized, 0, 200); + if (strlen($serialized) > 200) { + $preview .= '...'; + } + $io->text(' Preview:'); + $io->block($preview, null, 'fg=gray'); + + // Step 2: Generate embedding + $io->text('2. Generating embedding via OpenAI...'); + $embedding = $this->embeddingProvider->embed($serialized); + $io->text(sprintf(' Embedding: %d dimensions', count($embedding))); + $io->text(sprintf(' First 5 values: [%s]', implode(', ', array_map( + fn ($v) => number_format($v, 6), + array_slice($embedding, 0, 5) + )))); + + $io->success('OK'); + $success++; + } catch (\Throwable $e) { + $io->error(sprintf('Error: %s', $e->getMessage())); + $errors++; + } + } + + $io->newLine(); + $io->section('Summary'); + $io->listing([ + sprintf('Success: %d', $success), + sprintf('Errors: %d', $errors), + ]); + + if ($errors === 0) { + $io->success('Dry run completed successfully! Your pipeline is working.'); + $io->note('To actually index, configure VECTOR_STORE_DSN and run without --dry-run.'); + } + + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } + private function processAsync(SymfonyStyle $io, Larp $larp): int { $io->section('Dispatching async reindex message'); diff --git a/src/Domain/StoryAI/Controller/Backoffice/AIAssistantPageController.php b/src/Domain/StoryAI/Controller/Backoffice/AIAssistantPageController.php new file mode 100644 index 0000000..5f71f3a --- /dev/null +++ b/src/Domain/StoryAI/Controller/Backoffice/AIAssistantPageController.php @@ -0,0 +1,23 @@ +render('domain/story_ai/assistant/chat.html.twig', [ + 'larp' => $larp, + ]); + } +} diff --git a/src/Domain/StoryAI/Security/Voter/AIAssistantVoter.php b/src/Domain/StoryAI/Security/Voter/AIAssistantVoter.php new file mode 100644 index 0000000..9f11b52 --- /dev/null +++ b/src/Domain/StoryAI/Security/Voter/AIAssistantVoter.php @@ -0,0 +1,42 @@ +getUser(); + if (!$user instanceof User) { + return false; + } + + $participants = $subject->getParticipants(); + /** @var LarpParticipant|false $participant */ + $participant = $participants->filter( + fn (LarpParticipant $participant): bool => $participant->getUser()->getId() === $user->getId() + && ( + $participant->isOrganizer() + || $participant->isStoryWriter() + ) + )->first(); + + return $participant instanceof LarpParticipant; + } +} diff --git a/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php index 3f2f4aa..2444559 100644 --- a/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php +++ b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php @@ -8,6 +8,7 @@ use App\Domain\StoryObject\Entity\Event; use App\Domain\StoryObject\Entity\Faction; use App\Domain\StoryObject\Entity\Item; +use App\Domain\StoryObject\Entity\LoreDocument; use App\Domain\StoryObject\Entity\Place; use App\Domain\StoryObject\Entity\Quest; use App\Domain\StoryObject\Entity\Relation; @@ -33,6 +34,7 @@ public function serialize(StoryObject $storyObject): string $storyObject instanceof Place => $this->serializePlace($storyObject), $storyObject instanceof Item => $this->serializeItem($storyObject), $storyObject instanceof Relation => $this->serializeRelation($storyObject), + $storyObject instanceof LoreDocument => $this->serializeLoreDocument($storyObject), default => $this->serializeGeneric($storyObject), }; } @@ -63,6 +65,9 @@ private function serializeCharacter(Character $character): string $parts[] = sprintf('Story notes: %s', $this->cleanHtml($character->getNotes())); } + $parts[] = sprintf('Amount of threads: %s', $this->cleanHtml($character->getDescription())); + + // Factions $factions = $character->getFactions(); if (!$factions->isEmpty()) { @@ -393,6 +398,45 @@ private function serializeRelation(Relation $relation): string return implode("\n", $parts); } + private function serializeLoreDocument(LoreDocument $loreDocument): string + { + $parts = []; + + // Header with category + $parts[] = sprintf( + 'Lore Document [%s]: %s', + $loreDocument->getCategory()->getLabel(), + $loreDocument->getTitle() + ); + + // Summary if available + if ($loreDocument->getSummary()) { + $parts[] = sprintf('Summary: %s', $this->cleanHtml($loreDocument->getSummary())); + } + + // Full description/content + if ($loreDocument->getDescription()) { + $parts[] = sprintf('Content: %s', $this->cleanHtml($loreDocument->getDescription())); + } + + // Tags + $tags = $loreDocument->getTags(); + if (!$tags->isEmpty()) { + $tagNames = []; + foreach ($tags as $tag) { + $tagNames[] = $tag->getTitle(); + } + $parts[] = sprintf('Tags: %s', implode(', ', $tagNames)); + } + + // Priority indicator for AI context + if ($loreDocument->isAlwaysIncludeInContext()) { + $parts[] = 'Note: This is core world-building information.'; + } + + return implode("\n", $parts); + } + private function serializeGeneric(StoryObject $storyObject): string { $parts = []; diff --git a/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php b/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php index 3e68918..27e0d04 100644 --- a/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php +++ b/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php @@ -25,18 +25,27 @@ public function __construct( public function create(string $dsn): VectorStoreInterface { + $dsn = trim($dsn); + if (empty($dsn) || $dsn === 'null://') { $this->logger?->info('Vector store disabled (DSN not configured)'); return new NullVectorStore($this->logger); } $parsed = $this->parseDsn($dsn); + $scheme = $parsed['scheme'] ?? ''; + + // Empty scheme means invalid/missing DSN - default to null store + if (empty($scheme)) { + $this->logger?->info('Vector store disabled (invalid DSN, no scheme)'); + return new NullVectorStore($this->logger); + } - return match ($parsed['scheme']) { + return match ($scheme) { 'supabase' => $this->createSupabase($parsed), 'null' => new NullVectorStore($this->logger), default => throw new \InvalidArgumentException( - sprintf('Unknown vector store provider: %s', $parsed['scheme']) + sprintf('Unknown vector store provider: %s', $scheme) ), }; } diff --git a/src/Domain/StoryObject/Controller/Backoffice/LoreDocumentController.php b/src/Domain/StoryObject/Controller/Backoffice/LoreDocumentController.php new file mode 100644 index 0000000..dcb0106 --- /dev/null +++ b/src/Domain/StoryObject/Controller/Backoffice/LoreDocumentController.php @@ -0,0 +1,111 @@ +createForm(LoreDocumentFilterType::class); + $filterForm->handleRequest($request); + + $qb = $repository->createFilteredQueryBuilder($larp); + $this->filterBuilderUpdater->addFilterConditions($filterForm, $qb); + + $sort = $request->query->get('sort', 'priority'); + $dir = $request->query->get('dir', 'desc'); + + // Handle sorting + if ($sort === 'category') { + $qb->orderBy('ld.category', $dir); + } elseif ($sort === 'title') { + $qb->orderBy('ld.title', $dir); + } else { + $qb->orderBy('ld.priority', $dir) + ->addOrderBy('ld.title', 'ASC'); + } + + return $this->render('domain/story_object/lore_document/list.html.twig', [ + 'filterForm' => $filterForm->createView(), + 'loreDocuments' => $qb->getQuery()->getResult(), + 'larp' => $larp, + ]); + } + + #[Route('{loreDocument}', name: 'modify', defaults: ['loreDocument' => null], methods: ['GET', 'POST'])] + public function modify( + LarpManager $larpManager, + IntegrationManager $integrationManager, + StoryObjectMentionService $mentionService, + Request $request, + Larp $larp, + LoreDocumentRepository $repository, + ?LoreDocument $loreDocument = null, + ): Response { + $new = false; + if (!$loreDocument instanceof LoreDocument) { + $loreDocument = new LoreDocument(); + $loreDocument->setLarp($larp); + $new = true; + } + + $form = $this->createForm(LoreDocumentType::class, $loreDocument, ['larp' => $larp]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $repository->save($loreDocument); + $this->processIntegrationsForStoryObject($larpManager, $larp, $integrationManager, $new, $loreDocument); + $this->addFlash('success', $this->translator->trans('success_save')); + return $this->redirectToRoute('backoffice_larp_story_loreDocument_list', ['larp' => $larp->getId()]); + } + + // Get mentions only for existing documents +// $mentions = []; +// if (!$new) { +// $mentions = $mentionService->findMentions($loreDocument); +// } + + return $this->render('domain/story_object/lore_document/modify.html.twig', [ + 'form' => $form->createView(), + 'larp' => $larp, + 'loreDocument' => $loreDocument, +// 'mentions' => $mentions, + ]); + } + + #[Route('{loreDocument}/delete', name: 'delete', methods: ['GET', 'POST'])] + public function delete( + LarpManager $larpManager, + IntegrationManager $integrationManager, + Larp $larp, + Request $request, + LoreDocumentRepository $repository, + LoreDocument $loreDocument, + ): Response { + $deleteIntegrations = $request->query->getBoolean('integrations'); + if ($deleteIntegrations && !$this->removeStoryObjectFromIntegrations($larpManager, $larp, $integrationManager, $loreDocument, 'LoreDocument')) { + return $this->redirectToRoute('backoffice_larp_story_loreDocument_list', ['larp' => $larp->getId()]); + } + + $repository->remove($loreDocument); + $this->addFlash('success', $this->translator->trans('success_delete')); + return $this->redirectToRoute('backoffice_larp_story_loreDocument_list', ['larp' => $larp->getId()]); + } +} diff --git a/src/Domain/StoryObject/Entity/Enum/LoreDocumentCategory.php b/src/Domain/StoryObject/Entity/Enum/LoreDocumentCategory.php new file mode 100644 index 0000000..d8552d1 --- /dev/null +++ b/src/Domain/StoryObject/Entity/Enum/LoreDocumentCategory.php @@ -0,0 +1,40 @@ + 'World Setting', + self::TIMELINE => 'Timeline', + self::RELIGION => 'Religion', + self::MAGIC_SYSTEM => 'Magic System', + self::CULTURE => 'Culture', + self::GEOGRAPHY => 'Geography', + self::POLITICS => 'Politics', + self::ECONOMICS => 'Economics', + self::HISTORY => 'History', + self::RULES => 'Rules & Mechanics', + self::GENERAL => 'General', + }; + } +} diff --git a/src/Domain/StoryObject/Entity/Enum/TargetType.php b/src/Domain/StoryObject/Entity/Enum/TargetType.php index 8a07e8b..e156e74 100755 --- a/src/Domain/StoryObject/Entity/Enum/TargetType.php +++ b/src/Domain/StoryObject/Entity/Enum/TargetType.php @@ -7,6 +7,7 @@ use App\Domain\StoryObject\Entity\Event; use App\Domain\StoryObject\Entity\Faction; use App\Domain\StoryObject\Entity\Item; +use App\Domain\StoryObject\Entity\LoreDocument; use App\Domain\StoryObject\Entity\Place; use App\Domain\StoryObject\Entity\Quest; use App\Domain\StoryObject\Entity\Relation; @@ -37,6 +38,7 @@ enum TargetType: string case Relation = 'relation'; // describes relation between players/factions, can be anything starting from friendship, family to rivalry case Tag = 'tag'; case MapLocation = 'map_location'; // a location on a game map, used for tagging map markers + case LoreDocument = 'lore_document'; // general lore/world-building document (religion, timeline, setting, etc.) //Both storyline -> threads -> events and quests can have a decision tree public function getEntityClass(): string @@ -51,6 +53,7 @@ public function getEntityClass(): string self::Item => Item::class, self::Place => Place::class, self::Tag => Tag::class, + self::LoreDocument => LoreDocument::class, // Use FQCN string to avoid cross-domain import self::MapLocation => 'App\\Domain\\Map\\Entity\\MapLocation', }; diff --git a/src/Domain/StoryObject/Entity/LoreDocument.php b/src/Domain/StoryObject/Entity/LoreDocument.php new file mode 100644 index 0000000..fb45a54 --- /dev/null +++ b/src/Domain/StoryObject/Entity/LoreDocument.php @@ -0,0 +1,152 @@ + 50])] + private int $priority = 50; + + /** + * Whether this document should always be included in AI context. + * Use sparingly for critical world-building information. + */ + #[ORM\Column(type: 'boolean', options: ['default' => false])] + private bool $alwaysIncludeInContext = false; + + /** + * Whether this document is active and should be used/indexed. + */ + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + /** + * Optional summary for quick reference (shown in lists, used for embedding). + */ + #[Gedmo\Versioned] + #[ORM\Column(type: 'text', nullable: true)] + private ?string $summary = null; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Tag::class)] + #[ORM\JoinTable(name: 'lore_document_tags')] + private Collection $tags; + + public function __construct() + { + parent::__construct(); + $this->tags = new ArrayCollection(); + } + + public function getCategory(): LoreDocumentCategory + { + return $this->category; + } + + public function setCategory(LoreDocumentCategory $category): self + { + $this->category = $category; + return $this; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): self + { + $this->priority = $priority; + return $this; + } + + public function isAlwaysIncludeInContext(): bool + { + return $this->alwaysIncludeInContext; + } + + public function setAlwaysIncludeInContext(bool $alwaysIncludeInContext): self + { + $this->alwaysIncludeInContext = $alwaysIncludeInContext; + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + return $this; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function setSummary(?string $summary): self + { + $this->summary = $summary; + return $this; + } + + /** + * @return Collection + */ + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(Tag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags->add($tag); + } + return $this; + } + + public function removeTag(Tag $tag): self + { + $this->tags->removeElement($tag); + return $this; + } + + public static function getTargetType(): TargetType + { + return TargetType::LoreDocument; + } +} diff --git a/src/Domain/StoryObject/Entity/StoryObject.php b/src/Domain/StoryObject/Entity/StoryObject.php index fb41917..6e2992a 100755 --- a/src/Domain/StoryObject/Entity/StoryObject.php +++ b/src/Domain/StoryObject/Entity/StoryObject.php @@ -32,6 +32,7 @@ TargetType::Faction->value => Faction::class, TargetType::Item->value => Item::class, TargetType::Place->value => Place::class, + TargetType::LoreDocument->value => LoreDocument::class, ])] #[Gedmo\Loggable(logEntryClass: StoryObjectLogEntry::class)] abstract class StoryObject implements CreatorAwareInterface, Timestampable, \App\Domain\Core\Entity\TargetableInterface, LarpAwareInterface @@ -53,7 +54,6 @@ abstract class StoryObject implements CreatorAwareInterface, Timestampable, \App #[ORM\Column(type: 'text', nullable: true)] protected ?string $description = null; - #[Gedmo\Versioned] #[ORM\ManyToOne(targetEntity: Larp::class)] #[ORM\JoinColumn(nullable: false)] protected ?Larp $larp = null; @@ -128,4 +128,14 @@ public function getRelationsTo(): Collection { return $this->relationsTo; } + + public function isNew(): bool + { + return $this->createdAt === null; + } + + public function exists(): bool + { + return $this->createdAt !== null; + } } diff --git a/src/Domain/StoryObject/Form/Filter/LoreDocumentFilterType.php b/src/Domain/StoryObject/Form/Filter/LoreDocumentFilterType.php new file mode 100644 index 0000000..93780f2 --- /dev/null +++ b/src/Domain/StoryObject/Form/Filter/LoreDocumentFilterType.php @@ -0,0 +1,46 @@ +add('title', TextFilterType::class, [ + 'required' => false, + 'label' => 'title', + 'attr' => [ + 'placeholder' => 'search_by_title', + ], + ]) + ->add('category', ChoiceFilterType::class, [ + 'required' => false, + 'label' => 'category', + 'choices' => array_combine( + array_map(fn (LoreDocumentCategory $c) => $c->getLabel(), LoreDocumentCategory::cases()), + array_map(fn (LoreDocumentCategory $c) => $c->value, LoreDocumentCategory::cases()) + ), + 'placeholder' => 'all_categories', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_protection' => false, + 'validation_groups' => ['filtering'], + 'method' => 'GET', + 'translation_domain' => 'forms', + ]); + } +} diff --git a/src/Domain/StoryObject/Form/LoreDocumentType.php b/src/Domain/StoryObject/Form/LoreDocumentType.php new file mode 100644 index 0000000..d449f85 --- /dev/null +++ b/src/Domain/StoryObject/Form/LoreDocumentType.php @@ -0,0 +1,100 @@ +add('title', TextType::class, [ + 'label' => 'lore.title', + ]) + ->add('category', EnumType::class, [ + 'class' => LoreDocumentCategory::class, + 'label' => 'lore.category', + 'choice_label' => fn (LoreDocumentCategory $category) => $category->getLabel(), + ]) + ->add('summary', TextareaType::class, [ + 'label' => 'lore.summary', + 'required' => false, + 'attr' => [ + 'rows' => 3, + 'placeholder' => 'lore.summary_placeholder', + ], + 'help' => 'lore.summary_help', + ]) + ->add('description', TextareaType::class, [ + 'label' => 'lore.content', + 'required' => false, + 'attr' => [ + 'data-controller' => 'wysiwyg', + ], + ]) + ->add('priority', IntegerType::class, [ + 'label' => 'lore.priority', + 'attr' => [ + 'min' => 0, + 'max' => 100, + ], + 'help' => 'lore.priority_help', + ]) + ->add('alwaysIncludeInContext', CheckboxType::class, [ + 'label' => 'lore.always_include', + 'required' => false, + 'help' => 'lore.always_include_help', + ]) + ->add('active', CheckboxType::class, [ + 'label' => 'lore.active', + 'required' => false, + ]) + ->add('tags', EntityType::class, [ + 'class' => Tag::class, + 'query_builder' => fn (EntityRepository $repo) => $repo->createQueryBuilder('t') + ->where('t.larp = :larp') + ->setParameter('larp', $larp) + ->orderBy('t.title', 'ASC'), + 'choice_label' => 'title', + 'multiple' => true, + 'required' => false, + 'label' => 'tags', + 'autocomplete' => true, + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'submit', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => LoreDocument::class, + 'translation_domain' => 'forms', + 'larp' => null, + ]); + + $resolver->setRequired('larp'); + $resolver->setAllowedTypes('larp', Larp::class); + } +} diff --git a/src/Domain/StoryObject/Repository/LoreDocumentRepository.php b/src/Domain/StoryObject/Repository/LoreDocumentRepository.php new file mode 100644 index 0000000..196a33e --- /dev/null +++ b/src/Domain/StoryObject/Repository/LoreDocumentRepository.php @@ -0,0 +1,107 @@ + + * + * @method null|LoreDocument find($id, $lockMode = null, $lockVersion = null) + * @method null|LoreDocument findOneBy(array $criteria, array $orderBy = null) + * @method LoreDocument[] findAll() + * @method LoreDocument[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class LoreDocumentRepository extends BaseRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, LoreDocument::class); + } + + /** + * Find all active lore documents for a LARP, ordered by priority. + * + * @return LoreDocument[] + */ + public function findActiveByLarp(Larp $larp): array + { + return $this->createQueryBuilder('ld') + ->where('ld.larp = :larp') + ->andWhere('ld.active = true') + ->setParameter('larp', $larp) + ->orderBy('ld.priority', 'DESC') + ->addOrderBy('ld.title', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Find lore documents that should always be included in AI context. + * + * @return LoreDocument[] + */ + public function findAlwaysInclude(Larp $larp): array + { + return $this->createQueryBuilder('ld') + ->where('ld.larp = :larp') + ->andWhere('ld.active = true') + ->andWhere('ld.alwaysIncludeInContext = true') + ->setParameter('larp', $larp) + ->orderBy('ld.priority', 'DESC') + ->getQuery() + ->getResult(); + } + + /** + * Find lore documents by category. + * + * @return LoreDocument[] + */ + public function findByCategory(Larp $larp, LoreDocumentCategory $category): array + { + return $this->createQueryBuilder('ld') + ->where('ld.larp = :larp') + ->andWhere('ld.active = true') + ->andWhere('ld.category = :category') + ->setParameter('larp', $larp) + ->setParameter('category', $category) + ->orderBy('ld.priority', 'DESC') + ->addOrderBy('ld.title', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Count active lore documents for a LARP. + */ + public function countActiveByLarp(Larp $larp): int + { + return (int) $this->createQueryBuilder('ld') + ->select('COUNT(ld.id)') + ->where('ld.larp = :larp') + ->andWhere('ld.active = true') + ->setParameter('larp', $larp) + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * Create a query builder for filtering lore documents. + */ + public function createFilteredQueryBuilder(Larp $larp): QueryBuilder + { + return $this->createQueryBuilder('ld') + ->where('ld.larp = :larp') + ->setParameter('larp', $larp) + ->orderBy('ld.priority', 'DESC') + ->addOrderBy('ld.title', 'ASC'); + } +} diff --git a/templates/backoffice/larp/_menu.html.twig b/templates/backoffice/larp/_menu.html.twig index 59041c4..52f1946 100755 --- a/templates/backoffice/larp/_menu.html.twig +++ b/templates/backoffice/larp/_menu.html.twig @@ -154,6 +154,11 @@ {{ 'larp.faction.list'|trans }} +
  • + + {{ 'lore.document.list'|trans }} + +
  • {{ 'marketplace.singular'|trans }} @@ -260,6 +265,17 @@ {{ 'gallery.list'|trans }}
  • + + {# AI Assistant #} + {% if is_granted('VIEW_BO_AI_ASSISTANT', larp) %} + + {% endif %} diff --git a/templates/domain/story_ai/assistant/chat.html.twig b/templates/domain/story_ai/assistant/chat.html.twig new file mode 100644 index 0000000..c3fd941 --- /dev/null +++ b/templates/domain/story_ai/assistant/chat.html.twig @@ -0,0 +1,80 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% block larp_title %}{{ 'ai_assistant.title'|trans }}{% endblock %} + +{% block larp_content %} +
    + +
    +
    +

    + {{ 'ai_assistant.title'|trans }} +

    + +
    +
    + +
    + {# Messages area #} +
    + {# Welcome message #} +
    +
    + +
    +
    + {{ 'ai_assistant.welcome_message'|trans({'%larp%': larp.title}) }} +
    +
    +
    + + {# Typing indicator #} + + + {# Error display #} + + + {# Sources panel #} + +
    + + +
    +{% endblock %} diff --git a/templates/domain/story_object/lore_document/list.html.twig b/templates/domain/story_object/lore_document/list.html.twig new file mode 100644 index 0000000..dfd247c --- /dev/null +++ b/templates/domain/story_object/lore_document/list.html.twig @@ -0,0 +1,82 @@ +{% extends 'backoffice/larp/base.html.twig' %} +{% import 'macros/ui_components.html.twig' as ui %} + +{% block larp_title %}{{ 'lore.document.list'|trans }} - {{ larp.title }}{% endblock %} + +{% block larp_content %} +
    + {% set actions %} + {{ ui.primary_button('create', path('backoffice_larp_story_lore_document_modify', { larp: larp.id }), 'bi-plus-circle') }} + {% endset %} + {{ ui.card_header('lore.document.list', actions) }} + +
    + {% include 'includes/filter_form.html.twig' with { form: filterForm } %} + + {% if loreDocuments is not empty %} +
    + + + + {% include 'includes/sort_th.html.twig' with { + field: 'title', + label: 'title'|trans + } %} + {% include 'includes/sort_th.html.twig' with { + field: 'category', + label: 'category'|trans + } %} + {% include 'includes/sort_th.html.twig' with { + field: 'priority', + label: 'priority'|trans + } %} + + + + + + {% for doc in loreDocuments %} + + + + + + + + {% endfor %} + +
    {{ 'status'|trans }}{{ 'actions'|trans }}
    + + {{ doc.title }} + + {% if doc.summary %} +
    {{ doc.summary|u.truncate(80, '...') }} + {% endif %} +
    + {{ doc.category.label }} + + + {{ doc.priority }} + + {% if doc.alwaysIncludeInContext %} + + {% endif %} + + {% if doc.active %} + {{ 'active'|trans }} + {% else %} + {{ 'inactive'|trans }} + {% endif %} + + {{ ui.delete_button(doc.id, doc.title, path('backoffice_larp_story_lore_document_delete', { larp: larp.id, loreDocument: doc.id })) }} +
    +
    + {% else %} + {{ ui.empty_state('empty_list', 'bi-journal-text') }} + {% endif %} +
    +
    + + {# Unified delete modal with Stimulus controller #} + {% include 'includes/delete_modal.html.twig' %} +{% endblock %} diff --git a/templates/domain/story_object/lore_document/modify.html.twig b/templates/domain/story_object/lore_document/modify.html.twig new file mode 100644 index 0000000..6c5740c --- /dev/null +++ b/templates/domain/story_object/lore_document/modify.html.twig @@ -0,0 +1,46 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% import 'includes/story_object_tabs.html.twig' as tabs_macro %} +{% import 'macros/ui_components.html.twig' as ui %} + +{% block larp_content %} + {# Header showing story object type and title (only for existing objects) #} + {% if loreDocument.exists %} + {% include 'includes/story_object_header.html.twig' with { + storyObject: loreDocument, + storyObjectType: 'lore.document.singular', + } %} + + {# Tabs navigation #} + {{ tabs_macro.tabs('details', loreDocument, 'lore_document', larp, null, false) }} + {% endif %} + +
    + {{ form_start(form) }} + +
    +
    + {{ form_row(form.title) }} + {{ form_row(form.summary) }} + {{ form_row(form.description) }} +
    +
    + {{ form_row(form.category) }} + {{ form_row(form.priority) }} + {{ form_row(form.alwaysIncludeInContext) }} + {{ form_row(form.active) }} + {{ form_row(form.tags) }} +
    +
    + + {{ ui.form_actions( + loreDocument.exists ? 'save' : 'create', + 'bi-check-circle', + path('backoffice_larp_story_lore_document_list', { larp: larp.id }) + ) }} + {{ form_end(form) }} +
    + +{% endblock %} + +{% block larp_title %}{{ (loreDocument.exists ? 'lore.document.modify' : 'lore.document.create')|trans }} - {{ larp.title }}{% endblock %} diff --git a/templates/includes/story_object_tabs.html.twig b/templates/includes/story_object_tabs.html.twig index 93fbba5..f91ebf7 100644 --- a/templates/includes/story_object_tabs.html.twig +++ b/templates/includes/story_object_tabs.html.twig @@ -22,6 +22,7 @@ {# Mentions Tab #} + {% if mentionsCount != false %} + {% endif %} {# Decision Tree Tab (only for Quest and Thread) #} {% if storyObjectType == 'quest' or storyObjectType == 'thread' %} @@ -52,6 +54,7 @@ {% endif %} {# Discussions Tab #} + {% if storyObject.exists %} + {% endif %} {% endmacro %} diff --git a/translations/forms.en.yaml b/translations/forms.en.yaml index 5d2ddf3..3d000e2 100755 --- a/translations/forms.en.yaml +++ b/translations/forms.en.yaml @@ -531,6 +531,23 @@ location_form: is_active: "Active" is_active_help: "Is this location currently available for events?" +# Lore Documents +lore: + title: "Document Title" + category: "Category" + content: "Content" + summary: "Summary" + summary_placeholder: "Brief overview of this document (shown in lists)" + summary_help: "A short summary helps identify this document quickly and improves AI search accuracy" + priority: "Priority" + priority_help: "Higher priority documents (0-100) are included first in AI context" + always_include: "Always Include in AI Context" + always_include_help: "Use sparingly - forces this document to be included in all AI queries" + active: "Active" + +all_categories: "All Categories" +search_by_title: "Search by title..." + # Common form actions save: "Save" cancel: "Cancel" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 414e6e7..d18c983 100755 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -130,6 +130,10 @@ details: 'Details' start_date: 'Start Date' end_date: 'End Date' status: 'Status' +priority: 'Priority' +category: 'Category' +active: 'Active' +inactive: 'Inactive' user: 'User' view_all: 'View all' title: 'Title' @@ -981,6 +985,13 @@ lore: character: 'Character-specific (visible to specific characters)' view_timeline: 'View Lore Timeline' backoffice_timeline: 'Timeline View' + document: + singular: 'Lore Document' + plural: 'Lore Documents' + list: 'Lore Documents' + create: 'Create Lore Document' + modify: 'Edit Lore Document' + view: 'Lore Document Details' feedback: modal: @@ -1417,6 +1428,13 @@ larp_type: other: 'Other' # Common UI Elements +ai_assistant: + title: 'AI Assistant' + welcome_message: 'Hello! I am the AI Assistant for "%larp%". Ask me anything about the story, characters, events, quests, or lore.' + input_placeholder: 'Ask about your LARP story...' + new_conversation: 'New Conversation' + sources: 'Sources Referenced' + ui: loading: 'Loading...' saving: 'Saving...'