Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e258704e | ||
|
|
32dc45ba4f | ||
|
|
bc8e57d7e8 | ||
|
|
24416c5b87 | ||
|
|
b5e8277083 | ||
|
|
9be1049de6 | ||
|
|
fc83bed7cd | ||
|
|
9b7f1e6ee9 | ||
|
|
22da440130 | ||
|
|
02bc9f645e | ||
|
|
b5a2ee31ab | ||
|
|
ed311de937 | ||
|
|
8c8fd969ef | ||
|
|
26217e51f9 | ||
|
|
442530a154 | ||
|
|
a907bc891a | ||
|
|
d97307e99b | ||
|
|
35d62a659b | ||
|
|
f99309d1e1 | ||
|
|
fdf0ff8a25 | ||
|
|
f7e0ea888d | ||
|
|
b54db133cd | ||
|
|
cb94fa5e1c | ||
|
|
98afd7f8c3 | ||
|
|
33fd6801e5 | ||
|
|
6827147362 | ||
|
|
8a3a247b11 | ||
|
|
6817866de9 | ||
|
|
7d96739aab | ||
|
|
a68ab76eb6 | ||
|
|
1fb358a9d0 | ||
|
|
b3d78a6a10 | ||
|
|
c885e5697f | ||
|
|
0338dbea23 | ||
|
|
cab39839fd | ||
|
|
2842295b10 | ||
|
|
3e74eea1e5 | ||
|
|
4f980d5514 | ||
|
|
59f4b66ede | ||
|
|
fa51e9d948 | ||
|
|
306fec2b24 | ||
|
|
49fe986974 | ||
|
|
4dffd0b2c5 | ||
|
|
4c0660d6fd | ||
|
|
27de71fc4f | ||
|
|
478f31de47 | ||
|
|
4ce8973e56 | ||
|
|
34c98de864 | ||
|
|
38f7e9c2c4 | ||
|
|
e6876aabd5 | ||
|
|
b5d393b4bc | ||
|
|
77ae426d95 | ||
|
|
9f43df0302 |
@@ -1,3 +1,4 @@
|
|||||||
|
< 3.5.0.beta6-dev: 3e74eea1e5e3143888d67a8d8a11206df214dc24
|
||||||
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
|
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
|
||||||
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
|
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
|
||||||
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
|
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
|
|||||||
|
|
||||||
const { site_settings } = await ajax("/admin/config/site_settings.json", {
|
const { site_settings } = await ajax("/admin/config/site_settings.json", {
|
||||||
data: {
|
data: {
|
||||||
filter_area: `ai-features/${currentFeature.ref}`,
|
filter_area: `ai-features/${currentFeature.module_name}`,
|
||||||
plugin: "discourse-ai",
|
plugin: "discourse-ai",
|
||||||
category: "discourse_ai",
|
category: "discourse_ai",
|
||||||
},
|
},
|
||||||
|
|||||||
-156
@@ -1,156 +0,0 @@
|
|||||||
import Component from "@glimmer/component";
|
|
||||||
import { service } from "@ember/service";
|
|
||||||
import RouteTemplate from "ember-route-template";
|
|
||||||
import { gt } from "truth-helpers";
|
|
||||||
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
|
|
||||||
import DButton from "discourse/components/d-button";
|
|
||||||
import DPageSubheader from "discourse/components/d-page-subheader";
|
|
||||||
import { i18n } from "discourse-i18n";
|
|
||||||
|
|
||||||
export default RouteTemplate(
|
|
||||||
class extends Component {
|
|
||||||
@service adminPluginNavManager;
|
|
||||||
|
|
||||||
get tableHeaders() {
|
|
||||||
const prefix = "discourse_ai.features.list.header";
|
|
||||||
return [
|
|
||||||
i18n(`${prefix}.name`),
|
|
||||||
i18n(`${prefix}.persona`),
|
|
||||||
i18n(`${prefix}.groups`),
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
get configuredFeatures() {
|
|
||||||
return this.args.model.filter(
|
|
||||||
(feature) => feature.enable_setting.value === true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get unconfiguredFeatures() {
|
|
||||||
return this.args.model.filter(
|
|
||||||
(feature) => feature.enable_setting.value === false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DBreadcrumbsItem
|
|
||||||
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-features"
|
|
||||||
@label={{i18n "discourse_ai.features.short_title"}}
|
|
||||||
/>
|
|
||||||
<section class="ai-feature-list admin-detail">
|
|
||||||
<DPageSubheader
|
|
||||||
@titleLabel={{i18n "discourse_ai.features.short_title"}}
|
|
||||||
@descriptionLabel={{i18n "discourse_ai.features.description"}}
|
|
||||||
@learnMoreUrl="todo"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{{#if (gt this.configuredFeatures.length 0)}}
|
|
||||||
<div class="ai-feature-list__configured-features">
|
|
||||||
<h3>{{i18n "discourse_ai.features.list.configured_features"}}</h3>
|
|
||||||
|
|
||||||
<table class="d-admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{{#each this.tableHeaders as |header|}}
|
|
||||||
<th>{{header}}</th>
|
|
||||||
{{/each}}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{{#each this.configuredFeatures as |feature|}}
|
|
||||||
<tr
|
|
||||||
class="ai-feature-list__row d-admin-row__content"
|
|
||||||
data-feature-name={{feature.name}}
|
|
||||||
>
|
|
||||||
<td class="d-admin-row__overview ai-feature-list__row-item">
|
|
||||||
<span class="ai-feature-list__row-item-name">
|
|
||||||
<strong>
|
|
||||||
{{feature.name}}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span class="ai-feature-list__row-item-description">
|
|
||||||
{{feature.description}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__persona"
|
|
||||||
>
|
|
||||||
<DButton
|
|
||||||
class="btn-flat btn-small ai-feature-list__row-item-persona"
|
|
||||||
@translatedLabel={{feature.persona.name}}
|
|
||||||
@route="adminPlugins.show.discourse-ai-personas.edit"
|
|
||||||
@routeModels={{feature.persona.id}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__groups"
|
|
||||||
>
|
|
||||||
{{#if (gt feature.persona.allowed_groups.length 0)}}
|
|
||||||
<ul class="ai-feature-list__row-item-groups">
|
|
||||||
{{#each feature.persona.allowed_groups as |group|}}
|
|
||||||
<li>{{group.name}}</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td class="d-admin-row_controls">
|
|
||||||
<DButton
|
|
||||||
class="btn-small edit"
|
|
||||||
@label="discourse_ai.features.list.edit"
|
|
||||||
@route="adminPlugins.show.discourse-ai-features.edit"
|
|
||||||
@routeModels={{feature.id}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if (gt this.unconfiguredFeatures.length 0)}}
|
|
||||||
<div class="ai-feature-list__unconfigured-features">
|
|
||||||
<h3>{{i18n "discourse_ai.features.list.unconfigured_features"}}</h3>
|
|
||||||
|
|
||||||
<table class="d-admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{i18n "discourse_ai.features.list.header.name"}}</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{{#each this.unconfiguredFeatures as |feature|}}
|
|
||||||
<tr class="ai-feature-list__row d-admin-row__content">
|
|
||||||
<td class="d-admin-row__overview ai-feature-list__row-item">
|
|
||||||
<span class="ai-feature-list__row-item-name">
|
|
||||||
<strong>
|
|
||||||
{{feature.name}}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<span class="ai-feature-list__row-item-description">
|
|
||||||
{{feature.description}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="d-admin-row_controls">
|
|
||||||
<DButton
|
|
||||||
class="btn-small"
|
|
||||||
@label="discourse_ai.features.list.set_up"
|
|
||||||
@route="adminPlugins.show.discourse-ai-features.edit"
|
|
||||||
@routeModels={{feature.id}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
}
|
|
||||||
);
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<AiFeatures @features={{this.model}} />
|
||||||
@@ -40,6 +40,7 @@ module DiscourseAi
|
|||||||
embedding_def = EmbeddingDefinition.new(ai_embeddings_params)
|
embedding_def = EmbeddingDefinition.new(ai_embeddings_params)
|
||||||
|
|
||||||
if embedding_def.save
|
if embedding_def.save
|
||||||
|
log_ai_embedding_creation(embedding_def)
|
||||||
render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created
|
render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created
|
||||||
else
|
else
|
||||||
render_json_error embedding_def
|
render_json_error embedding_def
|
||||||
@@ -55,7 +56,10 @@ module DiscourseAi
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
initial_attributes = embedding_def.attributes.dup
|
||||||
|
|
||||||
if embedding_def.update(ai_embeddings_params.except(:dimensions))
|
if embedding_def.update(ai_embeddings_params.except(:dimensions))
|
||||||
|
log_ai_embedding_update(embedding_def, initial_attributes)
|
||||||
render json: AiEmbeddingDefinitionSerializer.new(embedding_def)
|
render json: AiEmbeddingDefinitionSerializer.new(embedding_def)
|
||||||
else
|
else
|
||||||
render_json_error embedding_def
|
render_json_error embedding_def
|
||||||
@@ -75,7 +79,16 @@ module DiscourseAi
|
|||||||
return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409)
|
return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
embedding_details = {
|
||||||
|
embedding_id: embedding_def.id,
|
||||||
|
display_name: embedding_def.display_name,
|
||||||
|
provider: embedding_def.provider,
|
||||||
|
dimensions: embedding_def.dimensions,
|
||||||
|
subject: embedding_def.display_name,
|
||||||
|
}
|
||||||
|
|
||||||
if embedding_def.destroy
|
if embedding_def.destroy
|
||||||
|
log_ai_embedding_deletion(embedding_details)
|
||||||
head :no_content
|
head :no_content
|
||||||
else
|
else
|
||||||
render_json_error embedding_def
|
render_json_error embedding_def
|
||||||
@@ -128,6 +141,60 @@ module DiscourseAi
|
|||||||
|
|
||||||
permitted
|
permitted
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ai_embeddings_logger_fields
|
||||||
|
{
|
||||||
|
display_name: {
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
},
|
||||||
|
dimensions: {
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
},
|
||||||
|
tokenizer_class: {
|
||||||
|
},
|
||||||
|
max_sequence_length: {
|
||||||
|
},
|
||||||
|
embed_prompt: {
|
||||||
|
type: :large_text,
|
||||||
|
},
|
||||||
|
search_prompt: {
|
||||||
|
type: :large_text,
|
||||||
|
},
|
||||||
|
matryoshka_dimensions: {
|
||||||
|
},
|
||||||
|
api_key: {
|
||||||
|
type: :sensitive,
|
||||||
|
},
|
||||||
|
# JSON fields should be tracked as simple changes
|
||||||
|
json_fields: [:provider_params],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_embedding_creation(embedding_def)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
|
||||||
|
logger.log_creation("embedding", embedding_def, ai_embeddings_logger_fields, entity_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_embedding_update(embedding_def, initial_attributes)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
|
||||||
|
|
||||||
|
logger.log_update(
|
||||||
|
"embedding",
|
||||||
|
embedding_def,
|
||||||
|
initial_attributes,
|
||||||
|
ai_embeddings_logger_fields,
|
||||||
|
entity_details,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_embedding_deletion(embedding_details)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
logger.log_deletion("embedding", embedding_details)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,24 +6,40 @@ module DiscourseAi
|
|||||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: serialize_features(DiscourseAi::Features.features)
|
render json: serialize_modules(DiscourseAi::Configuration::Module.all)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
|
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
|
||||||
render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i))
|
|
||||||
|
a_module = DiscourseAi::Configuration::Module.find_by(id: params[:id].to_i)
|
||||||
|
|
||||||
|
render json: serialize_module(a_module)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def serialize_features(features)
|
def serialize_modules(modules)
|
||||||
features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) }
|
modules.map { |a_module| serialize_module(a_module) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_module(a_module)
|
||||||
|
return nil if a_module.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
id: a_module.id,
|
||||||
|
module_name: a_module.name,
|
||||||
|
module_enabled: a_module.enabled?,
|
||||||
|
features: a_module.features.map { |f| serialize_feature(f) },
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialize_feature(feature)
|
def serialize_feature(feature)
|
||||||
return nil if feature.blank?
|
{
|
||||||
|
name: feature.name,
|
||||||
feature.merge(persona: serialize_persona(feature[:persona]))
|
persona: serialize_persona(persona_id_obj_hash[feature.persona_id]),
|
||||||
|
enabled: feature.enabled?,
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialize_persona(persona)
|
def serialize_persona(persona)
|
||||||
@@ -31,6 +47,18 @@ module DiscourseAi
|
|||||||
|
|
||||||
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
|
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def persona_id_obj_hash
|
||||||
|
@persona_id_obj_hash ||=
|
||||||
|
begin
|
||||||
|
setting_names = DiscourseAi::Configuration::Feature.all_persona_setting_names
|
||||||
|
ids = setting_names.map { |sn| SiteSetting.public_send(sn) }
|
||||||
|
|
||||||
|
AiPersona.where(id: ids).index_by(&:id)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ module DiscourseAi
|
|||||||
|
|
||||||
if llm_model.save
|
if llm_model.save
|
||||||
llm_model.toggle_companion_user
|
llm_model.toggle_companion_user
|
||||||
|
log_llm_model_creation(llm_model)
|
||||||
render json: LlmModelSerializer.new(llm_model), status: :created
|
render json: LlmModelSerializer.new(llm_model), status: :created
|
||||||
else
|
else
|
||||||
render_json_error llm_model
|
render_json_error llm_model
|
||||||
@@ -56,6 +57,10 @@ module DiscourseAi
|
|||||||
def update
|
def update
|
||||||
llm_model = LlmModel.find(params[:id])
|
llm_model = LlmModel.find(params[:id])
|
||||||
|
|
||||||
|
# Capture initial state for logging
|
||||||
|
initial_attributes = llm_model.attributes.dup
|
||||||
|
initial_quotas = llm_model.llm_quotas.map(&:attributes)
|
||||||
|
|
||||||
if params[:ai_llm].key?(:llm_quotas)
|
if params[:ai_llm].key?(:llm_quotas)
|
||||||
if quota_params
|
if quota_params
|
||||||
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
|
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
|
||||||
@@ -81,6 +86,7 @@ module DiscourseAi
|
|||||||
|
|
||||||
if llm_model.update(ai_llm_params(updating: llm_model))
|
if llm_model.update(ai_llm_params(updating: llm_model))
|
||||||
llm_model.toggle_companion_user
|
llm_model.toggle_companion_user
|
||||||
|
log_llm_model_update(llm_model, initial_attributes, initial_quotas)
|
||||||
render json: LlmModelSerializer.new(llm_model)
|
render json: LlmModelSerializer.new(llm_model)
|
||||||
else
|
else
|
||||||
render_json_error llm_model
|
render_json_error llm_model
|
||||||
@@ -109,11 +115,20 @@ module DiscourseAi
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Capture model details for logging before destruction
|
||||||
|
model_details = {
|
||||||
|
model_id: llm_model.id,
|
||||||
|
display_name: llm_model.display_name,
|
||||||
|
name: llm_model.name,
|
||||||
|
provider: llm_model.provider,
|
||||||
|
}
|
||||||
|
|
||||||
# Clean up companion users
|
# Clean up companion users
|
||||||
llm_model.enabled_chat_bot = false
|
llm_model.enabled_chat_bot = false
|
||||||
llm_model.toggle_companion_user
|
llm_model.toggle_companion_user
|
||||||
|
|
||||||
if llm_model.destroy
|
if llm_model.destroy
|
||||||
|
log_llm_model_deletion(model_details)
|
||||||
head :no_content
|
head :no_content
|
||||||
else
|
else
|
||||||
render_json_error llm_model
|
render_json_error llm_model
|
||||||
@@ -190,6 +205,89 @@ module DiscourseAi
|
|||||||
|
|
||||||
permitted
|
permitted
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ai_llm_logger_fields
|
||||||
|
{
|
||||||
|
display_name: {
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
},
|
||||||
|
tokenizer: {
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
},
|
||||||
|
max_prompt_tokens: {
|
||||||
|
},
|
||||||
|
max_output_tokens: {
|
||||||
|
},
|
||||||
|
enabled_chat_bot: {
|
||||||
|
},
|
||||||
|
vision_enabled: {
|
||||||
|
},
|
||||||
|
api_key: {
|
||||||
|
type: :sensitive,
|
||||||
|
},
|
||||||
|
input_cost: {
|
||||||
|
},
|
||||||
|
output_cost: {
|
||||||
|
},
|
||||||
|
# JSON fields should be tracked as simple changes
|
||||||
|
json_fields: [:provider_params],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_llm_model_creation(llm_model)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
|
||||||
|
|
||||||
|
# Add quota information as a special case
|
||||||
|
if llm_model.llm_quotas.any?
|
||||||
|
entity_details[:quotas] = llm_model
|
||||||
|
.llm_quotas
|
||||||
|
.map do |quota|
|
||||||
|
"Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s"
|
||||||
|
end
|
||||||
|
.join("; ")
|
||||||
|
end
|
||||||
|
|
||||||
|
logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_llm_model_update(llm_model, initial_attributes, initial_quotas)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
|
||||||
|
|
||||||
|
# Track quota changes separately as they're a special case
|
||||||
|
current_quotas = llm_model.llm_quotas.reload.map(&:attributes)
|
||||||
|
if initial_quotas != current_quotas
|
||||||
|
initial_quota_summary =
|
||||||
|
initial_quotas
|
||||||
|
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
|
||||||
|
.join("; ")
|
||||||
|
current_quota_summary =
|
||||||
|
current_quotas
|
||||||
|
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
|
||||||
|
.join("; ")
|
||||||
|
entity_details[:quotas_changed] = true
|
||||||
|
entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}"
|
||||||
|
end
|
||||||
|
|
||||||
|
logger.log_update(
|
||||||
|
"llm_model",
|
||||||
|
llm_model,
|
||||||
|
initial_attributes,
|
||||||
|
ai_llm_logger_fields,
|
||||||
|
entity_details,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_llm_model_deletion(model_details)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
model_details[:subject] = model_details[:display_name]
|
||||||
|
logger.log_deletion("llm_model", model_details)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ module DiscourseAi
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
ai_personas =
|
ai_personas =
|
||||||
AiPersona.ordered.map do |persona|
|
AiPersona
|
||||||
# we use a special serializer here cause names and descriptions are
|
.ordered
|
||||||
# localized for system personas
|
.includes(:user, :uploads)
|
||||||
LocalizedAiPersonaSerializer.new(persona, root: false)
|
.map { |persona| LocalizedAiPersonaSerializer.new(persona, root: false) }
|
||||||
end
|
|
||||||
tools =
|
tools =
|
||||||
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
|
DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
|
||||||
AiToolSerializer.new(tool, root: false)
|
AiToolSerializer.new(tool, root: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
AiTool
|
AiTool
|
||||||
.where(enabled: true)
|
.where(enabled: true)
|
||||||
.each do |tool|
|
.each do |tool|
|
||||||
@@ -31,10 +32,12 @@ module DiscourseAi
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
llms =
|
llms =
|
||||||
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
|
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
|
||||||
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
|
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
ai_personas: ai_personas,
|
ai_personas: ai_personas,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -58,6 +61,7 @@ module DiscourseAi
|
|||||||
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
|
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
|
||||||
if ai_persona.save
|
if ai_persona.save
|
||||||
RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids)
|
RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids)
|
||||||
|
log_ai_persona_creation(ai_persona)
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
|
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
|
||||||
@@ -74,8 +78,11 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
initial_attributes = @ai_persona.attributes.dup
|
||||||
|
|
||||||
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
|
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
|
||||||
RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids)
|
RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids)
|
||||||
|
log_ai_persona_update(@ai_persona, initial_attributes)
|
||||||
|
|
||||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
|
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
|
||||||
else
|
else
|
||||||
@@ -84,7 +91,14 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
persona_details = {
|
||||||
|
persona_id: @ai_persona.id,
|
||||||
|
name: @ai_persona.name,
|
||||||
|
description: @ai_persona.description,
|
||||||
|
}
|
||||||
|
|
||||||
if @ai_persona.destroy
|
if @ai_persona.destroy
|
||||||
|
log_ai_persona_deletion(persona_details)
|
||||||
head :no_content
|
head :no_content
|
||||||
else
|
else
|
||||||
render_json_error @ai_persona
|
render_json_error @ai_persona
|
||||||
@@ -261,6 +275,92 @@ module DiscourseAi
|
|||||||
|
|
||||||
examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
|
examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ai_persona_logger_fields
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
},
|
||||||
|
system_prompt: {
|
||||||
|
type: :large_text,
|
||||||
|
},
|
||||||
|
default_llm_id: {
|
||||||
|
},
|
||||||
|
temperature: {
|
||||||
|
},
|
||||||
|
top_p: {
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
},
|
||||||
|
max_context_posts: {
|
||||||
|
},
|
||||||
|
vision_enabled: {
|
||||||
|
},
|
||||||
|
vision_max_pixels: {
|
||||||
|
},
|
||||||
|
rag_chunk_tokens: {
|
||||||
|
},
|
||||||
|
rag_chunk_overlap_tokens: {
|
||||||
|
},
|
||||||
|
rag_conversation_chunks: {
|
||||||
|
},
|
||||||
|
rag_llm_model_id: {
|
||||||
|
},
|
||||||
|
question_consolidator_llm_id: {
|
||||||
|
},
|
||||||
|
allow_chat_channel_mentions: {
|
||||||
|
},
|
||||||
|
allow_chat_direct_messages: {
|
||||||
|
},
|
||||||
|
allow_topic_mentions: {
|
||||||
|
},
|
||||||
|
allow_personal_messages: {
|
||||||
|
},
|
||||||
|
tool_details: {
|
||||||
|
type: :large_text,
|
||||||
|
},
|
||||||
|
forced_tool_count: {
|
||||||
|
},
|
||||||
|
force_default_llm: {
|
||||||
|
},
|
||||||
|
# JSON fields
|
||||||
|
json_fields: %i[tools response_format examples allowed_group_ids],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_persona_creation(ai_persona)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
|
||||||
|
entity_details[:tools_count] = (ai_persona.tools || []).size
|
||||||
|
|
||||||
|
logger.log_creation("persona", ai_persona, ai_persona_logger_fields, entity_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_persona_update(ai_persona, initial_attributes)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
|
||||||
|
entity_details[:tools_count] = ai_persona.tools.size if ai_persona.tools.present?
|
||||||
|
|
||||||
|
logger.log_update(
|
||||||
|
"persona",
|
||||||
|
ai_persona,
|
||||||
|
initial_attributes,
|
||||||
|
ai_persona_logger_fields,
|
||||||
|
entity_details,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_persona_deletion(persona_details)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
persona_details[:subject] = persona_details[:name]
|
||||||
|
|
||||||
|
logger.log_deletion("persona", persona_details)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
initial_settings = AiModerationSetting.spam
|
||||||
|
initial_custom_instructions = initial_settings&.data&.dig("custom_instructions")
|
||||||
|
initial_llm_model_id = initial_settings&.llm_model_id
|
||||||
|
|
||||||
updated_params = {}
|
updated_params = {}
|
||||||
if allowed_params.key?(:llm_model_id)
|
if allowed_params.key?(:llm_model_id)
|
||||||
llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id]
|
llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id]
|
||||||
@@ -36,6 +40,8 @@ module DiscourseAi
|
|||||||
else
|
else
|
||||||
AiModerationSetting.create!(updated_params.merge(setting_type: :spam))
|
AiModerationSetting.create!(updated_params.merge(setting_type: :spam))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, allowed_params)
|
||||||
end
|
end
|
||||||
|
|
||||||
is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled])
|
is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled])
|
||||||
@@ -113,6 +119,30 @@ module DiscourseAi
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, params)
|
||||||
|
changes_to_log = {}
|
||||||
|
|
||||||
|
if params.key?(:llm_model_id) && initial_llm_model_id.to_s != params[:llm_model_id].to_s
|
||||||
|
old_model_name =
|
||||||
|
LlmModel.find_by(id: initial_llm_model_id)&.display_name || initial_llm_model_id
|
||||||
|
new_model_name =
|
||||||
|
LlmModel.find_by(id: params[:llm_model_id])&.display_name || params[:llm_model_id]
|
||||||
|
|
||||||
|
changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
if params.key?(:custom_instructions) &&
|
||||||
|
initial_custom_instructions != params[:custom_instructions]
|
||||||
|
changes_to_log[:custom_instructions] = params[:custom_instructions]
|
||||||
|
end
|
||||||
|
|
||||||
|
if changes_to_log.present?
|
||||||
|
changes_to_log[:subject] = I18n.t("discourse_ai.spam_detection.logging_subject")
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
logger.log_custom("update_ai_spam_settings", changes_to_log)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def allowed_params
|
def allowed_params
|
||||||
params.permit(:is_enabled, :llm_model_id, :custom_instructions)
|
params.permit(:is_enabled, :llm_model_id, :custom_instructions)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ module DiscourseAi
|
|||||||
|
|
||||||
if ai_tool.save
|
if ai_tool.save
|
||||||
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
|
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
|
||||||
|
log_ai_tool_creation(ai_tool)
|
||||||
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
|
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
|
||||||
else
|
else
|
||||||
render_json_error ai_tool
|
render_json_error ai_tool
|
||||||
@@ -32,8 +33,11 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
initial_attributes = @ai_tool.attributes.dup
|
||||||
|
|
||||||
if @ai_tool.update(ai_tool_params)
|
if @ai_tool.update(ai_tool_params)
|
||||||
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
|
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
|
||||||
|
log_ai_tool_update(@ai_tool, initial_attributes)
|
||||||
render_serialized(@ai_tool, AiCustomToolSerializer)
|
render_serialized(@ai_tool, AiCustomToolSerializer)
|
||||||
else
|
else
|
||||||
render_json_error @ai_tool
|
render_json_error @ai_tool
|
||||||
@@ -41,7 +45,15 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
tool_logger_details = {
|
||||||
|
tool_id: @ai_tool.id,
|
||||||
|
name: @ai_tool.name,
|
||||||
|
tool_name: @ai_tool.tool_name,
|
||||||
|
subject: @ai_tool.name,
|
||||||
|
}
|
||||||
|
|
||||||
if @ai_tool.destroy
|
if @ai_tool.destroy
|
||||||
|
log_ai_tool_deletion(tool_logger_details)
|
||||||
head :no_content
|
head :no_content
|
||||||
else
|
else
|
||||||
render_json_error @ai_tool
|
render_json_error @ai_tool
|
||||||
@@ -96,6 +108,60 @@ module DiscourseAi
|
|||||||
)
|
)
|
||||||
.except(:rag_uploads)
|
.except(:rag_uploads)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ai_tool_logger_fields
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
},
|
||||||
|
tool_name: {
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
},
|
||||||
|
rag_chunk_tokens: {
|
||||||
|
},
|
||||||
|
rag_chunk_overlap_tokens: {
|
||||||
|
},
|
||||||
|
rag_llm_model_id: {
|
||||||
|
},
|
||||||
|
script: {
|
||||||
|
type: :large_text,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
type: :large_text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_tool_creation(ai_tool)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
|
||||||
|
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
|
||||||
|
entity_details[:parameter_count] = ai_tool.parameters.size if ai_tool.parameters.present?
|
||||||
|
|
||||||
|
logger.log_creation("tool", ai_tool, ai_tool_logger_fields, entity_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_tool_update(ai_tool, initial_attributes)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
|
||||||
|
|
||||||
|
logger.log_update(
|
||||||
|
"tool",
|
||||||
|
ai_tool,
|
||||||
|
initial_attributes,
|
||||||
|
ai_tool_logger_fields,
|
||||||
|
entity_details,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_ai_tool_deletion(tool_details)
|
||||||
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
||||||
|
logger.log_deletion("tool", tool_details)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module AiBot
|
||||||
|
class ArtifactKeyValuesController < ::ApplicationController
|
||||||
|
requires_plugin DiscourseAi::PLUGIN_NAME
|
||||||
|
before_action :ensure_logged_in, only: %i[set destroy]
|
||||||
|
before_action :find_artifact
|
||||||
|
|
||||||
|
PER_PAGE_MAX = 100
|
||||||
|
|
||||||
|
def index
|
||||||
|
page = index_params[:page].to_i
|
||||||
|
page = 1 if page < 1
|
||||||
|
per_page = index_params[:per_page].to_i
|
||||||
|
per_page = PER_PAGE_MAX if per_page < 1 || per_page > PER_PAGE_MAX
|
||||||
|
|
||||||
|
query = build_index_query
|
||||||
|
|
||||||
|
total_count = query.count
|
||||||
|
key_values =
|
||||||
|
query
|
||||||
|
.includes(:user)
|
||||||
|
.order(:user_id, :key, :created_at)
|
||||||
|
.offset((page - 1) * per_page)
|
||||||
|
.limit(per_page + 1)
|
||||||
|
|
||||||
|
has_more = key_values.length > per_page
|
||||||
|
key_values = key_values.first(per_page) if has_more
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
key_values:
|
||||||
|
ActiveModel::ArraySerializer.new(
|
||||||
|
key_values,
|
||||||
|
each_serializer: AiArtifactKeyValueSerializer,
|
||||||
|
keys_only: params[:keys_only] == "true",
|
||||||
|
).as_json,
|
||||||
|
has_more: has_more,
|
||||||
|
total_count: total_count,
|
||||||
|
users:
|
||||||
|
key_values
|
||||||
|
.map { |kv| kv.user }
|
||||||
|
.uniq
|
||||||
|
.map { |u| BasicUserSerializer.new(u, root: nil).as_json },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
if params[:key].blank?
|
||||||
|
render json: { error: "Key parameter is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
key_value = @artifact.key_values.find_by(user_id: current_user.id, key: params[:key])
|
||||||
|
|
||||||
|
if key_value.nil?
|
||||||
|
render json: { error: "Key not found" }, status: :not_found
|
||||||
|
elsif key_value.destroy
|
||||||
|
head :ok
|
||||||
|
else
|
||||||
|
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set
|
||||||
|
key_value =
|
||||||
|
@artifact.key_values.find_or_initialize_by(
|
||||||
|
user: current_user,
|
||||||
|
key: key_value_params[:key],
|
||||||
|
)
|
||||||
|
|
||||||
|
key_value.assign_attributes(key_value_params.except(:key))
|
||||||
|
|
||||||
|
if key_value.save
|
||||||
|
render json: AiArtifactKeyValueSerializer.new(key_value).as_json
|
||||||
|
else
|
||||||
|
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def key_value_params
|
||||||
|
params.permit(:key, :value, :public)
|
||||||
|
end
|
||||||
|
|
||||||
|
def index_params
|
||||||
|
@index_params ||= params.permit(:page, :per_page, :key, :keys_only, :all_users)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_index_query
|
||||||
|
query = @artifact.key_values
|
||||||
|
|
||||||
|
query =
|
||||||
|
if current_user&.admin?
|
||||||
|
query
|
||||||
|
elsif current_user
|
||||||
|
query.where("user_id = ? OR public = true", current_user.id)
|
||||||
|
else
|
||||||
|
query.where(public: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
query = query.where("key = ?", index_params[:key]) if index_params[:key].present?
|
||||||
|
|
||||||
|
if index_params[:all_users].to_s != "true"
|
||||||
|
if current_user
|
||||||
|
query = query.where(user_id: current_user.id)
|
||||||
|
else
|
||||||
|
query = query.where("1 = 0")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_artifact
|
||||||
|
@artifact = AiArtifact.find_by(id: params[:artifact_id])
|
||||||
|
raise Discourse::NotFound if !@artifact
|
||||||
|
raise Discourse::NotFound if [email protected]? && guardian.anonymous?
|
||||||
|
raise Discourse::NotFound if [email protected]? && !guardian.can_see?(@artifact.post)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -25,25 +25,24 @@ module DiscourseAi
|
|||||||
input = get_text_param!
|
input = get_text_param!
|
||||||
force_default_locale = params[:force_default_locale] || false
|
force_default_locale = params[:force_default_locale] || false
|
||||||
|
|
||||||
prompt = CompletionPrompt.find_by(id: params[:mode])
|
raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank?
|
||||||
|
|
||||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT
|
||||||
|
|
||||||
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
|
|
||||||
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
||||||
|
|
||||||
prompt.custom_instruction = params[:custom_prompt]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
|
if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST
|
||||||
|
return suggest_thumbnails(input)
|
||||||
|
end
|
||||||
|
|
||||||
hijack do
|
hijack do
|
||||||
render json:
|
render json:
|
||||||
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
||||||
prompt,
|
params[:mode],
|
||||||
input,
|
input,
|
||||||
current_user,
|
current_user,
|
||||||
force_default_locale: force_default_locale,
|
force_default_locale: force_default_locale,
|
||||||
|
custom_prompt: params[:custom_prompt],
|
||||||
),
|
),
|
||||||
status: 200
|
status: 200
|
||||||
end
|
end
|
||||||
@@ -60,13 +59,10 @@ module DiscourseAi
|
|||||||
input = get_text_param!
|
input = get_text_param!
|
||||||
end
|
end
|
||||||
|
|
||||||
prompt = CompletionPrompt.enabled_by_name("generate_titles")
|
|
||||||
raise Discourse::InvalidParameters.new(:mode) if !prompt
|
|
||||||
|
|
||||||
hijack do
|
hijack do
|
||||||
render json:
|
render json:
|
||||||
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
|
||||||
prompt,
|
DiscourseAi::AiHelper::Assistant::GENERATE_TITLES,
|
||||||
input,
|
input,
|
||||||
current_user,
|
current_user,
|
||||||
),
|
),
|
||||||
@@ -115,12 +111,12 @@ module DiscourseAi
|
|||||||
location = params[:location]
|
location = params[:location]
|
||||||
raise Discourse::InvalidParameters.new(:location) if !location
|
raise Discourse::InvalidParameters.new(:location) if !location
|
||||||
|
|
||||||
prompt = CompletionPrompt.find_by(id: params[:mode])
|
raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank?
|
||||||
|
if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST
|
||||||
|
return suggest_thumbnails(input)
|
||||||
|
end
|
||||||
|
|
||||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT
|
||||||
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
|
|
||||||
|
|
||||||
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
|
|
||||||
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -133,7 +129,7 @@ module DiscourseAi
|
|||||||
:stream_composer_helper,
|
:stream_composer_helper,
|
||||||
user_id: current_user.id,
|
user_id: current_user.id,
|
||||||
text: text,
|
text: text,
|
||||||
prompt: prompt.name,
|
prompt: params[:mode],
|
||||||
custom_prompt: params[:custom_prompt],
|
custom_prompt: params[:custom_prompt],
|
||||||
force_default_locale: params[:force_default_locale] || false,
|
force_default_locale: params[:force_default_locale] || false,
|
||||||
client_id: params[:client_id],
|
client_id: params[:client_id],
|
||||||
@@ -149,7 +145,7 @@ module DiscourseAi
|
|||||||
post_id: post.id,
|
post_id: post.id,
|
||||||
user_id: current_user.id,
|
user_id: current_user.id,
|
||||||
text: text,
|
text: text,
|
||||||
prompt: prompt.name,
|
prompt: params[:mode],
|
||||||
custom_prompt: params[:custom_prompt],
|
custom_prompt: params[:custom_prompt],
|
||||||
client_id: params[:client_id],
|
client_id: params[:client_id],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ module ::Jobs
|
|||||||
|
|
||||||
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new)
|
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new)
|
||||||
|
|
||||||
DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
|
DiscourseAi::AiBot::Playground.new(bot).reply_to(post, feature_name: "bot")
|
||||||
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND
|
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND
|
||||||
Rails.logger.warn(
|
Rails.logger.warn(
|
||||||
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
|
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
|
||||||
|
|||||||
@@ -12,20 +12,27 @@ module Jobs
|
|||||||
post = Post.find_by(id: args[:post_id])
|
post = Post.find_by(id: args[:post_id])
|
||||||
return if post.blank? || post.raw.blank? || post.deleted_at.present? || post.user_id <= 0
|
return if post.blank? || post.raw.blank? || post.deleted_at.present? || post.user_id <= 0
|
||||||
|
|
||||||
|
topic = post.topic
|
||||||
|
return if topic.blank?
|
||||||
|
|
||||||
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
topic = post.topic
|
return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message
|
||||||
if topic.blank? || topic.category&.read_restricted? ||
|
else
|
||||||
topic.archetype == Archetype.private_message
|
if topic.archetype == Archetype.private_message &&
|
||||||
|
!TopicAllowedGroup.exists?(topic_id: topic.id)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
# the user may fill locale in manually
|
||||||
detected_locale = DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
|
if (detected_locale = post.locale).blank?
|
||||||
rescue FinalDestination::SSRFDetector::LookupFailedError
|
begin
|
||||||
# this job is non-critical
|
detected_locale = DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
|
||||||
# the backfill job will handle failures
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
return
|
# this job is non-critical
|
||||||
|
# the backfill job will handle failures
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
||||||
@@ -33,6 +40,7 @@ module Jobs
|
|||||||
|
|
||||||
locales.each do |locale|
|
locales.each do |locale|
|
||||||
next if locale == detected_locale
|
next if locale == detected_locale
|
||||||
|
next if post.post_localizations.exists?(locale:)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
DiscourseAi::Translation::PostLocalizer.localize(post, locale)
|
DiscourseAi::Translation::PostLocalizer.localize(post, locale)
|
||||||
|
|||||||
@@ -15,15 +15,22 @@ module Jobs
|
|||||||
end
|
end
|
||||||
|
|
||||||
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
return if topic.category&.read_restricted?
|
return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message
|
||||||
|
else
|
||||||
|
if topic.archetype == Archetype.private_message &&
|
||||||
|
!TopicAllowedGroup.exists?(topic_id: topic.id)
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
if (detected_locale = topic.locale).blank?
|
||||||
detected_locale = DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic)
|
begin
|
||||||
rescue FinalDestination::SSRFDetector::LookupFailedError
|
detected_locale = DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic)
|
||||||
# this job is non-critical
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
# the backfill job will handle failures
|
# this job is non-critical
|
||||||
return
|
# the backfill job will handle failures
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
locales = SiteSetting.experimental_content_localization_supported_locales.split("|")
|
||||||
@@ -31,6 +38,7 @@ module Jobs
|
|||||||
|
|
||||||
locales.each do |locale|
|
locales.each do |locale|
|
||||||
next if locale == detected_locale
|
next if locale == detected_locale
|
||||||
|
next if topic.topic_localizations.exists?(locale:)
|
||||||
|
|
||||||
begin
|
begin
|
||||||
DiscourseAi::Translation::TopicLocalizer.localize(topic, locale)
|
DiscourseAi::Translation::TopicLocalizer.localize(topic, locale)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class GenerateInferredConcepts < ::Jobs::Base
|
||||||
|
sidekiq_options queue: "low"
|
||||||
|
|
||||||
|
# Process items to generate new concepts
|
||||||
|
#
|
||||||
|
# @param args [Hash] Contains job arguments
|
||||||
|
# @option args [String] :item_type Required - Type of items to process ('topics' or 'posts')
|
||||||
|
# @option args [Array<Integer>] :item_ids Required - List of item IDs to process
|
||||||
|
# @option args [Integer] :batch_size (100) Number of items to process in each batch
|
||||||
|
# @option args [Boolean] :match_only (false) Only match against existing concepts without generating new ones
|
||||||
|
def execute(args = {})
|
||||||
|
return if args[:item_ids].blank? || args[:item_type].blank?
|
||||||
|
|
||||||
|
if %w[topics posts].exclude?(args[:item_type])
|
||||||
|
Rails.logger.error("Invalid item_type for GenerateInferredConcepts: #{args[:item_type]}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process items in smaller batches to avoid memory issues
|
||||||
|
batch_size = args[:batch_size] || 100
|
||||||
|
|
||||||
|
# Get the list of item IDs
|
||||||
|
item_ids = args[:item_ids]
|
||||||
|
match_only = args[:match_only] || false
|
||||||
|
|
||||||
|
# Process items in batches
|
||||||
|
item_ids.each_slice(batch_size) do |batch_item_ids|
|
||||||
|
process_batch(batch_item_ids, args[:item_type], match_only)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_batch(item_ids, item_type, match_only)
|
||||||
|
klass = item_type.singularize.classify.constantize
|
||||||
|
items = klass.where(id: item_ids)
|
||||||
|
manager = DiscourseAi::InferredConcepts::Manager.new
|
||||||
|
|
||||||
|
items.each do |item|
|
||||||
|
begin
|
||||||
|
process_item(item, item_type, match_only, manager)
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error(
|
||||||
|
"Error generating concepts from #{item_type.singularize} #{item.id}: #{e.message}\n#{e.backtrace.join("\n")}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_item(item, item_type, match_only, manager)
|
||||||
|
# Use the Manager method that handles both identifying and creating concepts
|
||||||
|
if match_only
|
||||||
|
if item_type == "topics"
|
||||||
|
manager.match_topic_to_concepts(item)
|
||||||
|
else # posts
|
||||||
|
manager.match_post_to_concepts(item)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if item_type == "topics"
|
||||||
|
manager.generate_concepts_from_topic(item)
|
||||||
|
else # posts
|
||||||
|
manager.generate_concepts_from_post(item)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,7 +17,8 @@ module Jobs
|
|||||||
cat_id = args[:from_category_id] || Category.order(:id).first&.id
|
cat_id = args[:from_category_id] || Category.order(:id).first&.id
|
||||||
last_id = nil
|
last_id = nil
|
||||||
|
|
||||||
categories = Category.where("id >= ?", cat_id).order(:id).limit(BATCH_SIZE)
|
categories =
|
||||||
|
Category.where("id >= ? AND locale IS NOT NULL", cat_id).order(:id).limit(BATCH_SIZE)
|
||||||
return if categories.empty?
|
return if categories.empty?
|
||||||
|
|
||||||
categories.each do |category|
|
categories.each do |category|
|
||||||
@@ -26,9 +27,13 @@ module Jobs
|
|||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
CategoryLocalization.transaction do
|
locales.each do |locale|
|
||||||
locales.each do |locale|
|
localization = category.category_localizations.find_by(locale:)
|
||||||
next if CategoryLocalization.exists?(category_id: category.id, locale: locale)
|
|
||||||
|
if locale == category.locale && localization
|
||||||
|
localization.destroy
|
||||||
|
else
|
||||||
|
next if locale == category.locale
|
||||||
begin
|
begin
|
||||||
DiscourseAi::Translation::CategoryLocalizer.localize(category, locale)
|
DiscourseAi::Translation::CategoryLocalizer.localize(category, locale)
|
||||||
rescue FinalDestination::SSRFDetector::LookupFailedError
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
|||||||
@@ -29,12 +29,22 @@ module Jobs
|
|||||||
.where.not(locale: locale)
|
.where.not(locale: locale)
|
||||||
.where("pl.id IS NULL")
|
.where("pl.id IS NULL")
|
||||||
|
|
||||||
|
posts = posts.joins(:topic)
|
||||||
|
|
||||||
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
# exclude all PMs
|
||||||
|
# and only include posts from public categories
|
||||||
posts =
|
posts =
|
||||||
posts
|
posts
|
||||||
.joins(:topic)
|
|
||||||
.where(topics: { category_id: Category.where(read_restricted: false).select(:id) })
|
|
||||||
.where.not(topics: { archetype: Archetype.private_message })
|
.where.not(topics: { archetype: Archetype.private_message })
|
||||||
|
.where(topics: { category_id: Category.where(read_restricted: false).select(:id) })
|
||||||
|
else
|
||||||
|
# all regular topics, and group PMs
|
||||||
|
posts =
|
||||||
|
posts.where(
|
||||||
|
"topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
|
||||||
|
Archetype.private_message,
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
||||||
|
|||||||
@@ -29,7 +29,19 @@ module Jobs
|
|||||||
.where("tl.id IS NULL")
|
.where("tl.id IS NULL")
|
||||||
|
|
||||||
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
topics = topics.where(category_id: Category.where(read_restricted: false).select(:id))
|
# exclude all PMs
|
||||||
|
# and only include posts from public categories
|
||||||
|
topics =
|
||||||
|
topics
|
||||||
|
.where.not(archetype: Archetype.private_message)
|
||||||
|
.where(category_id: Category.where(read_restricted: false).select(:id))
|
||||||
|
else
|
||||||
|
# all regular topics, and group PMs
|
||||||
|
topics =
|
||||||
|
topics.where(
|
||||||
|
"topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
|
||||||
|
Archetype.private_message,
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
if SiteSetting.ai_translation_backfill_max_age_days > 0
|
||||||
|
|||||||
@@ -10,19 +10,16 @@ module Jobs
|
|||||||
return unless args[:text]
|
return unless args[:text]
|
||||||
return unless args[:client_id]
|
return unless args[:client_id]
|
||||||
|
|
||||||
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
|
helper_mode = args[:prompt]
|
||||||
|
|
||||||
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
|
|
||||||
prompt.custom_instruction = args[:custom_prompt]
|
|
||||||
end
|
|
||||||
|
|
||||||
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
|
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
|
||||||
prompt,
|
helper_mode,
|
||||||
args[:text],
|
args[:text],
|
||||||
user,
|
user,
|
||||||
"/discourse-ai/ai-helper/stream_composer_suggestion",
|
"/discourse-ai/ai-helper/stream_composer_suggestion",
|
||||||
force_default_locale: args[:force_default_locale],
|
force_default_locale: args[:force_default_locale],
|
||||||
client_id: args[:client_id],
|
client_id: args[:client_id],
|
||||||
|
custom_prompt: args[:custom_prompt],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,16 +14,12 @@ module Jobs
|
|||||||
|
|
||||||
return unless user.guardian.can_see?(post)
|
return unless user.guardian.can_see?(post)
|
||||||
|
|
||||||
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
|
helper_mode = args[:prompt]
|
||||||
|
|
||||||
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
|
if helper_mode == DiscourseAi::AiHelper::Assistant::EXPLAIN
|
||||||
prompt.custom_instruction = args[:custom_prompt]
|
input = <<~TEXT.strip
|
||||||
end
|
<term>#{args[:text]}</term>
|
||||||
|
<context>#{post.raw}</context>
|
||||||
if prompt.name == "explain"
|
|
||||||
input = <<~TEXT
|
|
||||||
<term>#{args[:text]}</term>
|
|
||||||
<context>#{post.raw}</context>
|
|
||||||
<topic>#{topic.title}</topic>
|
<topic>#{topic.title}</topic>
|
||||||
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
|
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
|
||||||
TEXT
|
TEXT
|
||||||
@@ -32,10 +28,11 @@ module Jobs
|
|||||||
end
|
end
|
||||||
|
|
||||||
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
|
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
|
||||||
prompt,
|
helper_mode,
|
||||||
input,
|
input,
|
||||||
user,
|
user,
|
||||||
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}",
|
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}",
|
||||||
|
custom_prompt: args[:custom_prompt],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class CategoriesLocaleDetectionBackfill < ::Jobs::Scheduled
|
||||||
|
every 1.hour
|
||||||
|
sidekiq_options retry: false
|
||||||
|
cluster_concurrency 1
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
|
return if !SiteSetting.ai_translation_enabled
|
||||||
|
return if SiteSetting.ai_translation_backfill_rate == 0
|
||||||
|
|
||||||
|
categories = Category.where(locale: nil)
|
||||||
|
|
||||||
|
if SiteSetting.ai_translation_backfill_limit_to_public_content
|
||||||
|
categories = categories.where(read_restricted: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
categories = categories.limit(SiteSetting.ai_translation_backfill_rate)
|
||||||
|
return if categories.empty?
|
||||||
|
|
||||||
|
categories.each do |category|
|
||||||
|
begin
|
||||||
|
DiscourseAi::Translation::CategoryLocaleDetector.detect_locale(category)
|
||||||
|
rescue FinalDestination::SSRFDetector::LookupFailedError
|
||||||
|
rescue => e
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log(
|
||||||
|
"Failed to detect category #{category.id}'s locale: #{e.message}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
DiscourseAi::Translation::VerboseLogger.log("Detected #{categories.size} category locales")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class GenerateConceptsFromPopularItems < ::Jobs::Scheduled
|
||||||
|
every 1.day
|
||||||
|
|
||||||
|
# This job runs daily and generates new concepts from popular topics and posts
|
||||||
|
# It selects items based on engagement metrics and generates concepts from their content
|
||||||
|
def execute(_args)
|
||||||
|
return unless SiteSetting.inferred_concepts_enabled
|
||||||
|
|
||||||
|
process_popular_topics
|
||||||
|
process_popular_posts
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_popular_topics
|
||||||
|
# Find candidate topics that are popular and don't have concepts yet
|
||||||
|
manager = DiscourseAi::InferredConcepts::Manager.new
|
||||||
|
candidates =
|
||||||
|
manager.find_candidate_topics(
|
||||||
|
limit: SiteSetting.inferred_concepts_daily_topics_limit || 20,
|
||||||
|
min_posts: SiteSetting.inferred_concepts_min_posts || 5,
|
||||||
|
min_likes: SiteSetting.inferred_concepts_min_likes || 10,
|
||||||
|
min_views: SiteSetting.inferred_concepts_min_views || 100,
|
||||||
|
created_after: SiteSetting.inferred_concepts_lookback_days.days.ago,
|
||||||
|
)
|
||||||
|
|
||||||
|
return if candidates.blank?
|
||||||
|
|
||||||
|
# Process candidate topics - first generate concepts, then match
|
||||||
|
Jobs.enqueue(
|
||||||
|
:generate_inferred_concepts,
|
||||||
|
item_type: "topics",
|
||||||
|
item_ids: candidates.map(&:id),
|
||||||
|
batch_size: 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
if SiteSetting.inferred_concepts_background_match
|
||||||
|
# Schedule a follow-up job to match existing concepts
|
||||||
|
Jobs.enqueue_in(
|
||||||
|
1.hour,
|
||||||
|
:generate_inferred_concepts,
|
||||||
|
item_type: "topics",
|
||||||
|
item_ids: candidates.map(&:id),
|
||||||
|
batch_size: 10,
|
||||||
|
match_only: true,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_popular_posts
|
||||||
|
# Find candidate posts that are popular and don't have concepts yet
|
||||||
|
manager = DiscourseAi::InferredConcepts::Manager.new
|
||||||
|
candidates =
|
||||||
|
manager.find_candidate_posts(
|
||||||
|
limit: SiteSetting.inferred_concepts_daily_posts_limit || 30,
|
||||||
|
min_likes: SiteSetting.inferred_concepts_post_min_likes || 5,
|
||||||
|
exclude_first_posts: true,
|
||||||
|
created_after: SiteSetting.inferred_concepts_lookback_days.days.ago,
|
||||||
|
)
|
||||||
|
|
||||||
|
return if candidates.blank?
|
||||||
|
|
||||||
|
# Process candidate posts - first generate concepts, then match
|
||||||
|
Jobs.enqueue(
|
||||||
|
:generate_inferred_concepts,
|
||||||
|
item_type: "posts",
|
||||||
|
item_ids: candidates.map(&:id),
|
||||||
|
batch_size: 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
if SiteSetting.inferred_concepts_background_match
|
||||||
|
# Schedule a follow-up job to match against existing concepts
|
||||||
|
Jobs.enqueue_in(
|
||||||
|
1.hour,
|
||||||
|
:generate_inferred_concepts,
|
||||||
|
item_type: "posts",
|
||||||
|
item_ids: candidates.map(&:id),
|
||||||
|
batch_size: 10,
|
||||||
|
match_only: true,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,6 +10,7 @@ module Jobs
|
|||||||
return if !SiteSetting.discourse_ai_enabled
|
return if !SiteSetting.discourse_ai_enabled
|
||||||
return if !SiteSetting.ai_translation_enabled
|
return if !SiteSetting.ai_translation_enabled
|
||||||
limit = SiteSetting.ai_translation_backfill_rate
|
limit = SiteSetting.ai_translation_backfill_rate
|
||||||
|
|
||||||
return if limit == 0
|
return if limit == 0
|
||||||
|
|
||||||
topics = Topic.where(locale: nil, deleted_at: nil).where("topics.user_id > 0")
|
topics = Topic.where(locale: nil, deleted_at: nil).where("topics.user_id > 0")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class AiArtifact < ActiveRecord::Base
|
class AiArtifact < ActiveRecord::Base
|
||||||
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
|
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
|
||||||
|
has_many :key_values, class_name: "AiArtifactKeyValue", dependent: :destroy
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :post
|
belongs_to :post
|
||||||
validates :html, length: { maximum: 65_535 }
|
validates :html, length: { maximum: 65_535 }
|
||||||
@@ -15,13 +16,25 @@ class AiArtifact < ActiveRecord::Base
|
|||||||
https://ajax.googleapis.com
|
https://ajax.googleapis.com
|
||||||
https://d3js.org
|
https://d3js.org
|
||||||
https://code.jquery.com
|
https://code.jquery.com
|
||||||
|
https://esm.sh
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def self.artifact_version_attribute(version)
|
||||||
|
if version
|
||||||
|
"data-artifact-version='#{version}'"
|
||||||
|
else
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.iframe_for(id, version = nil)
|
def self.iframe_for(id, version = nil)
|
||||||
<<~HTML
|
<<~HTML
|
||||||
<div class='ai-artifact'>
|
<div class='ai-artifact'>
|
||||||
<iframe src='#{url(id, version)}' frameborder="0" height="100%" width="100%"></iframe>
|
<iframe src='#{url(id, version)}' frameborder="0" height="100%" width="100%"></iframe>
|
||||||
<a href='#{url(id, version)}' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
|
<div class='ai-artifact-controls'>
|
||||||
|
<a href='#{url(id, version)}' class='link-artifact' target='_blank'>#{I18n.t("discourse_ai.ai_artifact.link")}</a>
|
||||||
|
<a href class='copy-embed' data-artifact-id="#{id}" #{artifact_version_attribute(version)} data-url="#{url(id, version)}">#{I18n.t("discourse_ai.ai_artifact.copy_embed")}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
HTML
|
HTML
|
||||||
end
|
end
|
||||||
@@ -37,7 +50,11 @@ class AiArtifact < ActiveRecord::Base
|
|||||||
|
|
||||||
def self.share_publicly(id:, post:)
|
def self.share_publicly(id:, post:)
|
||||||
artifact = AiArtifact.find_by(id: id)
|
artifact = AiArtifact.find_by(id: id)
|
||||||
artifact.update!(metadata: { public: true }) if artifact&.post&.topic&.id == post.topic.id
|
if artifact&.post&.topic&.id == post.topic.id
|
||||||
|
artifact.metadata ||= {}
|
||||||
|
artifact.metadata[:public] = true
|
||||||
|
artifact.save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.unshare_publicly(id:)
|
def self.unshare_publicly(id:)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AiArtifactKeyValue < ActiveRecord::Base
|
||||||
|
belongs_to :ai_artifact
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
validates :key, presence: true, length: { maximum: 50 }
|
||||||
|
validates :value,
|
||||||
|
presence: true,
|
||||||
|
length: {
|
||||||
|
maximum: ->(_) { SiteSetting.ai_artifact_kv_value_max_length },
|
||||||
|
}
|
||||||
|
attribute :public, :boolean, default: false
|
||||||
|
validates :ai_artifact, presence: true
|
||||||
|
validates :user, presence: true
|
||||||
|
validates :key, uniqueness: { scope: %i[ai_artifact_id user_id] }
|
||||||
|
|
||||||
|
validate :validate_max_keys_per_user_per_artifact
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_max_keys_per_user_per_artifact
|
||||||
|
return unless ai_artifact_id && user_id
|
||||||
|
|
||||||
|
max_keys = SiteSetting.ai_artifact_max_keys_per_user_per_artifact
|
||||||
|
existing_count = self.class.where(ai_artifact_id: ai_artifact_id, user_id: user_id).count
|
||||||
|
|
||||||
|
# Don't count the current record if it's being updated
|
||||||
|
existing_count -= 1 if persisted?
|
||||||
|
|
||||||
|
if existing_count >= max_keys
|
||||||
|
errors.add(
|
||||||
|
:base,
|
||||||
|
I18n.t("discourse_ai.ai_artifact.errors.max_keys_exceeded", count: max_keys),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: ai_artifact_key_values
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# ai_artifact_id :bigint not null
|
||||||
|
# user_id :integer not null
|
||||||
|
# key :string(50) not null
|
||||||
|
# value :string(20000) not null
|
||||||
|
# public :boolean default(FALSE), not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_ai_artifact_kv_unique (ai_artifact_id,user_id,key) UNIQUE
|
||||||
|
#
|
||||||
@@ -316,6 +316,10 @@ class AiPersona < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def features
|
||||||
|
DiscourseAi::Configuration::Feature.find_features_using(persona_id: id)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def chat_preconditions
|
def chat_preconditions
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class AiTool < ActiveRecord::Base
|
|||||||
message: I18n.t("discourse_ai.tools.name.characters"),
|
message: I18n.t("discourse_ai.tools.name.characters"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate :validate_parameters_enum
|
||||||
|
|
||||||
def signature
|
def signature
|
||||||
{
|
{
|
||||||
name: function_call_name,
|
name: function_call_name,
|
||||||
@@ -57,6 +59,30 @@ class AiTool < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_parameters_enum
|
||||||
|
return unless parameters.is_a?(Array)
|
||||||
|
|
||||||
|
parameters.each_with_index do |param, index|
|
||||||
|
next if !param.is_a?(Hash) || !param.key?("enum")
|
||||||
|
enum_values = param["enum"]
|
||||||
|
|
||||||
|
if enum_values.empty?
|
||||||
|
errors.add(
|
||||||
|
:parameters,
|
||||||
|
"Parameter '#{param["name"]}' at index #{index}: enum cannot be empty",
|
||||||
|
)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
if enum_values.uniq.length != enum_values.length
|
||||||
|
errors.add(
|
||||||
|
:parameters,
|
||||||
|
"Parameter '#{param["name"]}' at index #{index}: enum values must be unique",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.preamble
|
def self.preamble
|
||||||
<<~JS
|
<<~JS
|
||||||
/**
|
/**
|
||||||
@@ -142,6 +168,7 @@ class AiTool < ActiveRecord::Base
|
|||||||
* base_64_content (string): Base64 encoded content of the file.
|
* base_64_content (string): Base64 encoded content of the file.
|
||||||
* Returns: { id: number, url: string, short_url: string } - Details of the created upload record.
|
* Returns: { id: number, url: string, short_url: string } - Details of the created upload record.
|
||||||
*
|
*
|
||||||
|
* upload.getUrl(shortUrl): Given a short URL, eg upload://12345, returns the full CDN friendly URL of the upload.
|
||||||
* 5. chain
|
* 5. chain
|
||||||
* Controls the execution flow.
|
* Controls the execution flow.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class CompletionPrompt < ActiveRecord::Base
|
class CompletionPrompt < ActiveRecord::Base
|
||||||
|
#
|
||||||
|
# DEPRECATED.
|
||||||
|
# TODO(roman): Remove after 06-17-25
|
||||||
|
#
|
||||||
|
|
||||||
TRANSLATE = -301
|
TRANSLATE = -301
|
||||||
GENERATE_TITLES = -307
|
GENERATE_TITLES = -307
|
||||||
PROOFREAD = -303
|
PROOFREAD = -303
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user