Skip to content

Commit b689f5e

Browse files
committed
FEATURE: Examples support for personas.
Examples simulate previous interactions with an LLM and come right after the system prompt. This helps grounding the model and producing better responses.
1 parent cf45e68 commit b689f5e

File tree

16 files changed

+301
-90
lines changed

16 files changed

+301
-90
lines changed

app/controllers/discourse_ai/admin/ai_personas_controller.rb

+10
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ def ai_persona_params
225225
permitted[:response_format] = permit_response_format(response_format)
226226
end
227227

228+
if examples = params.dig(:ai_persona, :examples)
229+
permitted[:examples] = permit_examples(examples)
230+
end
231+
228232
permitted
229233
end
230234

@@ -251,6 +255,12 @@ def permit_response_format(response_format)
251255
end
252256
end
253257
end
258+
259+
def permit_examples(examples)
260+
return [] if !examples.is_a?(Array)
261+
262+
examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
263+
end
254264
end
255265
end
256266
end

app/models/ai_persona.rb

+19
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class AiPersona < ActiveRecord::Base
1313
validate :system_persona_unchangeable, on: :update, if: :system
1414
validate :chat_preconditions
1515
validate :allowed_seeded_model, if: :default_llm_id
16+
validate :well_formated_examples
1617
validates :max_context_posts, numericality: { greater_than: 0 }, allow_nil: true
1718
# leaves some room for growth but sets a maximum to avoid memory issues
1819
# we may want to revisit this in the future
@@ -265,6 +266,7 @@ def class_instance
265266
define_method(:top_p) { @ai_persona&.top_p }
266267
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
267268
define_method(:uploads) { @ai_persona&.uploads }
269+
define_method(:examples) { @ai_persona&.examples }
268270
end
269271
end
270272

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

345347
errors.add(:base, error_msg) if old_format != new_format
348+
elsif examples_changed?
349+
old_examples = examples_change[0].flatten.to_set
350+
new_examples = examples_change[1].flatten.to_set
351+
352+
errors.add(:base, error_msg) if old_examples != new_examples
346353
end
347354
end
348355

@@ -363,6 +370,17 @@ def allowed_seeded_model
363370

364371
errors.add(:default_llm, I18n.t("discourse_ai.llm.configuration.invalid_seeded_model"))
365372
end
373+
374+
def well_formated_examples
375+
return if examples.blank?
376+
377+
if examples.is_a?(Array) &&
378+
examples.all? { |e| e.is_a?(Array) && e.length == 2 && e.all?(&:present?) }
379+
return
380+
end
381+
382+
errors.add(:examples, I18n.t("discourse_ai.personas.malformed_examples"))
383+
end
366384
end
367385

368386
# == Schema Information
@@ -401,6 +419,7 @@ def allowed_seeded_model
401419
# default_llm_id :bigint
402420
# question_consolidator_llm_id :bigint
403421
# response_format :jsonb
422+
# examples :jsonb
404423
#
405424
# Indexes
406425
#

app/serializers/localized_ai_persona_serializer.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
3131
:allow_topic_mentions,
3232
:allow_personal_messages,
3333
:force_default_llm,
34-
:response_format
34+
:response_format,
35+
:examples
3536

3637
has_one :user, serializer: BasicUserSerializer, embed: :object
3738
has_many :rag_uploads, serializer: UploadSerializer, embed: :object

assets/javascripts/discourse/admin/models/ai-persona.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const CREATE_ATTRIBUTES = [
3434
"allow_chat_channel_mentions",
3535
"allow_chat_direct_messages",
3636
"response_format",
37+
"examples",
3738
];
3839

