Skip to content

FEATURE: Examples support for personas. #1334

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/controllers/discourse_ai/admin/ai_personas_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ def ai_persona_params
permitted[:response_format] = permit_response_format(response_format)
end

if examples = params.dig(:ai_persona, :examples)
permitted[:examples] = permit_examples(examples)
end

permitted
end

Expand All @@ -251,6 +255,12 @@ def permit_response_format(response_format)
end
end
end

def permit_examples(examples)
return [] if !examples.is_a?(Array)

examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
end
end
end
end
19 changes: 19 additions & 0 deletions app/models/ai_persona.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class AiPersona < ActiveRecord::Base
validate :system_persona_unchangeable, on: :update, if: :system
validate :chat_preconditions
validate :allowed_seeded_model, if: :default_llm_id
validate :well_formated_examples
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
# leaves some room for growth but sets a maximum to avoid memory issues
# we may want to revisit this in the future
Expand Down Expand Up @@ -265,6 +266,7 @@ def class_instance
define_method(:top_p) { @ai_persona&.top_p }
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
define_method(:uploads) { @ai_persona&.uploads }
define_method(:examples) { @ai_persona&.examples }
end
end

Expand Down Expand Up @@ -343,6 +345,11 @@ def system_persona_unchangeable
new_format = response_format_change[1].map { |f| f["key"] }.to_set

errors.add(:base, error_msg) if old_format != new_format
elsif examples_changed?
old_examples = examples_change[0].flatten.to_set
new_examples = examples_change[1].flatten.to_set

errors.add(:base, error_msg) if old_examples != new_examples
end
end

Expand All @@ -363,6 +370,17 @@ def allowed_seeded_model

