Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d8895dad1 |
@@ -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, feature_name: "bot")
|
DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
|
||||||
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",
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -22,8 +22,6 @@ 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,
|
||||||
@@ -59,30 +57,6 @@ 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
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +142,6 @@ 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,25 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class InferredConcept < ActiveRecord::Base
|
|
||||||
has_many :inferred_concept_topics
|
|
||||||
has_many :topics, through: :inferred_concept_topics
|
|
||||||
|
|
||||||
has_many :inferred_concept_posts
|
|
||||||
has_many :posts, through: :inferred_concept_posts
|
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: true
|
|
||||||
end
|
|
||||||
|
|
||||||
# == Schema Information
|
|
||||||
#
|
|
||||||
# Table name: inferred_concepts
|
|
||||||
#
|
|
||||||
# id :bigint not null, primary key
|
|
||||||
# name :string not null
|
|
||||||
# created_at :datetime not null
|
|
||||||
# updated_at :datetime not null
|
|
||||||
#
|
|
||||||
# Indexes
|
|
||||||
#
|
|
||||||
# index_inferred_concepts_on_name (name) UNIQUE
|
|
||||||
#
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class InferredConceptPost < ActiveRecord::Base
|
|
||||||
belongs_to :inferred_concept
|
|
||||||
belongs_to :post
|
|
||||||
|
|
||||||
validates :inferred_concept_id, presence: true
|
|
||||||
validates :post_id, presence: true
|
|
||||||
validates :inferred_concept_id, uniqueness: { scope: :post_id }
|
|
||||||
end
|
|
||||||
|
|
||||||
# == Schema Information
|
|
||||||
#
|
|
||||||
# Table name: inferred_concept_posts
|
|
||||||
#
|
|
||||||
# inferred_concept_id :bigint
|
|
||||||
# post_id :bigint
|
|
||||||
# created_at :datetime not null
|
|
||||||
# updated_at :datetime not null
|
|
||||||
#
|
|
||||||
# Indexes
|
|
||||||
#
|
|
||||||
# index_inferred_concept_posts_on_inferred_concept_id (inferred_concept_id)
|
|
||||||
# index_inferred_concept_posts_uniqueness (post_id,inferred_concept_id) UNIQUE
|
|
||||||
#
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class InferredConceptTopic < ActiveRecord::Base
|
|
||||||
belongs_to :inferred_concept
|
|
||||||
belongs_to :topic
|
|
||||||
|
|
||||||
validates :inferred_concept_id, presence: true
|
|
||||||
validates :topic_id, presence: true
|
|
||||||
validates :inferred_concept_id, uniqueness: { scope: :topic_id }
|
|
||||||
end
|
|
||||||
|
|
||||||
# == Schema Information
|
|
||||||
#
|
|
||||||
# Table name: inferred_concept_topics
|
|
||||||
#
|
|
||||||
# inferred_concept_id :bigint
|
|
||||||
# topic_id :bigint
|
|
||||||
# created_at :datetime not null
|
|
||||||
# updated_at :datetime not null
|
|
||||||
#
|
|
||||||
# Indexes
|
|
||||||
#
|
|
||||||
# index_inferred_concept_topics_on_inferred_concept_id (inferred_concept_id)
|
|
||||||
# index_inferred_concept_topics_uniqueness (topic_id,inferred_concept_id) UNIQUE
|
|
||||||
#
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class AiInferredConceptPostSerializer < ApplicationSerializer
|
|
||||||
attributes :id,
|
|
||||||
:post_number,
|
|
||||||
:topic_id,
|
|
||||||
:topic_title,
|
|
||||||
:username,
|
|
||||||
:avatar_template,
|
|
||||||
:created_at,
|
|
||||||
:updated_at,
|
|
||||||
:excerpt,
|
|
||||||
:truncated,
|
|
||||||
:inferred_concepts
|
|
||||||
|
|
||||||
def avatar_template
|
|
||||||
User.avatar_template(object.username, object.uploaded_avatar_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def excerpt
|
|
||||||
Post.excerpt(object.cooked)
|
|
||||||
end
|
|
||||||
|
|
||||||
def truncated
|
|
||||||
object.cooked.length > SiteSetting.post_excerpt_maxlength
|
|
||||||
end
|
|
||||||
|
|
||||||
def inferred_concepts
|
|
||||||
ActiveModel::ArraySerializer.new(
|
|
||||||
object.inferred_concepts,
|
|
||||||
each_serializer: InferredConceptSerializer,
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class InferredConceptSerializer < ApplicationSerializer
|
|
||||||
attributes :id, :name, :created_at, :updated_at
|
|
||||||
end
|
|
||||||
@@ -30,16 +30,11 @@ export default class AiToolEditorForm extends Component {
|
|||||||
|
|
||||||
get formData() {
|
get formData() {
|
||||||
const parameters = (this.args.editingModel.parameters ?? []).map(
|
const parameters = (this.args.editingModel.parameters ?? []).map(
|
||||||
(parameter) => {
|
(parameter) => ({
|
||||||
const mappedParameter = {
|
...parameter,
|
||||||
...parameter,
|
isEnum: !!parameter.enum?.length,
|
||||||
};
|
enum: (parameter.enum ??= []),
|
||||||
mappedParameter.isEnum = parameter.enum && parameter.enum.length > 0;
|
})
|
||||||
if (!mappedParameter.isEnum) {
|
|
||||||
delete mappedParameter.enum;
|
|
||||||
}
|
|
||||||
return mappedParameter;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -68,19 +63,8 @@ export default class AiToolEditorForm extends Component {
|
|||||||
async save(data) {
|
async save(data) {
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
|
|
||||||
// we injected a isEnum thing, we need to clean it up
|
|
||||||
const copiedData = JSON.parse(JSON.stringify(data));
|
|
||||||
if (copiedData.parameters) {
|
|
||||||
copiedData.parameters.forEach((parameter) => {
|
|
||||||
if (!parameter.isEnum) {
|
|
||||||
delete parameter.enum;
|
|
||||||
}
|
|
||||||
delete parameter.isEnum;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.args.model.save(copiedData);
|
await this.args.model.save(data);
|
||||||
|
|
||||||
this.toasts.success({
|
this.toasts.success({
|
||||||
data: { message: i18n("discourse_ai.tools.saved") },
|
data: { message: i18n("discourse_ai.tools.saved") },
|
||||||
|
|||||||
+1
-15
@@ -22,20 +22,10 @@ export default class AiPersonaResponseFormatEditor extends Component {
|
|||||||
type: "string",
|
type: "string",
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: "string",
|
|
||||||
enum: ["string", "integer", "boolean", "array"],
|
|
||||||
},
|
|
||||||
array_type: {
|
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["string", "integer", "boolean"],
|
enum: ["string", "integer", "boolean"],
|
||||||
options: {
|
|
||||||
dependencies: {
|
|
||||||
type: "array",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["key", "type"],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,11 +41,7 @@ export default class AiPersonaResponseFormatEditor extends Component {
|
|||||||
const toDisplay = {};
|
const toDisplay = {};
|
||||||
|
|
||||||
this.args.data.response_format.forEach((keyDesc) => {
|
this.args.data.response_format.forEach((keyDesc) => {
|
||||||
if (keyDesc.type === "array") {
|
toDisplay[keyDesc.key] = keyDesc.type;
|
||||||
toDisplay[keyDesc.key] = `[${keyDesc.array_type}]`;
|
|
||||||
} else {
|
|
||||||
toDisplay[keyDesc.key] = keyDesc.type;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return prettyJSON(toDisplay);
|
return prettyJSON(toDisplay);
|
||||||
|
|||||||
@@ -144,11 +144,11 @@ export default class DebugAiModal extends Component {
|
|||||||
>{{i18n "discourse_ai.ai_bot.debug_ai_modal.response"}}</a></li>
|
>{{i18n "discourse_ai.ai_bot.debug_ai_modal.response"}}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="ai-debug-modal__tokens">
|
<div class="ai-debug-modal__tokens">
|
||||||
<span class="ai-debug-modal__tokens__count">
|
<span>
|
||||||
{{i18n "discourse_ai.ai_bot.debug_ai_modal.request_tokens"}}
|
{{i18n "discourse_ai.ai_bot.debug_ai_modal.request_tokens"}}
|
||||||
{{this.info.request_tokens}}
|
{{this.info.request_tokens}}
|
||||||
</span>
|
</span>
|
||||||
<span class="ai-debug-modal__tokens__count">
|
<span>
|
||||||
{{i18n "discourse_ai.ai_bot.debug_ai_modal.response_tokens"}}
|
{{i18n "discourse_ai.ai_bot.debug_ai_modal.response_tokens"}}
|
||||||
{{this.info.response_tokens}}
|
{{this.info.response_tokens}}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
|||||||
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
|
||||||
import { service } from "@ember/service";
|
import { service } from "@ember/service";
|
||||||
import { htmlSafe } from "@ember/template";
|
import { htmlSafe } from "@ember/template";
|
||||||
import { or } from "truth-helpers";
|
|
||||||
import CookText from "discourse/components/cook-text";
|
import CookText from "discourse/components/cook-text";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import DModal from "discourse/components/d-modal";
|
import DModal from "discourse/components/d-modal";
|
||||||
@@ -42,10 +41,6 @@ export default class ModalDiffModal extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get diffResult() {
|
get diffResult() {
|
||||||
if (this.loading) {
|
|
||||||
return this.escapedSelectedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.diffStreamer.diff?.length > 0) {
|
if (this.diffStreamer.diff?.length > 0) {
|
||||||
return this.diffStreamer.diff;
|
return this.diffStreamer.diff;
|
||||||
}
|
}
|
||||||
@@ -55,22 +50,10 @@ export default class ModalDiffModal extends Component {
|
|||||||
return this.escapedSelectedText;
|
return this.escapedSelectedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
get smoothStreamerResult() {
|
|
||||||
if (this.loading) {
|
|
||||||
return this.escapedSelectedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.smoothStreamer.renderedText;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isStreaming() {
|
get isStreaming() {
|
||||||
// diffStreamer stops Streaming when it is finished with a chunk, looking at isDone is safe
|
// diffStreamer stops Streaming when it is finished with a chunk, looking at isDone is safe
|
||||||
// it starts off not done
|
// it starts off not done
|
||||||
if (this.args.model.showResultAsDiff) {
|
return !this.diffStreamer.isDone || this.smoothStreamer.isStreaming;
|
||||||
return !this.diffStreamer.isDone;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.smoothStreamer.isStreaming;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get primaryBtnLabel() {
|
get primaryBtnLabel() {
|
||||||
@@ -171,37 +154,42 @@ export default class ModalDiffModal extends Component {
|
|||||||
{{willDestroy this.cleanup}}
|
{{willDestroy this.cleanup}}
|
||||||
class="text-preview"
|
class="text-preview"
|
||||||
>
|
>
|
||||||
<div
|
{{#if this.loading}}
|
||||||
class={{concatClass
|
<div class="composer-ai-helper-modal__loading">
|
||||||
"composer-ai-helper-modal__suggestion"
|
{{[email protected]~}}
|
||||||
"streamable-content"
|
</div>
|
||||||
(if this.isStreaming "streaming")
|
{{else}}
|
||||||
(if @model.showResultAsDiff "inline-diff")
|
<div
|
||||||
(if this.diffStreamer.isThinking "thinking")
|
class={{concatClass
|
||||||
(if this.loading "composer-ai-helper-modal__loading")
|
"composer-ai-helper-modal__suggestion"
|
||||||
}}
|
"streamable-content"
|
||||||
>
|
(if this.isStreaming "streaming")
|
||||||
{{~#if @model.showResultAsDiff~}}
|
(if @model.showResultAsDiff "inline-diff")
|
||||||
<span class="diff-inner">{{htmlSafe this.diffResult}}</span>
|
(if this.diffStreamer.isThinking "thinking")
|
||||||
{{else}}
|
}}
|
||||||
{{#if (or this.loading this.smoothStreamer.isStreaming)}}
|
>
|
||||||
<CookText
|
{{~#if @model.showResultAsDiff~}}
|
||||||
@rawText={{this.smoothStreamerResult}}
|
<span class="diff-inner">{{htmlSafe this.diffResult}}</span>
|
||||||
class="cooked"
|
|
||||||
/>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="composer-ai-helper-modal__old-value">
|
{{#if this.smoothStreamer.isStreaming}}
|
||||||
{{~this.escapedSelectedText~}}
|
|
||||||
</div>
|
|
||||||
<div class="composer-ai-helper-modal__new-value">
|
|
||||||
<CookText
|
<CookText
|
||||||
@rawText={{this.smoothStreamerResult}}
|
@rawText={{this.smoothStreamer.renderedText}}
|
||||||
class="cooked"
|
class="cooked"
|
||||||
/>
|
/>
|
||||||
</div>
|
{{else}}
|
||||||
|
<div class="composer-ai-helper-modal__old-value">
|
||||||
|
{{@model.selectedText}}
|
||||||
|
</div>
|
||||||
|
<div class="composer-ai-helper-modal__new-value">
|
||||||
|
<CookText
|
||||||
|
@rawText={{this.smoothStreamer.renderedText}}
|
||||||
|
class="cooked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
</div>
|
||||||
</div>
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</:body>
|
</:body>
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -15,12 +15,13 @@ export default class AiFullPageDiscobotDiscoveries extends Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@service capabilities;
|
|
||||||
@service discobotDiscoveries;
|
@service discobotDiscoveries;
|
||||||
@service site;
|
@service site;
|
||||||
|
|
||||||
get previewLength() {
|
get previewLength() {
|
||||||
if (!this.capabilities.viewport.md) {
|
// todo: replace with js breakpoint API
|
||||||
|
// https://github.com/discourse/discourse/pull/32060
|
||||||
|
if (this.site.mobileView || this.site.narrowDesktopView) {
|
||||||
return 50;
|
return 50;
|
||||||
} else {
|
} else {
|
||||||
return 10000;
|
return 10000;
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ mark.highlight {
|
|||||||
animation-name: mark-blink;
|
animation-name: mark-blink;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-ai-helper-modal__loading.inline-diff {
|
.composer-ai-helper-modal__loading {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ span.onebox-ai-llm-title {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-debug-modal__tokens__count {
|
.ai-debug-modal__tokens span {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
@use "lib/viewport";
|
@use "lib/viewport";
|
||||||
|
|
||||||
.composer-ai-helper-modal {
|
.composer-ai-helper-modal {
|
||||||
|
.text-preview,
|
||||||
.inline-diff {
|
.inline-diff {
|
||||||
font-family: var(--d-font-family--monospace);
|
font-family: var(--d-font-family--monospace);
|
||||||
font-variant-ligatures: none;
|
font-variant-ligatures: none;
|
||||||
}
|
|
||||||
|
|
||||||
.text-preview,
|
|
||||||
.inline-diff {
|
|
||||||
ins {
|
ins {
|
||||||
background-color: var(--success-low);
|
background-color: var(--success-low);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -57,16 +55,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__old-value {
|
&__old-value {
|
||||||
white-space: pre-wrap;
|
background-color: var(--danger-low);
|
||||||
border-left: 2px solid var(--danger);
|
|
||||||
padding-left: 1rem;
|
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__new-value {
|
&__new-value {
|
||||||
border-left: 2px solid var(--success);
|
background-color: var(--success-low);
|
||||||
padding-left: 1rem;
|
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +77,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-composer-helper-menu {
|
.ai-composer-helper-menu {
|
||||||
|
padding: 0.25rem;
|
||||||
max-width: 25rem;
|
max-width: 25rem;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
||||||
@@ -705,7 +701,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.5em 1rem;
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -330,15 +330,6 @@ en:
|
|||||||
short_summarizer:
|
short_summarizer:
|
||||||
name: "Summarizer (short form)"
|
name: "Summarizer (short form)"
|
||||||
description: "Default persona used to power AI short summaries for topic lists' items"
|
description: "Default persona used to power AI short summaries for topic lists' items"
|
||||||
concept_finder:
|
|
||||||
name: "Concept Finder"
|
|
||||||
description: "AI Bot specialized in identifying concepts and themes in content"
|
|
||||||
concept_matcher:
|
|
||||||
name: "Concept Matcher"
|
|
||||||
description: "AI Bot specialized in matching content against existing concepts"
|
|
||||||
concept_deduplicator:
|
|
||||||
name: "Concept Deduplicator"
|
|
||||||
description: "AI Bot specialized in deduplicating concepts"
|
|
||||||
topic_not_found: "Summary unavailable, topic not found!"
|
topic_not_found: "Summary unavailable, topic not found!"
|
||||||
summarizing: "Summarizing topic"
|
summarizing: "Summarizing topic"
|
||||||
searching: "Searching for: '%{query}'"
|
searching: "Searching for: '%{query}'"
|
||||||
@@ -558,9 +549,6 @@ en:
|
|||||||
discord_search:
|
discord_search:
|
||||||
name: "Discord Search"
|
name: "Discord Search"
|
||||||
description: "Adds the ability to search Discord channels"
|
description: "Adds the ability to search Discord channels"
|
||||||
inferred_concepts:
|
|
||||||
name: "Inferred Concepts"
|
|
||||||
description: "Classifies topics and posts into areas of interest / labels."
|
|
||||||
|
|
||||||
errors:
|
errors:
|
||||||
quota_exceeded: "You have exceeded the quota for this model. Please try again in %{relative_time}."
|
quota_exceeded: "You have exceeded the quota for this model. Please try again in %{relative_time}."
|
||||||
|
|||||||
@@ -417,55 +417,3 @@ discourse_ai:
|
|||||||
default: false
|
default: false
|
||||||
client: false
|
client: false
|
||||||
hidden: true
|
hidden: true
|
||||||
|
|
||||||
inferred_concepts_enabled:
|
|
||||||
default: false
|
|
||||||
client: true
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_background_match:
|
|
||||||
default: false
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_daily_topics_limit:
|
|
||||||
default: 20
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_min_posts:
|
|
||||||
default: 5
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_min_likes:
|
|
||||||
default: 10
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_min_views:
|
|
||||||
default: 100
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_lookback_days:
|
|
||||||
default: 30
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_daily_posts_limit:
|
|
||||||
default: 30
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_post_min_likes:
|
|
||||||
default: 5
|
|
||||||
client: false
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_generate_persona:
|
|
||||||
default: "-15"
|
|
||||||
type: enum
|
|
||||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_match_persona:
|
|
||||||
default: "-16"
|
|
||||||
type: enum
|
|
||||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
inferred_concepts_deduplicate_persona:
|
|
||||||
default: "-17"
|
|
||||||
type: enum
|
|
||||||
enum: "DiscourseAi::Configuration::PersonaEnumerator"
|
|
||||||
area: "ai-features/inferred_concepts"
|
|
||||||
|
|||||||
@@ -72,13 +72,9 @@ DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id|
|
|||||||
|
|
||||||
persona.tools = tools.map { |name, value| [name, value] }
|
persona.tools = tools.map { |name, value| [name, value] }
|
||||||
|
|
||||||
# Only set response_format if it's not defined as a method in the persona class
|
persona.response_format = instance.response_format
|
||||||
if !instance.class.instance_methods.include?(:response_format)
|
|
||||||
persona.response_format = instance.response_format
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only set examples if it's not defined as a method in the persona class
|
persona.examples = instance.examples
|
||||||
persona.examples = instance.examples if !instance.class.instance_methods.include?(:examples)
|
|
||||||
|
|
||||||
persona.system_prompt = instance.system_prompt
|
persona.system_prompt = instance.system_prompt
|
||||||
persona.top_p = instance.top_p
|
persona.top_p = instance.top_p
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
class CreateInferredConceptsTable < ActiveRecord::Migration[7.2]
|
|
||||||
def change
|
|
||||||
create_table :inferred_concepts do |t|
|
|
||||||
t.string :name, null: false
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :inferred_concepts, :name, unique: true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class CreateInferredConceptTopics < ActiveRecord::Migration[7.0]
|
|
||||||
def change
|
|
||||||
create_table :inferred_concept_topics, id: false do |t|
|
|
||||||
t.bigint :inferred_concept_id
|
|
||||||
t.bigint :topic_id
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :inferred_concept_topics,
|
|
||||||
%i[topic_id inferred_concept_id],
|
|
||||||
unique: true,
|
|
||||||
name: "index_inferred_concept_topics_uniqueness"
|
|
||||||
|
|
||||||
add_index :inferred_concept_topics, :inferred_concept_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class CreateInferredConceptPosts < ActiveRecord::Migration[7.0]
|
|
||||||
def change
|
|
||||||
create_table :inferred_concept_posts, id: false do |t|
|
|
||||||
t.bigint :inferred_concept_id
|
|
||||||
t.bigint :post_id
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :inferred_concept_posts,
|
|
||||||
%i[post_id inferred_concept_id],
|
|
||||||
unique: true,
|
|
||||||
name: "index_inferred_concept_posts_uniqueness"
|
|
||||||
|
|
||||||
add_index :inferred_concept_posts, :inferred_concept_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -9,7 +9,6 @@ module DiscourseAi
|
|||||||
@stream_consumer = stream_consumer
|
@stream_consumer = stream_consumer
|
||||||
@current_key = nil
|
@current_key = nil
|
||||||
@current_value = nil
|
@current_value = nil
|
||||||
@tracking_array = false
|
|
||||||
@parser = DiscourseAi::Completions::JsonStreamingParser.new
|
@parser = DiscourseAi::Completions::JsonStreamingParser.new
|
||||||
|
|
||||||
@parser.key do |k|
|
@parser.key do |k|
|
||||||
@@ -17,28 +16,12 @@ module DiscourseAi
|
|||||||
@current_value = nil
|
@current_value = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@parser.value do |value|
|
@parser.value do |v|
|
||||||
if @current_key
|
if @current_key
|
||||||
if @tracking_array
|
stream_consumer.notify_progress(@current_key, v)
|
||||||
@current_value << value
|
@current_key = nil
|
||||||
stream_consumer.notify_progress(@current_key, @current_value)
|
|
||||||
else
|
|
||||||
stream_consumer.notify_progress(@current_key, value)
|
|
||||||
@current_key = nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@parser.start_array do
|
|
||||||
@tracking_array = true
|
|
||||||
@current_value = []
|
|
||||||
end
|
|
||||||
|
|
||||||
@parser.end_array do
|
|
||||||
@tracking_array = false
|
|
||||||
@current_key = nil
|
|
||||||
@current_value = nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def broken?
|
def broken?
|
||||||
@@ -63,9 +46,8 @@ module DiscourseAi
|
|||||||
end
|
end
|
||||||
|
|
||||||
if @parser.state == :start_string && @current_key
|
if @parser.state == :start_string && @current_key
|
||||||
buffered = @tracking_array ? [@parser.buf] : @parser.buf
|
|
||||||
# this is is worth notifying
|
# this is is worth notifying
|
||||||
stream_consumer.notify_progress(@current_key, buffered)
|
stream_consumer.notify_progress(@current_key, @parser.buf)
|
||||||
end
|
end
|
||||||
|
|
||||||
@current_key = nil if @parser.state == :end_value
|
@current_key = nil if @parser.state == :end_value
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ module DiscourseAi
|
|||||||
@property_cursors[prop_name] = @tracked[prop_name].length
|
@property_cursors[prop_name] = @tracked[prop_name].length
|
||||||
unread
|
unread
|
||||||
else
|
else
|
||||||
# Ints and bools, and arrays are always returned as is.
|
# Ints and bools are always returned as is.
|
||||||
@tracked[prop_name]
|
@tracked[prop_name]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ module DiscourseAi
|
|||||||
def valid_value?(val)
|
def valid_value?(val)
|
||||||
return true if Rails.env.test?
|
return true if Rails.env.test?
|
||||||
return true if AiModerationSetting.spam
|
return true if AiModerationSetting.spam
|
||||||
# only validate when enabling setting
|
|
||||||
return true if val == "f"
|
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module DiscourseAi
|
|||||||
@persona =
|
@persona =
|
||||||
AiPersona
|
AiPersona
|
||||||
.all_personas(enabled_only: false)
|
.all_personas(enabled_only: false)
|
||||||
.find { |p| p.id == SiteSetting.ai_discord_search_persona.to_i }
|
.find { |persona| persona.id == SiteSetting.ai_discord_search_persona.to_i }
|
||||||
.new
|
.new
|
||||||
@bot =
|
@bot =
|
||||||
DiscourseAi::Personas::Bot.as(
|
DiscourseAi::Personas::Bot.as(
|
||||||
|
|||||||
@@ -36,14 +36,6 @@ module DiscourseAi
|
|||||||
persona_setting_name: "ai_discord_search_persona",
|
persona_setting_name: "ai_discord_search_persona",
|
||||||
enable_setting_name: "ai_discord_search_enabled",
|
enable_setting_name: "ai_discord_search_enabled",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name_ref: "inferred_concepts",
|
|
||||||
name_key: "discourse_ai.features.inferred_concepts.name",
|
|
||||||
description_key: "discourse_ai.features.inferred_concepts.description",
|
|
||||||
persona_setting_name: "inferred_concepts_generate_persona",
|
|
||||||
enable_setting_name: "inferred_concepts_enabled",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module DiscourseAi
|
|
||||||
module InferredConcepts
|
|
||||||
class Applier
|
|
||||||
# Associates the provided concepts with a topic
|
|
||||||
# topic: a Topic instance
|
|
||||||
# concepts: an array of InferredConcept instances
|
|
||||||
def apply_to_topic(topic, concepts)
|
|
||||||
return if topic.blank? || concepts.blank?
|
|
||||||
|
|
||||||
topic.inferred_concepts << concepts
|
|
||||||
end
|
|
||||||
|
|
||||||
# Associates the provided concepts with a post
|
|
||||||
# post: a Post instance
|
|
||||||
# concepts: an array of InferredConcept instances
|
|
||||||
def apply_to_post(post, concepts)
|
|
||||||
return if post.blank? || concepts.blank?
|
|
||||||
|
|
||||||
post.inferred_concepts << concepts
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extracts content from a topic for concept analysis
|
|
||||||
# Returns a string with the topic title and first few posts
|
|
||||||
def topic_content_for_analysis(topic)
|
|
||||||
return "" if topic.blank?
|
|
||||||
|
|
||||||
# Combine title and first few posts for analysis
|
|
||||||
posts = Post.where(topic_id: topic.id).order(:post_number).limit(10)
|
|
||||||
|
|
||||||
content = "Title: #{topic.title}\n\n"
|
|
||||||
content += posts.map { |p| "#{p.post_number}) #{p.user.username}: #{p.raw}" }.join("\n\n")
|
|
||||||
|
|
||||||
content
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extracts content from a post for concept analysis
|
|
||||||
# Returns a string with the post content
|
|
||||||
def post_content_for_analysis(post)
|
|
||||||
return "" if post.blank?
|
|
||||||
|
|
||||||
# Get the topic title for context
|
|
||||||
topic_title = post.topic&.title || ""
|
|
||||||
|
|
||||||
content = "Topic: #{topic_title}\n\n"
|
|
||||||
content += "Post by #{post.user.username}:\n#{post.raw}"
|
|
||||||
|
|
||||||
content
|
|
||||||
end
|
|
||||||
|
|
||||||
# Match a topic with existing concepts
|
|
||||||
def match_existing_concepts(topic)
|
|
||||||
return [] if topic.blank?
|
|
||||||
|
|
||||||
# Get content to analyze
|
|
||||||
content = topic_content_for_analysis(topic)
|
|
||||||
|
|
||||||
# Get all existing concepts
|
|
||||||
existing_concepts = DiscourseAi::InferredConcepts::Manager.new.list_concepts
|
|
||||||
return [] if existing_concepts.empty?
|
|
||||||
|
|
||||||
# Use the ConceptMatcher persona to match concepts
|
|
||||||
matched_concept_names = match_concepts_to_content(content, existing_concepts)
|
|
||||||
|
|
||||||
# Find concepts in the database
|
|
||||||
matched_concepts = InferredConcept.where(name: matched_concept_names)
|
|
||||||
|
|
||||||
# Apply concepts to the topic
|
|
||||||
apply_to_topic(topic, matched_concepts)
|
|
||||||
|
|
||||||
matched_concepts
|
|
||||||
end
|
|
||||||
|
|
||||||
# Match a post with existing concepts
|
|
||||||
def match_existing_concepts_for_post(post)
|
|
||||||
return [] if post.blank?
|
|
||||||
|
|
||||||
# Get content to analyze
|
|
||||||
content = post_content_for_analysis(post)
|
|
||||||
|
|
||||||
# Get all existing concepts
|
|
||||||
existing_concepts = DiscourseAi::InferredConcepts::Manager.new.list_concepts
|
|
||||||
return [] if existing_concepts.empty?
|
|
||||||
|
|
||||||
# Use the ConceptMatcher persona to match concepts
|
|
||||||
matched_concept_names = match_concepts_to_content(content, existing_concepts)
|
|
||||||
|
|
||||||
# Find concepts in the database
|
|
||||||
matched_concepts = InferredConcept.where(name: matched_concept_names)
|
|
||||||
|
|
||||||
# Apply concepts to the post
|
|
||||||
apply_to_post(post, matched_concepts)
|
|
||||||
|
|
||||||
matched_concepts
|
|
||||||
end
|
|
||||||
|
|
||||||
# Use ConceptMatcher persona to match content against provided concepts
|
|
||||||
def match_concepts_to_content(content, concept_list)
|
|
||||||
return [] if content.blank? || concept_list.blank?
|
|
||||||
|
|
||||||
# Prepare user message with only the content
|
|
||||||
user_message = content
|
|
||||||
|
|
||||||
# Use the ConceptMatcher persona to match concepts
|
|
||||||
|
|
||||||
persona =
|
|
||||||
AiPersona
|
|
||||||
.all_personas(enabled_only: false)
|
|
||||||
.find { |p| p.id == SiteSetting.inferred_concepts_match_persona.to_i }
|
|
||||||
.new
|
|
||||||
|
|
||||||
llm = LlmModel.find(persona.class.default_llm_id)
|
|
||||||
|
|
||||||
input = { type: :user, content: content }
|
|
||||||
|
|
||||||
context =
|
|
||||||
DiscourseAi::Personas::BotContext.new(
|
|
||||||
messages: [input],
|
|
||||||
user: Discourse.system_user,
|
|
||||||
inferred_concepts: concept_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: persona, model: llm)
|
|
||||||
structured_output = nil
|
|
||||||
|
|
||||||
bot.reply(context) do |partial, _, type|
|
|
||||||
structured_output = partial if type == :structured_output
|
|
||||||
end
|
|
||||||
|
|
||||||
structured_output&.read_buffered_property(:matching_concepts) || []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module DiscourseAi
|
|
||||||
module InferredConcepts
|
|
||||||
class Finder
|
|
||||||
# Identifies potential concepts from provided content
|
|
||||||
# Returns an array of concept names (strings)
|
|
||||||
def identify_concepts(content)
|
|
||||||
return [] if content.blank?
|
|
||||||
|
|
||||||
# Use the ConceptFinder persona to identify concepts
|
|
||||||
persona =
|
|
||||||
AiPersona
|
|
||||||
.all_personas(enabled_only: false)
|
|
||||||
.find { |p| p.id == SiteSetting.inferred_concepts_generate_persona.to_i }
|
|
||||||
.new
|
|
||||||
|
|
||||||
llm = LlmModel.find(persona.class.default_llm_id)
|
|
||||||
context =
|
|
||||||
DiscourseAi::Personas::BotContext.new(
|
|
||||||
messages: [{ type: :user, content: content }],
|
|
||||||
user: Discourse.system_user,
|
|
||||||
inferred_concepts: DiscourseAi::InferredConcepts::Manager.new.list_concepts,
|
|
||||||
)
|
|
||||||
|
|
||||||
bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: persona, model: llm)
|
|
||||||
structured_output = nil
|
|
||||||
|
|
||||||
bot.reply(context) do |partial, _, type|
|
|
||||||
structured_output = partial if type == :structured_output
|
|
||||||
end
|
|
||||||
|
|
||||||
structured_output&.read_buffered_property(:concepts) || []
|
|
||||||
end
|
|
||||||
|
|
||||||
# Creates or finds concepts in the database from provided names
|
|
||||||
# Returns an array of InferredConcept instances
|
|
||||||
def create_or_find_concepts(concept_names)
|
|
||||||
return [] if concept_names.blank?
|
|
||||||
|
|
||||||
concept_names.map { |name| InferredConcept.find_or_create_by(name: name) }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Finds candidate topics to use for concept generation
|
|
||||||
#
|
|
||||||
# @param limit [Integer] Maximum number of topics to return
|
|
||||||
# @param min_posts [Integer] Minimum number of posts in topic
|
|
||||||
# @param min_likes [Integer] Minimum number of likes across all posts
|
|
||||||
# @param min_views [Integer] Minimum number of views
|
|
||||||
# @param exclude_topic_ids [Array<Integer>] Topic IDs to exclude
|
|
||||||
# @param category_ids [Array<Integer>] Only include topics from these categories (optional)
|
|
||||||
# @param created_after [DateTime] Only include topics created after this time (optional)
|
|
||||||
# @return [Array<Topic>] Array of Topic objects that are good candidates
|
|
||||||
def find_candidate_topics(
|
|
||||||
limit: 100,
|
|
||||||
min_posts: 5,
|
|
||||||
min_likes: 10,
|
|
||||||
min_views: 100,
|
|
||||||
exclude_topic_ids: [],
|
|
||||||
category_ids: nil,
|
|
||||||
created_after: 30.days.ago
|
|
||||||
)
|
|
||||||
query =
|
|
||||||
Topic.where(
|
|
||||||
"topics.posts_count >= ? AND topics.views >= ? AND topics.like_count >= ?",
|
|
||||||
min_posts,
|
|
||||||
min_views,
|
|
||||||
min_likes,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply additional filters
|
|
||||||
query = query.where("topics.id NOT IN (?)", exclude_topic_ids) if exclude_topic_ids.present?
|
|
||||||
query = query.where("topics.category_id IN (?)", category_ids) if category_ids.present?
|
|
||||||
query = query.where("topics.created_at >= ?", created_after) if created_after.present?
|
|
||||||
|
|
||||||
# Exclude PM topics (if they exist in Discourse)
|
|
||||||
query = query.where(archetype: Archetype.default)
|
|
||||||
|
|
||||||
# Exclude topics that already have concepts
|
|
||||||
topics_with_concepts = <<~SQL
|
|
||||||
SELECT DISTINCT topic_id
|
|
||||||
FROM inferred_concept_topics
|
|
||||||
SQL
|
|
||||||
|
|
||||||
query = query.where("topics.id NOT IN (#{topics_with_concepts})")
|
|
||||||
|
|
||||||
# Score and order topics by engagement (combination of views, likes, and posts)
|
|
||||||
query =
|
|
||||||
query.select(
|
|
||||||
"topics.*,
|
|
||||||
(topics.like_count * 2 + topics.posts_count * 3 + topics.views * 0.1) AS engagement_score",
|
|
||||||
).order("engagement_score DESC")
|
|
||||||
|
|
||||||
# Return limited number of topics
|
|
||||||
query.limit(limit)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Find candidate posts that are good for concept generation
|
|
||||||
#
|
|
||||||
# @param limit [Integer] Maximum number of posts to return
|
|
||||||
# @param min_likes [Integer] Minimum number of likes
|
|
||||||
# @param exclude_first_posts [Boolean] Exclude first posts in topics
|
|
||||||
# @param exclude_post_ids [Array<Integer>] Post IDs to exclude
|
|
||||||
# @param category_ids [Array<Integer>] Only include posts from topics in these categories
|
|
||||||
# @param created_after [DateTime] Only include posts created after this time
|
|
||||||
# @return [Array<Post>] Array of Post objects that are good candidates
|
|
||||||
def find_candidate_posts(
|
|
||||||
limit: 100,
|
|
||||||
min_likes: 5,
|
|
||||||
exclude_first_posts: true,
|
|
||||||
exclude_post_ids: [],
|
|
||||||
category_ids: nil,
|
|
||||||
created_after: 30.days.ago
|
|
||||||
)
|
|
||||||
query = Post.where("posts.like_count >= ?", min_likes)
|
|
||||||
|
|
||||||
# Exclude first posts if specified
|
|
||||||
query = query.where("posts.post_number > 1") if exclude_first_posts
|
|
||||||
|
|
||||||
# Apply additional filters
|
|
||||||
query = query.where("posts.id NOT IN (?)", exclude_post_ids) if exclude_post_ids.present?
|
|
||||||
query = query.where("posts.created_at >= ?", created_after) if created_after.present?
|
|
||||||
|
|
||||||
# Filter by category if specified
|
|
||||||
if category_ids.present?
|
|
||||||
query = query.joins(:topic).where("topics.category_id IN (?)", category_ids)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Exclude posts that already have concepts
|
|
||||||
posts_with_concepts = <<~SQL
|
|
||||||
SELECT DISTINCT post_id
|
|
||||||
FROM inferred_concept_posts
|
|
||||||
SQL
|
|
||||||
|
|
||||||
query = query.where("posts.id NOT IN (#{posts_with_concepts})")
|
|
||||||
|
|
||||||
# Order by engagement (likes)
|
|
||||||
query = query.order(like_count: :desc)
|
|
||||||
|
|
||||||
# Return limited number of posts
|
|
||||||
query.limit(limit)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Deduplicate and standardize a list of concepts
|
|
||||||
# @param concept_names [Array<String>] List of concept names to deduplicate
|
|
||||||
# @return [Hash] Hash with deduplicated concepts and mapping
|
|
||||||
def deduplicate_concepts(concept_names)
|
|
||||||
return { deduplicated_concepts: [], mapping: {} } if concept_names.blank?
|
|
||||||
|
|
||||||
# Use the ConceptDeduplicator persona to deduplicate concepts
|
|
||||||
persona =
|
|
||||||
AiPersona
|
|
||||||
.all_personas(enabled_only: false)
|
|
||||||
.find { |p| p.id == SiteSetting.inferred_concepts_deduplicate_persona.to_i }
|
|
||||||
.new
|
|
||||||
|
|
||||||
llm = LlmModel.find(persona.class.default_llm_id)
|
|
||||||
|
|
||||||
# Create the input for the deduplicator
|
|
||||||
input = { type: :user, content: concept_names.join(", ") }
|
|
||||||
|
|
||||||
context =
|
|
||||||
DiscourseAi::Personas::BotContext.new(messages: [input], user: Discourse.system_user)
|
|
||||||
|
|
||||||
bot = DiscourseAi::Personas::Bot.as(Discourse.system_user, persona: persona, model: llm)
|
|
||||||
structured_output = nil
|
|
||||||
|
|
||||||
bot.reply(context) do |partial, _, type|
|
|
||||||
structured_output = partial if type == :structured_output
|
|
||||||
end
|
|
||||||
|
|
||||||
structured_output&.read_buffered_property(:streamlined_tags) || []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user