From cd28d5963dcfe3036ceb0fba570c9f12a9386d26 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 15 Oct 2026 00:32:19 +0200 Subject: [PATCH 1/4] [AI-178] CRUD for EmbeddingSets https://community.openproject.org/wp/AI-178 From e9d9b0f32705bfcff69c80158183d6c64f15d199 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 15 Oct 2026 00:34:24 +0200 Subject: [PATCH 2/4] Add EmbeddingSet model An embedding set describes one way of indexing work packages for semantic search: the embedding model, the number of vector dimensions and the context length in tokens, together with a display name and a description for administrators. Sets are standalone records. The state column is in place for the ingestion lifecycle (created, ingesting, error, ready) but only "created" is used so far. The model must be an embedding model offered by the active LLM connection. It is stored by its external identifier and only checked when it changes, so a set keeps working as a record if the server stops offering the model later. https://community.openproject.org/wp/AI-178 --- modules/llm/app/models/embedding_set.rb | 53 +++++++++++++ modules/llm/config/locales/en.yml | 12 +++ .../20261014120000_create_embedding_sets.rb | 44 +++++++++++ .../spec/factories/embedding_set_factory.rb | 39 ++++++++++ modules/llm/spec/models/embedding_set_spec.rb | 75 +++++++++++++++++++ .../support/shared/embedding_model_context.rb | 38 ++++++++++ 6 files changed, 261 insertions(+) create mode 100644 modules/llm/app/models/embedding_set.rb create mode 100644 modules/llm/db/migrate/20261014120000_create_embedding_sets.rb create mode 100644 modules/llm/spec/factories/embedding_set_factory.rb create mode 100644 modules/llm/spec/models/embedding_set_spec.rb create mode 100644 modules/llm/spec/support/shared/embedding_model_context.rb diff --git a/modules/llm/app/models/embedding_set.rb b/modules/llm/app/models/embedding_set.rb new file mode 100644 index 000000000000..def43dc57b8f --- /dev/null +++ b/modules/llm/app/models/embedding_set.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class EmbeddingSet < ApplicationRecord + enum :state, { + created: "created", + ingesting: "ingesting", + error: "error", + ready: "ready" + } + + validates :title, presence: true + validates :model_id, presence: true, length: { maximum: LlmModel::MAX_EXTERNAL_ID_LENGTH } + validates :embedding_vector_dimensions, :context_token_length, + numericality: { only_integer: true, greater_than: 0 } + + validate :model_offered, if: -> { model_id.present? && model_id_changed? } + + private + + def model_offered + return if LlmConnection.active_connection.embedding_model_ids.include?(model_id) + + errors.add(:model_id, :not_offered) + end +end diff --git a/modules/llm/config/locales/en.yml b/modules/llm/config/locales/en.yml index 89706f5ccd71..27f861940468 100644 --- a/modules/llm/config/locales/en.yml +++ b/modules/llm/config/locales/en.yml @@ -2,6 +2,14 @@ en: activerecord: attributes: + embedding_set: + context_token_length: "Context length" + description: "Description" + embedding_vector_dimensions: "Dimensions" + # ActiveRecord::Base.human_attribute_name strips the _id suffix. + model: "Model" + state: "Status" + title: "Display name" llm_capability_verdict: capability: "Capability" model_id: "Model" @@ -27,6 +35,10 @@ en: external: "Model name" errors: models: + embedding_set: + attributes: + model_id: + not_offered: "is not an embedding model offered by the configured LLM server." llm_connection: attributes: api_format: diff --git a/modules/llm/db/migrate/20261014120000_create_embedding_sets.rb b/modules/llm/db/migrate/20261014120000_create_embedding_sets.rb new file mode 100644 index 000000000000..1b718d894978 --- /dev/null +++ b/modules/llm/db/migrate/20261014120000_create_embedding_sets.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class CreateEmbeddingSets < ActiveRecord::Migration[8.1] + def change + create_table :embedding_sets do |t| + t.string :title, null: false + t.text :description + t.string :model_id, null: false, limit: 512 + t.integer :embedding_vector_dimensions, null: false + t.integer :context_token_length, null: false + t.string :state, null: false, default: "created" + + t.timestamps null: false + end + end +end diff --git a/modules/llm/spec/factories/embedding_set_factory.rb b/modules/llm/spec/factories/embedding_set_factory.rb new file mode 100644 index 000000000000..44f2045fa8c8 --- /dev/null +++ b/modules/llm/spec/factories/embedding_set_factory.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +FactoryBot.define do + factory :embedding_set do + sequence(:title) { |n| "Embedding set #{n}" } + description { nil } + model_id { "bge-m3" } + embedding_vector_dimensions { 1024 } + context_token_length { 8192 } + end +end diff --git a/modules/llm/spec/models/embedding_set_spec.rb b/modules/llm/spec/models/embedding_set_spec.rb new file mode 100644 index 000000000000..4919c8e9cab1 --- /dev/null +++ b/modules/llm/spec/models/embedding_set_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe EmbeddingSet do + include_context "with an embedding model offered" + + subject(:embedding_set) { build(:embedding_set) } + + it "starts out as created" do + expect(embedding_set.save).to be(true) + expect(embedding_set.reload).to be_created + end + + it "requires a title" do + embedding_set.title = "" + + expect(embedding_set).not_to be_valid + expect(embedding_set.errors.details[:title]).to include(error: :blank) + end + + %i[embedding_vector_dimensions context_token_length].each do |attribute| + it "requires a positive integer #{attribute}" do + embedding_set.public_send(:"#{attribute}=", 0) + + expect(embedding_set).not_to be_valid + expect(embedding_set.errors.details[attribute]).to include(a_hash_including(error: :greater_than)) + end + end + + it "rejects a chat model" do + embedding_set.model_id = "qwen3.6-27b" + + expect(embedding_set).not_to be_valid + expect(embedding_set.errors.details[:model_id]).to include(error: :not_offered) + end + + it "keeps a stored model that is no longer offered" do + embedding_set.save! + connection.capability_verdicts.destroy_all + + embedding_set.title = "Renamed" + + expect(embedding_set).to be_valid + end +end diff --git a/modules/llm/spec/support/shared/embedding_model_context.rb b/modules/llm/spec/support/shared/embedding_model_context.rb new file mode 100644 index 000000000000..eef92ebfc9c6 --- /dev/null +++ b/modules/llm/spec/support/shared/embedding_model_context.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +RSpec.shared_context "with an embedding model offered" do + let!(:connection) { create(:llm_connection, :with_models) } + + before do + connection.capability_verdicts.create!(model_id: "bge-m3", capability: "embeddings", + state: "supported", source: "probe", checked_at: Time.current) + end +end From f49e43fa1fb2c1870fbd5f43ca8021466a121800 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 15 Oct 2026 00:34:53 +0200 Subject: [PATCH 3/4] Add contracts and services for embedding sets Creating, updating and deleting embedding sets goes through the usual BaseServices with contracts, following the labels administration. All three operations are restricted to active administrators. https://community.openproject.org/wp/AI-178 --- .../contracts/embedding_sets/base_contract.rb | 41 ++++++++++++++++ .../embedding_sets/create_contract.rb | 34 ++++++++++++++ .../embedding_sets/delete_contract.rb | 35 ++++++++++++++ .../embedding_sets/update_contract.rb | 34 ++++++++++++++ .../services/embedding_sets/create_service.rb | 34 ++++++++++++++ .../services/embedding_sets/delete_service.rb | 34 ++++++++++++++ .../embedding_sets/set_attributes_service.rb | 34 ++++++++++++++ .../services/embedding_sets/update_service.rb | 34 ++++++++++++++ .../embedding_sets/create_contract_spec.rb | 47 +++++++++++++++++++ .../embedding_sets/delete_contract_spec.rb | 43 +++++++++++++++++ .../embedding_sets/update_contract_spec.rb | 47 +++++++++++++++++++ 11 files changed, 417 insertions(+) create mode 100644 modules/llm/app/contracts/embedding_sets/base_contract.rb create mode 100644 modules/llm/app/contracts/embedding_sets/create_contract.rb create mode 100644 modules/llm/app/contracts/embedding_sets/delete_contract.rb create mode 100644 modules/llm/app/contracts/embedding_sets/update_contract.rb create mode 100644 modules/llm/app/services/embedding_sets/create_service.rb create mode 100644 modules/llm/app/services/embedding_sets/delete_service.rb create mode 100644 modules/llm/app/services/embedding_sets/set_attributes_service.rb create mode 100644 modules/llm/app/services/embedding_sets/update_service.rb create mode 100644 modules/llm/spec/contracts/embedding_sets/create_contract_spec.rb create mode 100644 modules/llm/spec/contracts/embedding_sets/delete_contract_spec.rb create mode 100644 modules/llm/spec/contracts/embedding_sets/update_contract_spec.rb diff --git a/modules/llm/app/contracts/embedding_sets/base_contract.rb b/modules/llm/app/contracts/embedding_sets/base_contract.rb new file mode 100644 index 000000000000..091f46d65ff7 --- /dev/null +++ b/modules/llm/app/contracts/embedding_sets/base_contract.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class BaseContract < ::ModelContract + include RequiresAdminGuard + + attribute :title + attribute :description + attribute :model_id + attribute :embedding_vector_dimensions + attribute :context_token_length + end +end diff --git a/modules/llm/app/contracts/embedding_sets/create_contract.rb b/modules/llm/app/contracts/embedding_sets/create_contract.rb new file mode 100644 index 000000000000..f897ce8328d0 --- /dev/null +++ b/modules/llm/app/contracts/embedding_sets/create_contract.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class CreateContract < BaseContract + end +end diff --git a/modules/llm/app/contracts/embedding_sets/delete_contract.rb b/modules/llm/app/contracts/embedding_sets/delete_contract.rb new file mode 100644 index 000000000000..d38d6e40e2fc --- /dev/null +++ b/modules/llm/app/contracts/embedding_sets/delete_contract.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class DeleteContract < ::DeleteContract + delete_permission :admin + end +end diff --git a/modules/llm/app/contracts/embedding_sets/update_contract.rb b/modules/llm/app/contracts/embedding_sets/update_contract.rb new file mode 100644 index 000000000000..c5fb1ef35791 --- /dev/null +++ b/modules/llm/app/contracts/embedding_sets/update_contract.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class UpdateContract < BaseContract + end +end diff --git a/modules/llm/app/services/embedding_sets/create_service.rb b/modules/llm/app/services/embedding_sets/create_service.rb new file mode 100644 index 000000000000..ebbdadb1defa --- /dev/null +++ b/modules/llm/app/services/embedding_sets/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class CreateService < ::BaseServices::Create + end +end diff --git a/modules/llm/app/services/embedding_sets/delete_service.rb b/modules/llm/app/services/embedding_sets/delete_service.rb new file mode 100644 index 000000000000..0936ff85431d --- /dev/null +++ b/modules/llm/app/services/embedding_sets/delete_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class DeleteService < ::BaseServices::Delete + end +end diff --git a/modules/llm/app/services/embedding_sets/set_attributes_service.rb b/modules/llm/app/services/embedding_sets/set_attributes_service.rb new file mode 100644 index 000000000000..6967395bfaa7 --- /dev/null +++ b/modules/llm/app/services/embedding_sets/set_attributes_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/modules/llm/app/services/embedding_sets/update_service.rb b/modules/llm/app/services/embedding_sets/update_service.rb new file mode 100644 index 000000000000..d68d80a93354 --- /dev/null +++ b/modules/llm/app/services/embedding_sets/update_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class UpdateService < ::BaseServices::Update + end +end diff --git a/modules/llm/spec/contracts/embedding_sets/create_contract_spec.rb b/modules/llm/spec/contracts/embedding_sets/create_contract_spec.rb new file mode 100644 index 000000000000..021b2a6bac5d --- /dev/null +++ b/modules/llm/spec/contracts/embedding_sets/create_contract_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper +require "contracts/shared/model_contract_shared_context" + +RSpec.describe EmbeddingSets::CreateContract do + include_context "ModelContract shared context" + include_context "with an embedding model offered" + + let(:embedding_set) { build(:embedding_set) } + let(:contract) { described_class.new(embedding_set, current_user) } + + it_behaves_like "contract is valid for active admins and invalid for regular users" + + include_examples "contract reuses the model errors" do + let(:current_user) { build_stubbed(:admin) } + end +end diff --git a/modules/llm/spec/contracts/embedding_sets/delete_contract_spec.rb b/modules/llm/spec/contracts/embedding_sets/delete_contract_spec.rb new file mode 100644 index 000000000000..cb142b90fae6 --- /dev/null +++ b/modules/llm/spec/contracts/embedding_sets/delete_contract_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper +require "contracts/shared/model_contract_shared_context" + +RSpec.describe EmbeddingSets::DeleteContract do + include_context "ModelContract shared context" + include_context "with an embedding model offered" + + let(:embedding_set) { create(:embedding_set) } + let(:contract) { described_class.new(embedding_set, current_user) } + + it_behaves_like "contract is valid for active admins and invalid for regular users" +end diff --git a/modules/llm/spec/contracts/embedding_sets/update_contract_spec.rb b/modules/llm/spec/contracts/embedding_sets/update_contract_spec.rb new file mode 100644 index 000000000000..939815e5ec38 --- /dev/null +++ b/modules/llm/spec/contracts/embedding_sets/update_contract_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper +require "contracts/shared/model_contract_shared_context" + +RSpec.describe EmbeddingSets::UpdateContract do + include_context "ModelContract shared context" + include_context "with an embedding model offered" + + let(:embedding_set) { create(:embedding_set) } + let(:contract) { described_class.new(embedding_set, current_user) } + + it_behaves_like "contract is valid for active admins and invalid for regular users" + + include_examples "contract reuses the model errors" do + let(:current_user) { build_stubbed(:admin) } + end +end From ad2c3703f5f12ffeeee9199c756f9ea62cc9ba9e Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 15 Oct 2026 00:34:53 +0200 Subject: [PATCH 4/4] Manage embedding sets on the feature configuration tab Below the semantic search feature, a new box lists the embedding sets with their model, context length, dimensions and status. Administrators create and edit sets in a dialog and delete them through a danger dialog, opened from a row action menu. The model picker only offers models known to create embeddings. Every set shows "Not ingested" for now; ingestion, activation and locking the parameters of the active set follow separately. The box and the endpoints require both the llm_connection and the semantic_search feature flags. https://community.openproject.org/wp/AI-178 --- .../embedding_sets/box_component.html.erb | 57 +++++++ .../embedding_sets/box_component.rb | 39 +++++ .../delete_dialog_component.html.erb | 44 +++++ .../embedding_sets/delete_dialog_component.rb | 45 ++++++ .../embedding_sets/dialog_component.html.erb | 66 ++++++++ .../embedding_sets/dialog_component.rb | 58 +++++++ .../embedding_sets/form_component.html.erb | 43 +++++ .../embedding_sets/form_component.rb | 58 +++++++ .../embedding_sets/row_component.rb | 98 +++++++++++ .../embedding_sets/table_component.rb | 65 ++++++++ .../admin/embedding_sets_controller.rb | 127 +++++++++++++++ modules/llm/app/forms/embedding_sets/form.rb | 106 ++++++++++++ .../admin/llm_feature_bindings/index.html.erb | 4 + modules/llm/config/locales/en.yml | 26 +++ modules/llm/config/routes.rb | 11 ++ .../features/admin/llm_connection_spec.rb | 58 +++++++ .../requests/admin/embedding_sets_spec.rb | 153 ++++++++++++++++++ 17 files changed, 1058 insertions(+) create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/box_component.html.erb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/box_component.rb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.html.erb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.rb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/dialog_component.html.erb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/dialog_component.rb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/form_component.html.erb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/form_component.rb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/row_component.rb create mode 100644 modules/llm/app/components/llm_connections/embedding_sets/table_component.rb create mode 100644 modules/llm/app/controllers/admin/embedding_sets_controller.rb create mode 100644 modules/llm/app/forms/embedding_sets/form.rb create mode 100644 modules/llm/spec/requests/admin/embedding_sets_spec.rb diff --git a/modules/llm/app/components/llm_connections/embedding_sets/box_component.html.erb b/modules/llm/app/components/llm_connections/embedding_sets/box_component.html.erb new file mode 100644 index 000000000000..96e9568f027c --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/box_component.html.erb @@ -0,0 +1,57 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render(Primer::Box.new(border: true, border_radius: 2, p: 3, mb: 3, test_selector: "llm-embedding-set--box")) do %> + <%= + flex_layout(justify_content: :space_between, align_items: :flex_start, mb: 3) do |header| + header.with_column do + concat(render(Primer::Beta::Text.new(tag: :h3, font_size: 4, font_weight: :bold, mb: 1)) { t("admin.embedding_sets.box.title") }) + concat(render(Primer::Beta::Text.new(tag: :p, color: :muted, mb: 0)) { t("admin.embedding_sets.box.caption") }) + end + + header.with_column(ml: 3) do + render( + Primer::Beta::Button.new( + scheme: :primary, + size: :small, + tag: :a, + href: new_dialog_embedding_sets_path, + data: { controller: "async-dialog" }, + test_selector: "llm-embedding-set--new-button" + ) + ) do |button| + button.with_leading_visual_icon(icon: :plus) + t("admin.embedding_sets.box.new") + end + end + end + %> + + <%= render(LlmConnections::EmbeddingSets::TableComponent.new(rows: embedding_sets)) %> +<% end %> diff --git a/modules/llm/app/components/llm_connections/embedding_sets/box_component.rb b/modules/llm/app/components/llm_connections/embedding_sets/box_component.rb new file mode 100644 index 000000000000..04ab92ec4149 --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/box_component.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + module EmbeddingSets + class BoxComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + options :embedding_sets + end + end +end diff --git a/modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.html.erb b/modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.html.erb new file mode 100644 index 000000000000..7e691ff17848 --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.html.erb @@ -0,0 +1,44 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + render( + Primer::OpenProject::DangerDialog.new( + title: t("admin.embedding_sets.destroy.title"), + confirm_button_text: t(:button_delete), + form_arguments:, + test_selector: TEST_SELECTOR + ) + ) do |dialog| + dialog.with_confirmation_message do |message| + message.with_heading(tag: :h2) { t("admin.embedding_sets.destroy.heading", title: embedding_set.title) } + message.with_description_content(t("admin.embedding_sets.destroy.description")) + end + end +%> diff --git a/modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.rb b/modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.rb new file mode 100644 index 000000000000..f6a5f58faf8e --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/delete_dialog_component.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + module EmbeddingSets + class DeleteDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + TEST_SELECTOR = "llm-embedding-set--delete-dialog" + + alias_method :embedding_set, :model + + def form_arguments + { action: embedding_set_path(embedding_set), method: :delete } + end + end + end +end diff --git a/modules/llm/app/components/llm_connections/embedding_sets/dialog_component.html.erb b/modules/llm/app/components/llm_connections/embedding_sets/dialog_component.html.erb new file mode 100644 index 000000000000..e9bd52ca71ea --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/dialog_component.html.erb @@ -0,0 +1,66 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + render( + Primer::Alpha::Dialog.new( + title:, + size: :medium_portrait, + id: DIALOG_ID, + test_selector: DIALOG_ID + ) + ) do |d| + d.with_header(variant: :large) + + d.with_body do + render(LlmConnections::EmbeddingSets::FormComponent.new(embedding_set, connection:)) + end + + d.with_footer do + component_collection do |buttons| + buttons.with_component( + Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID }) + ) do + t(:button_cancel) + end + + buttons.with_component( + Primer::Beta::Button.new( + scheme: :primary, + form: LlmConnections::EmbeddingSets::FormComponent::FORM_ID, + type: :submit, + test_selector: "llm-embedding-set--submit" + ) + ) do + button_caption + end + end + end + end +%> diff --git a/modules/llm/app/components/llm_connections/embedding_sets/dialog_component.rb b/modules/llm/app/components/llm_connections/embedding_sets/dialog_component.rb new file mode 100644 index 000000000000..3eb402595270 --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/dialog_component.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + module EmbeddingSets + class DialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "llm-embedding-set-dialog" + + alias_method :embedding_set, :model + + options :connection + + private + + def title + if embedding_set.persisted? + t("admin.embedding_sets.dialog.edit_title", title: embedding_set.title_was) + else + t("admin.embedding_sets.dialog.new_title") + end + end + + def button_caption + embedding_set.persisted? ? t(:button_save) : t(:button_create) + end + end + end +end diff --git a/modules/llm/app/components/llm_connections/embedding_sets/form_component.html.erb b/modules/llm/app/components/llm_connections/embedding_sets/form_component.html.erb new file mode 100644 index 000000000000..2fca976ac9a4 --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/form_component.html.erb @@ -0,0 +1,43 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + component_wrapper do + primer_form_with( + id: FORM_ID, + model:, + scope: :embedding_set, + url: form_url, + method: http_method, + data: { turbo: true } + ) do |f| + render(::EmbeddingSets::Form.new(f, model_options:)) + end + end +%> diff --git a/modules/llm/app/components/llm_connections/embedding_sets/form_component.rb b/modules/llm/app/components/llm_connections/embedding_sets/form_component.rb new file mode 100644 index 000000000000..c2fab9b26f14 --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/form_component.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + module EmbeddingSets + class FormComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + FORM_ID = "llm-embedding-set-form" + + options :connection + + private + + def http_method + model.persisted? ? :patch : :post + end + + def form_url + model.persisted? ? embedding_set_path(model) : embedding_sets_path + end + + def model_options + LlmConnections::SelectableModelsQuery + .new(connection, OpenProject::Llm::Features[:semantic_search], bound_model_id: model.model_id_in_database) + .call + end + end + end +end diff --git a/modules/llm/app/components/llm_connections/embedding_sets/row_component.rb b/modules/llm/app/components/llm_connections/embedding_sets/row_component.rb new file mode 100644 index 000000000000..14aaa337cd89 --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/row_component.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + module EmbeddingSets + class RowComponent < OpPrimer::BorderBoxRowComponent + alias_method :embedding_set, :model + + def row_data + { test_selector: "llm-embedding-set--row-#{embedding_set.id}" } + end + + def title + safe_join( + [ + render(Primer::Beta::Text.new(font_weight: :bold)) { embedding_set.title }, + description + ].compact + ) + end + + delegate :model_id, :context_token_length, :embedding_vector_dimensions, to: :embedding_set + + def state + render(Primer::Beta::Text.new(color: :subtle)) do + I18n.t("admin.embedding_sets.states.#{embedding_set.state}") + end + end + + def button_links + [action_menu] + end + + private + + def description + return if embedding_set.description.blank? + + render(Primer::Beta::Text.new(tag: :div, color: :subtle, font_size: :small, classes: "ellipsis")) do + embedding_set.description + end + end + + def action_menu + render(Primer::Alpha::ActionMenu.new(anchor_align: :end, + test_selector: "llm-embedding-set--menu-#{embedding_set.id}")) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": I18n.t(:label_more), scheme: :invisible) + + menu.with_item( + label: I18n.t(:button_edit), + tag: :a, + href: edit_dialog_embedding_set_path(embedding_set), + content_arguments: { data: { controller: "async-dialog" } } + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + + menu.with_item( + label: I18n.t("admin.embedding_sets.menu.delete"), + scheme: :danger, + tag: :a, + href: deletion_dialog_embedding_set_path(embedding_set), + content_arguments: { data: { controller: "async-dialog" } } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end + end + end +end diff --git a/modules/llm/app/components/llm_connections/embedding_sets/table_component.rb b/modules/llm/app/components/llm_connections/embedding_sets/table_component.rb new file mode 100644 index 000000000000..5099eb0b1bfb --- /dev/null +++ b/modules/llm/app/components/llm_connections/embedding_sets/table_component.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module LlmConnections + module EmbeddingSets + class TableComponent < OpPrimer::BorderBoxTableComponent + columns :title, :model_id, :context_token_length, :embedding_vector_dimensions, :state + + main_column :title + + mobile_columns :title, :state + + mobile_labels :state + + def has_actions? = true + + def has_footer? = false + + def mobile_title = I18n.t("admin.embedding_sets.box.title") + + def headers + [ + [:title, { caption: EmbeddingSet.human_attribute_name(:title) }], + [:model_id, { caption: EmbeddingSet.human_attribute_name(:model_id) }], + [:context_token_length, { caption: EmbeddingSet.human_attribute_name(:context_token_length) }], + [:embedding_vector_dimensions, { caption: EmbeddingSet.human_attribute_name(:embedding_vector_dimensions) }], + [:state, { caption: EmbeddingSet.human_attribute_name(:state) }] + ] + end + + def blank_title = I18n.t("admin.embedding_sets.box.blank_title") + + def blank_description = I18n.t("admin.embedding_sets.box.blank_description") + + def blank_icon = :database + end + end +end diff --git a/modules/llm/app/controllers/admin/embedding_sets_controller.rb b/modules/llm/app/controllers/admin/embedding_sets_controller.rb new file mode 100644 index 000000000000..f604e954e84a --- /dev/null +++ b/modules/llm/app/controllers/admin/embedding_sets_controller.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Admin + class EmbeddingSetsController < ApplicationController + include OpTurbo::ComponentStream + + layout "admin" + menu_item :llm_connection + + before_action :require_feature + before_action :require_admin + before_action :set_connection + before_action :require_enabled_connection + before_action :find_embedding_set, only: %i[edit_dialog update deletion_dialog destroy] + + def new_dialog + respond_with_dialog LlmConnections::EmbeddingSets::DialogComponent.new(EmbeddingSet.new, connection: @connection) + end + + def edit_dialog + respond_with_dialog LlmConnections::EmbeddingSets::DialogComponent.new(@embedding_set, connection: @connection) + end + + def create + result = ::EmbeddingSets::CreateService + .new(user: current_user) + .call(embedding_set_params) + + respond_to_save(result, notice: t(:notice_successful_create)) + end + + def update + result = ::EmbeddingSets::UpdateService + .new(user: current_user, model: @embedding_set) + .call(embedding_set_params) + + respond_to_save(result, notice: t(:notice_successful_update)) + end + + def deletion_dialog + respond_with_dialog LlmConnections::EmbeddingSets::DeleteDialogComponent.new(@embedding_set) + end + + def destroy + result = ::EmbeddingSets::DeleteService + .new(user: current_user, model: @embedding_set) + .call + + if result.success? + flash[:notice] = t(:notice_successful_delete) + else + flash[:error] = result.errors.full_messages + end + + redirect_to llm_feature_bindings_path, status: :see_other + end + + private + + def respond_to_save(result, notice:) + result.on_success do + flash[:notice] = notice + redirect_to llm_feature_bindings_path, status: :see_other + end + + result.on_failure do + update_via_turbo_stream( + component: LlmConnections::EmbeddingSets::FormComponent.new(result.result, connection: @connection), + status: :unprocessable_entity + ) + respond_with_turbo_streams + end + end + + def require_feature + render_404 unless OpenProject::FeatureDecisions.llm_connection_active? && + OpenProject::FeatureDecisions.semantic_search_active? + end + + def set_connection + @connection = LlmConnection.active_connection + end + + def require_enabled_connection + return if Setting.llm_features_enabled? && @connection.configured? + + flash[:notice] = t("admin.llm_connections.disabled_notice") + redirect_to llm_connection_path, status: :see_other + end + + def find_embedding_set + @embedding_set = EmbeddingSet.find(params.expect(:id)) + end + + def embedding_set_params + params.expect(embedding_set: %i[title description model_id embedding_vector_dimensions context_token_length]) + end + end +end diff --git a/modules/llm/app/forms/embedding_sets/form.rb b/modules/llm/app/forms/embedding_sets/form.rb new file mode 100644 index 000000000000..1c7dd509f546 --- /dev/null +++ b/modules/llm/app/forms/embedding_sets/form.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module EmbeddingSets + class Form < ApplicationForm + # Primer::Forms::Base.new assigns the builder itself and calls this with the + # remaining keywords, so the builder must not appear in the signature. + def initialize(model_options:) + super() + @model_options = model_options + end + + form do |f| + f.text_field( + name: :title, + label: EmbeddingSet.human_attribute_name(:title), + required: true, + autofocus: true + ) + + f.text_area( + name: :description, + label: EmbeddingSet.human_attribute_name(:description), + rows: 3 + ) + + f.autocompleter( + name: :model_id, + label: EmbeddingSet.human_attribute_name(:model_id), + caption: I18n.t("admin.embedding_sets.form.model_caption"), + required: true, + autocomplete_options: { + decorated: true, + inputValue: selected_model_id, + append_to: "##{LlmConnections::EmbeddingSets::DialogComponent::DIALOG_ID}", + data: { test_selector: "llm-embedding-set--model" } + } + ) do |list| + model_options.each do |option| + list.option(label: option_label(option), + value: option.model_id, + selected: selected_model_id == option.model_id) + end + end + + f.text_field( + name: :context_token_length, + type: :number, + min: 1, + label: EmbeddingSet.human_attribute_name(:context_token_length), + caption: I18n.t("admin.embedding_sets.form.context_token_length_caption"), + required: true, + input_width: :small + ) + + f.text_field( + name: :embedding_vector_dimensions, + type: :number, + min: 1, + label: EmbeddingSet.human_attribute_name(:embedding_vector_dimensions), + caption: I18n.t("admin.embedding_sets.form.embedding_vector_dimensions_caption"), + required: true, + input_width: :small + ) + end + + private + + attr_reader :model_options + + def selected_model_id = model.model_id + + def option_label(option) + return option.model_id if option.qualifies + + I18n.t("admin.embedding_sets.form.option_unqualified", model: option.model_id) + end + end +end diff --git a/modules/llm/app/views/admin/llm_feature_bindings/index.html.erb b/modules/llm/app/views/admin/llm_feature_bindings/index.html.erb index 7e38cac13451..2354d2e7e640 100644 --- a/modules/llm/app/views/admin/llm_feature_bindings/index.html.erb +++ b/modules/llm/app/views/admin/llm_feature_bindings/index.html.erb @@ -57,3 +57,7 @@ See COPYRIGHT and LICENSE files for more details. ) ) %> <% end %> + +<% if OpenProject::FeatureDecisions.semantic_search_active? %> + <%= render(LlmConnections::EmbeddingSets::BoxComponent.new(embedding_sets: EmbeddingSet.order(:created_at))) %> +<% end %> diff --git a/modules/llm/config/locales/en.yml b/modules/llm/config/locales/en.yml index 27f861940468..7d7dd43508db 100644 --- a/modules/llm/config/locales/en.yml +++ b/modules/llm/config/locales/en.yml @@ -79,6 +79,32 @@ en: external_id: conflicting_capabilities: "has capability assertions stored under it that conflict with those of this model." admin: + embedding_sets: + box: + blank_description: "Create an embedding set to choose the model, context length and dimensions used to index work packages." + blank_title: "No embedding sets yet" + caption: "Each set describes one way of indexing work packages for semantic search." + new: "New embedding set" + title: "Embedding sets" + destroy: + description: "The set and its settings will be removed. This cannot be undone." + heading: "Delete %{title}?" + title: "Delete embedding set" + dialog: + edit_title: "Edit \"%{title}\"" + new_title: "New embedding set" + form: + context_token_length_caption: "Maximum number of tokens embedded per work package." + embedding_vector_dimensions_caption: "How many numbers each vector has." + model_caption: "Only models known to create embeddings are offered." + option_unqualified: "%{model} (no longer eligible)" + menu: + delete: "Delete…" + states: + created: "Not ingested" + error: "Failed" + ingesting: "Ingesting" + ready: "Ready" llm_connections: delete_api_key: configured_from_env: "This connection is configured through the environment and cannot be changed here." diff --git a/modules/llm/config/routes.rb b/modules/llm/config/routes.rb index d69c73306cfe..d98612fb7e06 100644 --- a/modules/llm/config/routes.rb +++ b/modules/llm/config/routes.rb @@ -59,6 +59,17 @@ # of a registered feature, and a feature may not have a row yet. resources :llm_feature_bindings, only: %i[index update], controller: "admin/llm_feature_bindings" + resources :embedding_sets, only: %i[create update destroy], controller: "admin/embedding_sets" do + collection do + get :new_dialog, defaults: { format: :turbo_stream } + end + + member do + get :edit_dialog, defaults: { format: :turbo_stream } + get :deletion_dialog, defaults: { format: :turbo_stream } + end + end + resources :work_package_embeddings, only: [], controller: "admin/work_package_embeddings" do collection do post :reindex diff --git a/modules/llm/spec/features/admin/llm_connection_spec.rb b/modules/llm/spec/features/admin/llm_connection_spec.rb index 58cc9f9956e3..e18371d89b35 100644 --- a/modules/llm/spec/features/admin/llm_connection_spec.rb +++ b/modules/llm/spec/features/admin/llm_connection_spec.rb @@ -261,5 +261,63 @@ def choose_action(item) expect(page).to have_no_test_selector("llm-feature-binding--dimensions-description_assistant") expect(page).to be_axe_clean.within("#content") end + + describe "embedding sets" do + include Components::Autocompleter::NgSelectAutocompleteHelpers + + before do + connection.capability_verdicts.create!(model_id: "bge-m3", capability: "embeddings", + state: "supported", source: "probe", checked_at: Time.current) + end + + def within_embedding_set_menu(embedding_set, &) + find_test_selector("llm-embedding-set--menu-#{embedding_set.id}").click_link_or_button + within("anchored-position", &) + end + + it "creates, edits and deletes a set" do + visit llm_feature_bindings_path + + find_test_selector("llm-embedding-set--new-button").click + + within_test_selector("llm-embedding-set-dialog") do + fill_in "Display name", with: " " + fill_in "Context length", with: "2048" + fill_in "Dimensions", with: "768" + select_autocomplete(find_test_selector("llm-embedding-set--model"), + query: "bge-m3", + results_selector: "#llm-embedding-set-dialog") + expect(page).to be_axe_clean + + click_on "Create" + + expect(page).to have_text("can't be blank") + fill_in "Display name", with: "Nomic small" + click_on "Create" + end + + expect_flash(message: "Successful creation.") + embedding_set = EmbeddingSet.find_by!(title: "Nomic small") + within_test_selector("llm-embedding-set--row-#{embedding_set.id}") do + expect(page).to have_text("bge-m3") + expect(page).to have_text("Not ingested") + end + + within_embedding_set_menu(embedding_set) { click_on "Edit" } + within_test_selector("llm-embedding-set-dialog") do + fill_in "Display name", with: "Nomic large" + click_on "Save" + end + + expect_flash(message: "Successful update.") + expect(page).to have_test_selector("llm-embedding-set--row-#{embedding_set.id}", text: "Nomic large") + + within_embedding_set_menu(embedding_set) { click_on "Delete…" } + within_test_selector("llm-embedding-set--delete-dialog") { click_on "Delete" } + + expect_flash(message: "Successful deletion.") + expect(page).to have_no_test_selector("llm-embedding-set--row-#{embedding_set.id}") + end + end end end diff --git a/modules/llm/spec/requests/admin/embedding_sets_spec.rb b/modules/llm/spec/requests/admin/embedding_sets_spec.rb new file mode 100644 index 000000000000..9c3c220a2425 --- /dev/null +++ b/modules/llm/spec/requests/admin/embedding_sets_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_module_spec_helper + +RSpec.describe "Admin embedding sets", :skip_csrf, + type: :rails_request, with_flag: { llm_connection: true, semantic_search: true }, + with_settings: { llm_features_enabled: true } do + include_context "with an embedding model offered" + + shared_let(:admin) { create(:admin) } + + let(:turbo_headers) { { "Accept" => "text/vnd.turbo-stream.html" } } + let(:valid_params) do + { embedding_set: { title: "Nomic small", description: "Trial", model_id: "bge-m3", + embedding_vector_dimensions: 768, context_token_length: 2048 } } + end + + before { login_as admin } + + describe "with the semantic search flag off", with_flag: { llm_connection: true, semantic_search: false } do + it "does not expose the endpoints" do + get new_dialog_embedding_sets_path + expect(response).to have_http_status(:not_found) + + post embedding_sets_path, params: valid_params + expect(response).to have_http_status(:not_found) + expect(EmbeddingSet.count).to eq(0) + end + end + + describe "as a non-admin" do + before { login_as create(:user) } + + it "refuses to create a set" do + post embedding_sets_path, params: valid_params + + expect(response).to have_http_status(:forbidden) + expect(EmbeddingSet.count).to eq(0) + end + end + + describe "GET /admin/llm_feature_bindings" do + it "lists the embedding sets below the features" do + create(:embedding_set, title: "Qwen3 baseline") + + get llm_feature_bindings_path + + expect(response).to have_http_status(:ok) + expect(page).to have_test_selector("llm-embedding-set--box", text: "Qwen3 baseline") + expect(page).to have_test_selector("llm-embedding-set--box", text: "Not ingested") + end + + it "hides the box without the semantic search flag", with_flag: { llm_connection: true, semantic_search: false } do + get llm_feature_bindings_path + + expect(page).to have_no_test_selector("llm-embedding-set--box") + end + end + + describe "GET new_dialog" do + it "offers only embedding models" do + get new_dialog_embedding_sets_path, headers: turbo_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include("New embedding set") + + items = Nokogiri::HTML(response.parsed_body).at_css("opce-autocompleter")["data-items"] + expect(JSON.parse(items).pluck("id")).to contain_exactly("bge-m3") + end + end + + describe "POST /admin/embedding_sets" do + it "creates the set and returns to the feature configuration" do + post embedding_sets_path, params: valid_params + + expect(response).to redirect_to(llm_feature_bindings_path) + expect(EmbeddingSet.sole).to have_attributes(title: "Nomic small", model_id: "bge-m3", + embedding_vector_dimensions: 768, + context_token_length: 2048, state: "created") + end + + it "re-renders the form with errors" do + post embedding_sets_path, params: { embedding_set: valid_params[:embedding_set].merge(model_id: "qwen3.6-27b") }, + headers: turbo_headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include("is not an embedding model offered by the configured LLM server.") + expect(EmbeddingSet.count).to eq(0) + end + end + + describe "existing set" do + let!(:embedding_set) { create(:embedding_set, title: "Qwen3 baseline") } + + it "renders the edit dialog" do + get edit_dialog_embedding_set_path(embedding_set), headers: turbo_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Edit "Qwen3 baseline"") + end + + it "updates the set" do + patch embedding_set_path(embedding_set), + params: { embedding_set: { title: "Qwen3 production", context_token_length: 4096 } } + + expect(response).to redirect_to(llm_feature_bindings_path) + expect(embedding_set.reload).to have_attributes(title: "Qwen3 production", context_token_length: 4096) + end + + it "renders the deletion dialog" do + get deletion_dialog_embedding_set_path(embedding_set), headers: turbo_headers + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Delete Qwen3 baseline?") + end + + it "deletes the set" do + delete embedding_set_path(embedding_set) + + expect(response).to redirect_to(llm_feature_bindings_path) + expect(EmbeddingSet.count).to eq(0) + end + end +end