errors.add(:default_llm, I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"))
end

def well_formated_examples
return if examples.blank?

if examples.is_a?(Array) &&
examples.all? { |e| e.is_a?(Array) && e.length == 2 && e.all?(&:present?) }
return
end

errors.add(:examples, I18n.t("discourse_ai.personas.malformed_examples"))
end
end

# == Schema Information
Expand Down Expand Up @@ -401,6 +419,7 @@ def allowed_seeded_model
# default_llm_id :bigint
# question_consolidator_llm_id :bigint
# response_format :jsonb
# examples :jsonb
#
# Indexes
#
Expand Down
3 changes: 2 additions & 1 deletion app/serializers/localized_ai_persona_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
:allow_topic_mentions,
:allow_personal_messages,
:force_default_llm,
:response_format
:response_format,
:examples

has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
Expand Down
3 changes: 2 additions & 1 deletion assets/javascripts/discourse/admin/models/ai-persona.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const CREATE_ATTRIBUTES = [
"allow_chat_channel_mentions",
"allow_chat_direct_messages",
"response_format",
"examples",
];

const SYSTEM_ATTRIBUTES = [
Expand Down Expand Up @@ -61,7 +62,6 @@ const SYSTEM_ATTRIBUTES = [
"allow_topic_mentions",
"allow_chat_channel_mentions",
"allow_chat_direct_messages",
"response_format",
];

export default class AiPersona extends RestModel {
Expand Down Expand Up @@ -154,6 +154,7 @@ export default class AiPersona extends RestModel {
this.populateTools(attrs);
attrs.forced_tool_count = this.forced_tool_count || -1;
attrs.response_format = attrs.response_format || [];
attrs.examples = attrs.examples || [];

return attrs;
}
Expand Down
33 changes: 33 additions & 0 deletions assets/javascripts/discourse/components/ai-persona-editor.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import AdminUser from "admin/models/admin-user";
import GroupChooser from "select-kit/components/group-chooser";
import AiPersonaResponseFormatEditor from "../components/modal/ai-persona-response-format-editor";
import AiLlmSelector from "./ai-llm-selector";
import AiPersonaCollapsableExample from "./ai-persona-example";
import AiPersonaToolOptions from "./ai-persona-tool-options";
import AiToolSelector from "./ai-tool-selector";
import RagOptionsFk from "./rag-options-fk";
Expand Down Expand Up @@ -230,6 +231,12 @@ export default class PersonaEditor extends Component {
return this.allTools.filter((tool) => tools.includes(tool.id));
}

@action
addExamplesPair(form, data) {
const newExamples = [...data.examples, ["", ""]];
form.set("examples", newExamples);
}

mapToolOptions(currentOptions, toolNames) {
const updatedOptions = Object.assign({}, currentOptions);

Expand Down Expand Up @@ -422,6 +429,32 @@ export default class PersonaEditor extends Component {
</form.Field>
{{/unless}}

<form.Section
@title={{i18n "discourse_ai.ai_persona.examples.title"}}
@subtitle={{i18n "discourse_ai.ai_persona.examples.examples_help"}}
>
{{#unless data.system}}
<form.Container>
<form.Button
@action={{fn this.addExamplesPair form data}}
@label="discourse_ai.ai_persona.examples.new"
class="ai-persona-editor__new_example"
/>
</form.Container>
{{/unless}}

{{#if (gt data.examples.length 0)}}
<form.Collection @name="examples" as |exCollection exCollectionIdx|>
<AiPersonaCollapsableExample
@examplesCollection={{exCollection}}
@exampleNumber={{exCollectionIdx}}
@system={{data.system}}
@form={{form}}
/>
</form.Collection>
{{/if}}
</form.Section>

<form.Section @title={{i18n "discourse_ai.ai_persona.ai_tools"}}>
<form.Field
@name="tools"
Expand Down
67 changes: 67 additions & 0 deletions assets/javascripts/discourse/components/ai-persona-example.gjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { eq } from "truth-helpers";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";

export default class AiPersonaCollapsableExample extends Component {
@tracked collapsed = true;

get caretIcon() {
return this.collapsed ? "angle-right" : "angle-down";
}

@action
toggleExample() {
this.collapsed = !this.collapsed;
}

@action
deletePair() {
this.collapsed = true;
this.args.examplesCollection.remove(this.args.exampleNumber);
}

get exampleTitle() {
return i18n("discourse_ai.ai_persona.examples.collapsable_title", {
number: this.args.exampleNumber + 1,
});
}

<template>
<div role="button" {{on "click" this.toggleExample}}>
<span>{{icon this.caretIcon}}</span>
{{this.exampleTitle}}
</div>
{{#unless this.collapsed}}
<@examplesCollection.Collection as |exPair pairIdx|>
<exPair.Field
@title={{i18n
(concat
"discourse_ai.ai_persona.examples."
(if (eq pairIdx 0) "user" "model")
)
}}
@validation="required|length:1,100"
@disabled={{@system}}
as |field|
>
<field.Textarea />
</exPair.Field>
</@examplesCollection.Collection>

{{#unless @system}}
<@form.Container>
<@form.Button
@action={{this.deletePair}}
@label="discourse_ai.ai_persona.examples.remove"
class="ai-persona-editor__delete_example btn-danger"
/>
</@form.Container>
{{/unless}}
{{/unless}}
</template>
}
8 changes: 8 additions & 0 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,14 @@ en:
modal:
root_title: "Response structure"
key_title: "Key"
examples:
title: Examples
examples_help: Simulate previous interactions with the LLM and ground it to produce better result.
new: New example
remove: Delete example
collapsable_title: "Example #%{number}"
user: "User message"
model: "Model response"

list:
enabled: "AI Bot?"
Expand Down
3 changes: 3 additions & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@ en:
other: "We couldn't delete this model because %{settings} are using it. Update the settings and try again."
cannot_edit_builtin: "You can't edit a built-in model."

personas:
malformed_examples: "The given examples have the wrong format."

embeddings:
delete_failed: "This model is currently in use. Update the `ai embeddings selected model` first."
cannot_edit_builtin: "You can't edit a built-in model."
Expand Down
2 changes: 2 additions & 0 deletions db/fixtures/personas/603_ai_personas.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def from_setting(setting_name)

persona.response_format = instance.response_format

persona.examples = instance.examples

persona.system_prompt = instance.system_prompt
persona.top_p = instance.top_p
persona.temperature = instance.temperature
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20250508154953_add_examples_to_personas.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddExamplesToPersonas < ActiveRecord::Migration[7.2]
def change
add_column :ai_personas, :examples, :jsonb
end
end
30 changes: 24 additions & 6 deletions lib/personas/persona.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ def response_format
nil
end

def examples
[]
end

def available_tools
self
.class
Expand All @@ -173,11 +177,7 @@ def available_tools
end

def craft_prompt(context, llm: nil)
system_insts =
system_prompt.gsub(/\{(\w+)\}/) do |match|
found = context.lookup_template_param(match[1..-2])
found.nil? ? match : found.to_s
end
system_insts = replace_placeholders(system_prompt, context)

prompt_insts = <<~TEXT.strip
#{system_insts}
Expand Down Expand Up @@ -206,10 +206,21 @@ def craft_prompt(context, llm: nil)

prompt_insts << fragments_guidance if fragments_guidance.present?

post_system_examples = []

if examples.present?
examples.flatten.each_with_index do |e, idx|
post_system_examples << {
content: replace_placeholders(e, context),
type: (idx + 1).odd? ? :user : :model,
}
end
end

prompt =
DiscourseAi::Completions::Prompt.new(
prompt_insts,
messages: context.messages,
messages: post_system_examples.concat(context.messages),
topic_id: context.topic_id,
post_id: context.post_id,
)
Expand Down Expand Up @@ -239,6 +250,13 @@ def allow_partial_tool_calls?

protected

def replace_placeholders(content, context)
content.gsub(/\{(\w+)\}/) do |match|
found = context.lookup_template_param(match[1..-2])
found.nil? ? match : found.to_s
end
end

def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:)
function_id = tool_call.id
function_name = tool_call.name
Expand Down
9 changes: 9 additions & 0 deletions lib/personas/summarizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ def system_prompt
def response_format
[{ key: "summary", type: "string" }]
end

def examples
[
[
"Here are the posts inside <input></input> XML tags:\n\n<input>1) user1 said: I love Mondays 2) user2 said: I hate Mondays</input>\n\nGenerate a concise, coherent summary of the text above maintaining the original language.",
"Two users are sharing their feelings toward Mondays. [user1]({resource_url}/1) hates them, while [user2]({resource_url}/2) loves them.",
],
]
end
end
end
end
17 changes: 1 addition & 16 deletions lib/summarization/strategies/topic_summary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,7 @@ def as_llm_messages(contents)
input =
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join

messages = []
messages << {
type: :user,
content:
"Here are the posts inside <input></input> XML tags:\n\n<input>1) user1 said: I love Mondays 2) user2 said: I hate Mondays</input>\n\nGenerate a concise, coherent summary of the text above maintaining the original language.",
}

messages << {
type: :model,
content:
"Two users are sharing their feelings toward Mondays. [user1](#{resource_path}/1) hates them, while [user2](#{resource_path}/2) loves them.",
}

messages << { type: :user, content: <<~TEXT.strip }
[{ type: :user, content: <<~TEXT.strip }]
#{content_title.present? ? "The discussion title is: " + content_title + ".\n" : ""}
Here are the posts, inside <input></input> XML tags:

Expand All @@ -67,8 +54,6 @@ def as_llm_messages(contents)

Generate a concise, coherent summary of the text above maintaining the original language.
TEXT

messages
end

private
Expand Down
Loading
Loading