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) %}
+
+
+ {{ 'ai_assistant.title'|trans }}
+
+
+ {% 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 %}
+
+
+
+
+
+ {# 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
+ } %}
+ | {{ 'status'|trans }} |
+ {{ 'actions'|trans }} |
+
+
+
+ {% for doc in loreDocuments %}
+
+
+
+ {{ 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 })) }}
+ |
+
+ {% endfor %}
+
+
+
+ {% 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 %}
@@ -29,6 +30,7 @@
{{ mentionsCount }}
+ {% 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 %}
@@ -60,5 +63,6 @@
{{ commentsCount }}
+ {% 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...'