diff --git a/.env b/.env index 20fbe70..29d0bc4 100755 --- a/.env +++ b/.env @@ -63,4 +63,22 @@ 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 + +# 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/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/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..ff32a4a 100755 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -25,5 +25,6 @@ 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 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/config/services.yaml b/config/services.yaml index 62005e8..0af86ed 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,6 +8,12 @@ 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)%' + # 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. @@ -54,4 +60,28 @@ 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 + + # 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..1a7a160 --- /dev/null +++ b/docs/technical/STORY_AI.md @@ -0,0 +1,281 @@ +# 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 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/ +├── 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 # Converts StoryObjects to text +│ ├── Query/ +│ │ ├── 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 +│ ├── 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/{larp-uuid}/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": "uuid", "title": "King Aldric"}, + {"type": "Thread", "id": "uuid", "title": "The Northern Wars"} + ] +} +``` + +## Indexing + +### Automatic Indexing + +Story objects are automatically indexed when created/updated via `StoryObjectIndexSubscriber`. + +### Manual Reindex + +```bash +# Reindex a specific LARP (synchronous) +php bin/console app:story-ai:reindex + +# Force reindex (even if content unchanged) +php bin/console app:story-ai:reindex --force + +# Async via Messenger +php bin/console app:story-ai:reindex --async +``` + +## Setup Guide + +### Prerequisites + +- OpenAI API key +- Supabase project with pgvector extension +- Symfony Messenger configured (for async indexing) + +--- + +### Step 1: Set Up Supabase + +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`: + +```env +# OpenAI +OPENAI_API_KEY=sk-your-api-key-here +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +OPENAI_COMPLETION_MODEL=gpt-4o-mini + +# Supabase Vector Store +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_KEY=your-service-role-key + +# Vector store provider (supabase or null) +VECTOR_STORE_PROVIDER=supabase +``` + +--- + +### Step 3: Configure Message Queue + +StoryAI uses Symfony Messenger for async indexing. + +**Local Development:** + +```env +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +``` + +Run the worker: + +```bash +docker compose exec php php bin/console messenger:consume async -vv +``` + +**Production:** + +```env +# Redis +MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +``` + +--- + +### Step 4: Initial Indexing + +Index existing story objects: + +```bash +# Get your LARP UUID +php bin/console doctrine:query:sql "SELECT id, title FROM larp LIMIT 5" + +# Index the LARP +php bin/console app:story-ai:reindex +``` + +--- + +### Step 5: Verify Setup + +```bash +curl -X POST http://localhost/api/larp//ai/search \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"query": "test"}' +``` + +--- + +## Key Services + +### EmbeddingService + +Handles indexing of story objects: + +```php +// 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] + +// Delete embedding when story object is deleted +$embeddingService->deleteStoryObjectEmbedding($character); + +// Generate embedding for a query +$vector = $embeddingService->generateQueryEmbedding("Who is the king?"); +``` + +### VectorSearchService + +Performs similarity search: + +```php +$results = $vectorSearchService->search( + larp: $larp, + query: "characters involved in the rebellion", + limit: 10, + minSimilarity: 0.5 +); +// Returns SearchResult[] with similarity scores +``` + +### RAGQueryService + +Main entry point for AI queries: + +```php +$result = $ragQueryService->query($larp, "Who are the main antagonists?"); +// Returns AIQueryResult with answer and sources +``` + +### ContextBuilder + +Assembles context for LLM prompts: + +```php +$context = $contextBuilder->buildContext($searchResults, $larp, maxTokens: 12000); +$systemPrompt = $contextBuilder->buildSystemPrompt($larp); +``` + +--- + +## Cost Considerations + +| 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/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/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/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 new file mode 100644 index 0000000..f4074b9 --- /dev/null +++ b/src/Domain/StoryAI/Command/ReindexStoryAICommand.php @@ -0,0 +1,207 @@ +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('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 + { + $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())); + + // 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()), + sprintf('Available: %s', $this->vectorStore->isAvailable() ? 'Yes' : 'No'), + ]); + + 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; + } + + if ($input->getOption('async')) { + return $this->processAsync($io, $larp); + } + + 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'); + + $this->messageBus->dispatch(new ReindexLarpMessage($larp->getId())); + $io->success('Reindex message dispatched. Run messenger:consume to process.'); + + return Command::SUCCESS; + } + + private function processSync(SymfonyStyle $io, Larp $larp, 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()); + }, + $force + ); + + $progressBar->finish(); + $io->newLine(2); + + $io->success([ + sprintf('Indexed: %d', $stats['indexed']), + sprintf('Skipped (unchanged): %d', $stats['skipped']), + sprintf('Errors: %d', $stats['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/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/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/DTO/VectorDocument.php b/src/Domain/StoryAI/DTO/VectorDocument.php new file mode 100644 index 0000000..5048a05 --- /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/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/IndexStoryObjectMessage.php b/src/Domain/StoryAI/Message/IndexStoryObjectMessage.php new file mode 100644 index 0000000..e367fb7 --- /dev/null +++ b/src/Domain/StoryAI/Message/IndexStoryObjectMessage.php @@ -0,0 +1,18 @@ +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/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/EmbeddingService.php b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php new file mode 100644 index 0000000..f2e7224 --- /dev/null +++ b/src/Domain/StoryAI/Service/Embedding/EmbeddingService.php @@ -0,0 +1,179 @@ +getLarp(); + if (!$larp) { + throw new \InvalidArgumentException('Story object must belong to a LARP'); + } + + // Serialize the story object to text + $serializedContent = $this->serializer->serialize($storyObject); + $contentHash = hash('sha256', $serializedContent); + + // 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 + $vector = $this->embeddingProvider->embed($serializedContent); + + // 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); + + $this->logger?->info('Story object indexed successfully', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + 'type' => $entityType, + 'vector_store' => $this->vectorStore->getProviderName(), + ]); + } + + /** + * Reindex all story objects for a LARP. + * + * @return array{indexed: int, skipped: int, errors: int} + */ + public function reindexLarp(Larp $larp, ?callable $progressCallback = null, bool $force = false): 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, + 'vector_store' => $this->vectorStore->getProviderName(), + ]); + + foreach ($storyObjects as $index => $storyObject) { + try { + $serializedContent = $this->serializer->serialize($storyObject); + $contentHash = hash('sha256', $serializedContent); + + // 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) { + $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 from the vector store. + */ + public function deleteStoryObjectEmbedding(StoryObject $storyObject): void + { + $this->vectorStore->delete($storyObject->getId()); + + $this->logger?->debug('Embedding deleted', [ + 'story_object_id' => $storyObject->getId()->toRfc4122(), + 'vector_store' => $this->vectorStore->getProviderName(), + ]); + } + + /** + * Generate embedding for arbitrary text (for queries). + * + * @return array + */ + 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(); + } +} diff --git a/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php new file mode 100644 index 0000000..2444559 --- /dev/null +++ b/src/Domain/StoryAI/Service/Embedding/StoryObjectSerializer.php @@ -0,0 +1,503 @@ + $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), + $storyObject instanceof LoreDocument => $this->serializeLoreDocument($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 + $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())); + } + + $parts[] = sprintf('Amount of threads: %s', $this->cleanHtml($character->getDescription())); + + + // 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; + if ($target) { + $relationStrings[] = sprintf('%s of %s', $type, $target->getTitle()); + } + } + + foreach ($relationsTo as $relation) { + $source = $relation->getFrom(); + $type = $relation->getRelationType()->value; + 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(); + + if ($from && $to) { + $parts[] = sprintf( + 'Relation: %s is %s to %s', + $from->getTitle(), + $relation->getRelationType()->value, + $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 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 = []; + + $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 + * @return string|null + */ + private function summarizeDecisionTree(?array $decisionTree): ?string + { + if (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..63ba47e --- /dev/null +++ b/src/Domain/StoryAI/Service/Query/ContextBuilder.php @@ -0,0 +1,116 @@ +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 formatSearchResult(SearchResult $result): string + { + $typeLabel = $result->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..1a69f50 --- /dev/null +++ b/src/Domain/StoryAI/Service/Query/VectorSearchService.php @@ -0,0 +1,202 @@ +embeddingService->generateQueryEmbedding($query); + + $this->logger?->debug('Performing vector search', [ + 'query' => $query, + 'larp_id' => $larp->getId()->toRfc4122(), + 'limit' => $limit, + 'vector_store' => $this->vectorStore->getProviderName(), + ]); + + $results = $this->vectorStore->search( + embedding: $queryEmbedding, + larpId: $larp->getId(), + limit: $limit, + minSimilarity: $minSimilarity, + ); + + return array_map( + fn (VectorSearchResult $result) => $result->toSearchResult(), + $results + ); + } + + /** + * Search only story objects. + * + * @return SearchResult[] + */ + public function searchStoryObjects( + string $query, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5, + ): array { + $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 (VectorSearchResult $result) => $result->toSearchResult(), + $results + ); + } + + /** + * Search only lore documents. + * + * @return SearchResult[] + */ + public function searchLoreDocuments( + string $query, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5, + ): array { + $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 (VectorSearchResult $result) => $result->toSearchResult(), + $results + ); + } + + /** + * Search with custom options. + * + * @return SearchResult[] + */ + public function searchByQuery( + string $query, + Larp $larp, + int $limit = 10, + float $minSimilarity = 0.5, + bool $includeStoryObjects = true, + bool $includeLoreDocuments = true, + ?string $entityType = null, + ): array { + $queryEmbedding = $this->embeddingService->generateQueryEmbedding($query); + + $filters = []; + + // Filter by document type + if ($includeStoryObjects && !$includeLoreDocuments) { + $filters['type'] = VectorDocument::TYPE_STORY_OBJECT; + } elseif (!$includeStoryObjects && $includeLoreDocuments) { + $filters['type'] = VectorDocument::TYPE_LORE_CHUNK; + } + + // Filter by entity type (Character, Thread, Quest, etc.) + if ($entityType) { + $filters['entity_type'] = $entityType; + } + + $results = $this->vectorStore->search( + embedding: $queryEmbedding, + larpId: $larp->getId(), + limit: $limit, + minSimilarity: $minSimilarity, + filters: $filters, + ); + + return array_map( + fn (VectorSearchResult $result) => $result->toSearchResult(), + $results + ); + } + + /** + * 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 + ); + } + + /** + * Check if the vector store is available for searches. + */ + public function isAvailable(): bool + { + 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..27e0d04 --- /dev/null +++ b/src/Domain/StoryAI/Service/VectorStore/VectorStoreFactory.php @@ -0,0 +1,107 @@ + 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 + { + $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 ($scheme) { + 'supabase' => $this->createSupabase($parsed), + 'null' => new NullVectorStore($this->logger), + default => throw new \InvalidArgumentException( + sprintf('Unknown vector store provider: %s', $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; +} 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/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'], 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": { 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...'