3940
const SYSTEM_ATTRIBUTES = [
@@ -61,7 +62,6 @@ const SYSTEM_ATTRIBUTES = [
6162
"allow_topic_mentions",
6263
"allow_chat_channel_mentions",
6364
"allow_chat_direct_messages",
64-
"response_format",
6565
];
6666

6767
export default class AiPersona extends RestModel {
@@ -154,6 +154,7 @@ export default class AiPersona extends RestModel {
154154
this.populateTools(attrs);
155155
attrs.forced_tool_count = this.forced_tool_count || -1;
156156
attrs.response_format = attrs.response_format || [];
157+
attrs.examples = attrs.examples || [];
157158

158159
return attrs;
159160
}

assets/javascripts/discourse/components/ai-persona-editor.gjs

+39
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import AdminUser from "admin/models/admin-user";
1717
import GroupChooser from "select-kit/components/group-chooser";
1818
import AiPersonaResponseFormatEditor from "../components/modal/ai-persona-response-format-editor";
1919
import AiLlmSelector from "./ai-llm-selector";
20+
import AiPersonaCollapsableExample from "./ai-persona-example";
2021
import AiPersonaToolOptions from "./ai-persona-tool-options";
2122
import AiToolSelector from "./ai-tool-selector";
2223
import RagOptionsFk from "./rag-options-fk";
@@ -230,6 +231,18 @@ export default class PersonaEditor extends Component {
230231
return this.allTools.filter((tool) => tools.includes(tool.id));
231232
}
232233

234+
@action
235+
addExamplesPair(form, data) {
236+
const newExamples = [...data.examples, ["", ""]];
237+
form.set("examples", newExamples);
238+
}
239+
240+
@action
241+
removeExamplesPair(form, data, idxToRemove) {
242+
const updatedExamples = data.examples.toSpliced(idxToRemove, 1);
243+
form.set("examples", updatedExamples);
244+
}
245+
233246
mapToolOptions(currentOptions, toolNames) {
234247
const updatedOptions = Object.assign({}, currentOptions);
235248

@@ -422,6 +435,32 @@ export default class PersonaEditor extends Component {
422435
</form.Field>
423436
{{/unless}}
424437

438+
<form.Section
439+
@title={{i18n "discourse_ai.ai_persona.examples.title"}}
440+
@subtitle={{i18n "discourse_ai.ai_persona.examples.examples_help"}}
441+
>
442+
{{#unless data.system}}
443+
<form.Container>
444+
<form.Button
445+
@action={{fn this.addExamplesPair form data}}
446+
@label="discourse_ai.ai_persona.examples.new"
447+
class="ai-persona-editor__new_example"
448+
/>
449+
</form.Container>
450+
{{/unless}}
451+
452+
{{#if (gt data.examples.length 0)}}
453+
<form.Collection @name="examples" as |exCollection exCollectionIdx|>
454+
<AiPersonaCollapsableExample
455+
@examplesCollection={{exCollection}}
456+
@exampleNumber={{exCollectionIdx}}
457+
@system={{data.system}}
458+
@form={{form}}
459+
/>
460+
</form.Collection>
461+
{{/if}}
462+
</form.Section>
463+
425464
<form.Section @title={{i18n "discourse_ai.ai_persona.ai_tools"}}>
426465
<form.Field
427466
@name="tools"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Component from "@glimmer/component";
2+
import { tracked } from "@glimmer/tracking";
3+
import { concat } from "@ember/helper";
4+
import { on } from "@ember/modifier";
5+
import { action } from "@ember/object";
6+
import { eq } from "truth-helpers";
7+
import icon from "discourse/helpers/d-icon";
8+
import { i18n } from "discourse-i18n";
9+
10+
export default class AiPersonaCollapsableExample extends Component {
11+
@tracked collapsed = true;
12+
13+
get caretIcon() {
14+
return this.collapsed ? "angle-right" : "angle-down";
15+
}
16+
17+
@action
18+
toggleExample() {
19+
this.collapsed = !this.collapsed;
20+
}
21+
22+
@action
23+
deletePair() {
24+
this.collapsed = true;
25+
this.args.examplesCollection.remove(this.args.exampleNumber);
26+
}
27+
28+
get exampleTitle() {
29+
return i18n("discourse_ai.ai_persona.examples.collapsable_title", {
30+
number: this.args.exampleNumber + 1,
31+
});
32+
}
33+
34+
<template>
35+
<div role="button" {{on "click" this.toggleExample}}>
36+
<span>{{icon this.caretIcon}}</span>
37+
{{this.exampleTitle}}
38+
</div>
39+
{{#unless this.collapsed}}
40+
<@examplesCollection.Collection as |exPair pairIdx|>
41+
<exPair.Field
42+
@title={{i18n
43+
(concat
44+
"discourse_ai.ai_persona.examples."
45+
(if (eq pairIdx 0) "user" "model")
46+
)
47+
}}
48+
@validation="required|length:1,100"
49+
@disabled={{@system}}
50+
as |field|
51+
>
52+
<field.Textarea />
53+
</exPair.Field>
54+
</@examplesCollection.Collection>
55+
56+
{{#unless @system}}
57+
<@form.Container>
58+
<@form.Button
59+
@action={{this.deletePair}}
60+
@label="discourse_ai.ai_persona.examples.remove"
61+
class="ai-persona-editor__delete_example btn-danger"
62+
/>
63+
</@form.Container>
64+
{{/unless}}
65+
{{/unless}}
66+
</template>
67+
}

config/locales/client.en.yml

+8
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ en:
330330
modal:
331331
root_title: "Response structure"
332332
key_title: "Key"
333+
examples:
334+
title: Examples
335+
examples_help: Simulate previous interactions with the LLM and ground it to produce better result.
336+
new: New example
337+
remove: Delete example
338+
collapsable_title: "Example #%{number}"
339+
user: "User message"
340+
model: "Model response"
333341

334342
list:
335343
enabled: "AI Bot?"

config/locales/server.en.yml

+3
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,9 @@ en:
495495
other: "We couldn't delete this model because %{settings} are using it. Update the settings and try again."
496496
cannot_edit_builtin: "You can't edit a built-in model."
497497

498+
personas:
499+
malformed_examples: "The given examples have the wrong format."
500+
498501
embeddings:
499502
delete_failed: "This model is currently in use. Update the `ai embeddings selected model` first."
500503
cannot_edit_builtin: "You can't edit a built-in model."

db/fixtures/personas/603_ai_personas.rb

+2
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ def from_setting(setting_name)
7474

7575
persona.response_format = instance.response_format
7676

77+
persona.examples = instance.examples
78+
7779
persona.system_prompt = instance.system_prompt
7880
persona.top_p = instance.top_p
7981
persona.temperature = instance.temperature
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class AddExamplesToPersonas < ActiveRecord::Migration[7.2]
4+
def change
5+
add_column :ai_personas, :examples, :jsonb
6+
end
7+
end

lib/personas/persona.rb

+24-6
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ def response_format
164164
nil
165165
end
166166

167+
def examples
168+
[]
169+
end
170+
167171
def available_tools
168172
self
169173
.class
@@ -173,11 +177,7 @@ def available_tools
173177
end
174178

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

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

207207
prompt_insts << fragments_guidance if fragments_guidance.present?
208208

209+
post_system_examples = []
210+
211+
if examples.present?
212+
examples.flatten.each_with_index do |e, idx|
213+
post_system_examples << {
214+
content: replace_placeholders(e, context),
215+
type: (idx + 1).odd? ? :user : :model,
216+
}
217+
end
218+
end
219+
209220
prompt =
210221
DiscourseAi::Completions::Prompt.new(
211222
prompt_insts,
212-
messages: context.messages,
223+
messages: post_system_examples.concat(context.messages),
213224
topic_id: context.topic_id,
214225
post_id: context.post_id,
215226
)
@@ -239,6 +250,13 @@ def allow_partial_tool_calls?
239250

240251
protected
241252

253+
def replace_placeholders(content, context)
254+
content.gsub(/\{(\w+)\}/) do |match|
255+
found = context.lookup_template_param(match[1..-2])
256+
found.nil? ? match : found.to_s
257+
end
258+
end
259+
242260
def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:)
243261
function_id = tool_call.id
244262
function_name = tool_call.name

lib/personas/summarizer.rb

+9
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ def system_prompt
3232
def response_format
3333
[{ key: "summary", type: "string" }]
3434
end
35+
36+
def examples
37+
[
38+
[
39+
"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.",
40+
"Two users are sharing their feelings toward Mondays. [user1]({resource_url}/1) hates them, while [user2]({resource_url}/2) loves them.",
41+
],
42+
]
43+
end
3544
end
3645
end
3746
end

lib/summarization/strategies/topic_summary.rb

+1-16
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,7 @@ def as_llm_messages(contents)
4444
input =
4545
contents.map { |item| "(#{item[:id]} #{item[:poster]} said: #{item[:text]} " }.join
4646

47-
messages = []
48-
messages << {
49-
type: :user,
50-
content:
51-
"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.",
52-
}
53-
54-
messages << {
55-
type: :model,
56-
content:
57-
"Two users are sharing their feelings toward Mondays. [user1](#{resource_path}/1) hates them, while [user2](#{resource_path}/2) loves them.",
58-
}
59-
60-
messages << { type: :user, content: <<~TEXT.strip }
47+
[{ type: :user, content: <<~TEXT.strip }]
6148
#{content_title.present? ? "The discussion title is: " + content_title + ".\n" : ""}
6249
Here are the posts, inside <input></input> XML tags:
6350
@@ -67,8 +54,6 @@ def as_llm_messages(contents)
6754
6855
Generate a concise, coherent summary of the text above maintaining the original language.
6956
TEXT
70-
71-
messages
7257
end
7358

7459
private

0 commit comments

Comments
 (0)