Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 5856d2e

Browse files
committed
Backend support for artifact key value store
1 parent 33fd680 commit 5856d2e

File tree

10 files changed

+482
-0
lines changed

10 files changed

+482
-0
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module AiBot
5+
class ArtifactKeyValuesController < ::ApplicationController
6+
requires_plugin DiscourseAi::PLUGIN_NAME
7+
before_action :ensure_logged_in, only: %i[create]
8+
before_action :find_artifact
9+
10+
PER_PAGE_MAX = 100
11+
12+
def index
13+
page = index_params[:page].to_i
14+
page = 1 if page < 1
15+
per_page = index_params[:per_page].to_i
16+
per_page = PER_PAGE_MAX if per_page < 1 || per_page > PER_PAGE_MAX
17+
18+
query = build_index_query
19+
20+
total_count = query.count
21+
key_values =
22+
query
23+
.includes(:user)
24+
.order(:user_id, :key, :created_at)
25+
.offset((page - 1) * per_page)
26+
.limit(per_page + 1)
27+
28+
has_more = key_values.length > per_page
29+
key_values = key_values.first(per_page) if has_more
30+
31+
render json: {
32+
key_values:
33+
ActiveModel::ArraySerializer.new(
34+
key_values,
35+
each_serializer: AiArtifactKeyValueSerializer,
36+
keys_only: params[:keys_only] == "true",
37+
).as_json,
38+
has_more: has_more,
39+
total_count: total_count,
40+
}
41+
end
42+
43+
def create
44+
key_value = @artifact.key_values.build(key_value_params)
45+
key_value.user = current_user
46+
47+
if key_value.save
48+
render json: AiArtifactKeyValueSerializer.new(key_value).as_json
49+
else
50+
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
51+
end
52+
end
53+
54+
private
55+
56+
def key_value_params
57+
params.permit(:key, :value, :public)
58+
end
59+
60+
def index_params
61+
@index_params ||= params.permit(:page, :per_page, :key, :keys_only, :all_users)
62+
end
63+
64+
def build_index_query
65+
query = @artifact.key_values
66+
67+
query =
68+
if current_user&.admin?
69+
query
70+
elsif current_user
71+
query.where("user_id = ? OR public = true", current_user.id)
72+
else
73+
query.where(public: true)
74+
end
75+
76+
query = query.where("key = ?", index_params[:key]) if index_params[:key].present?
77+
78+
if !index_params[:all_users].to_s == "true" && current_user
79+
query = query.where(user_id: current_user.id)
80+
end
81+
82+
query
83+
end
84+
85+
def find_artifact
86+
@artifact = AiArtifact.find_by(id: params[:artifact_id])
87+
raise Discourse::NotFound if !@artifact
88+
raise Discourse::NotFound if !@artifact.public? && guardian.anonymous?
89+
raise Discourse::NotFound if !@artifact.public? && !guardian.can_see?(@artifact.post)
90+
end
91+
end
92+
end
93+
end

app/models/ai_artifact.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
class AiArtifact < ActiveRecord::Base
44
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
5+
has_many :key_values, class_name: "AiArtifactKeyValue", dependent: :destroy
56
belongs_to :user
67
belongs_to :post
78
validates :html, length: { maximum: 65_535 }
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
class AiArtifactKeyValue < ActiveRecord::Base
4+
belongs_to :ai_artifact
5+
belongs_to :user
6+
7+
validates :key, presence: true, length: { maximum: 50 }
8+
validates :value,
9+
presence: true,
10+
length: {
11+
maximum: ->(_) { SiteSetting.ai_artifact_kv_value_max_length },
12+
}
13+
attribute :public, :boolean, default: false
14+
validates :ai_artifact, presence: true
15+
validates :user, presence: true
16+
validates :key, uniqueness: { scope: %i[ai_artifact_id user_id] }
17+
18+
validate :validate_max_keys_per_user_per_artifact
19+
20+
private
21+
22+
def validate_max_keys_per_user_per_artifact
23+
return unless ai_artifact_id && user_id
24+
25+
max_keys = SiteSetting.ai_artifact_max_keys_per_user_per_artifact
26+
existing_count = self.class.where(ai_artifact_id: ai_artifact_id, user_id: user_id).count
27+
28+
# Don't count the current record if it's being updated
29+
existing_count -= 1 if persisted?
30+
31+
if existing_count >= max_keys
32+
errors.add(
33+
:base,
34+
I18n.t("discourse_ai.ai_artifact.errors.max_keys_exceeded", count: max_keys),
35+
)
36+
end
37+
end
38+
end
39+
40+
# == Schema Information
41+
#
42+
# Table name: ai_artifact_key_values
43+
#
44+
# id :bigint not null, primary key
45+
# ai_artifact_id :bigint not null
46+
# user_id :integer not null
47+
# key :string(50) not null
48+
# value :string(20000) not null
49+
# public :boolean default(FALSE), not null
50+
# created_at :datetime not null
51+
# updated_at :datetime not null
52+
#
53+
# Indexes
54+
#
55+
# index_ai_artifact_kv_unique (ai_artifact_id,user_id,key) UNIQUE
56+
#
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
class AiArtifactKeyValueSerializer < ApplicationSerializer
4+
attributes :id, :key, :value, :public, :created_at, :updated_at
5+
6+
has_one :user, serializer: BasicUserSerializer
7+
8+
def include_value?
9+
!options[:keys_only]
10+
end
11+
end

config/locales/server.en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ en:
219219

220220
discourse_ai:
221221
ai_artifact:
222+
errors:
223+
max_keys_exceeded:
224+
one: "You can only have %{max} key in the artifact."
225+
other: "You can only have %{max} keys in the artifact."
222226
link: "Show Artifact in new tab"
223227
view_source: "View Source"
224228
view_changes: "View Changes"

config/routes.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
get "/:id/:version" => "artifacts#show"
4747
end
4848

49+
scope module: :ai_bot, path: "/ai-bot/artifact-key-values/:artifact_id" do
50+
get "/" => "artifact_key_values#index"
51+
post "/" => "artifact_key_values#create"
52+
end
53+
4954
scope module: :summarization, path: "/summarization", defaults: { format: :json } do
5055
get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ }
5156
get "/channels/:channel_id" => "chat_summary#show"

config/settings.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,9 @@ discourse_ai:
507507
type: enum
508508
enum: "DiscourseAi::Configuration::PersonaEnumerator"
509509
area: "ai-features/inferred_concepts"
510+
ai_artifact_kv_value_max_length:
511+
default: 5000
512+
hidden: true
513+
ai_artifact_max_keys_per_user_per_artifact:
514+
default: 100
515+
hidden: true
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
class CreateAiArtifactsKeyValues < ActiveRecord::Migration[7.2]
3+
def change
4+
create_table :ai_artifact_key_values do |t|
5+
t.bigint :ai_artifact_id, null: false
6+
t.integer :user_id, null: false
7+
t.string :key, null: false, limit: 50
8+
t.string :value, null: false, limit: 20_000
9+
t.boolean :public, null: false, default: false
10+
t.timestamps
11+
end
12+
13+
add_index :ai_artifact_key_values,
14+
%i[ai_artifact_id user_id key],
15+
unique: true,
16+
name: "index_ai_artifact_kv_unique"
17+
end
18+
end

spec/fabricators/ai_artifact_fabricator.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
metadata { { public: false } }
1010
end
1111

12+
Fabricator(:ai_artifact_key_value) do
13+
ai_artifact
14+
user
15+
key { sequence(:key) { |i| "key_#{i}" } }
16+
value { "value" }
17+
public { false }
18+
end
19+
1220
Fabricator(:ai_artifact_version) do
1321
ai_artifact
1422
version_number { sequence(:version_number) { |i| i } }

0 commit comments

Comments
 (0)