From 0338dbea23f0471ab23f3d72fb1ca4701cf36265 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 27 May 2025 10:37:30 -0300 Subject: [PATCH] FEATURE: Use different personas to power AI helper features. You can now edit each AI helper prompt individually through personas, limit access to specific groups, set different LLMs, etc. --- .../ai_helper/assistant_controller.rb | 34 +- app/jobs/regular/stream_composer_helper.rb | 9 +- app/jobs/regular/stream_post_helper.rb | 17 +- .../topics_locale_detection_backfill.rb | 1 + .../components/ai-composer-helper-menu.gjs | 6 +- .../components/ai-helper-options-list.gjs | 3 +- .../components/ai-post-helper-menu.gjs | 4 +- .../ai-edit-suggestion-button.gjs | 2 +- assets/javascripts/initializers/ai-helper.js | 2 +- config/locales/server.en.yml | 27 ++ config/settings.yml | 42 +- .../ai_helper/603_completion_prompts.rb | 269 ------------ db/fixtures/personas/603_ai_personas.rb | 17 +- evals/lib/eval.rb | 5 +- lib/ai_helper/assistant.rb | 386 +++++++++++------- lib/completions/endpoints/canned_response.rb | 12 +- lib/configuration/llm_dependency_validator.rb | 2 +- lib/personas/bot_context.rb | 13 +- lib/personas/custom_prompt.rb | 22 + lib/personas/image_captioner.rb | 19 + lib/personas/markdown_table_generator.rb | 59 +++ lib/personas/persona.rb | 22 +- lib/personas/post_illustrator.rb | 22 + lib/personas/proofreader.rb | 72 ++++ lib/personas/smart_dates.rb | 63 +++ lib/personas/titles_generator.rb | 39 ++ lib/personas/translator.rb | 31 ++ lib/personas/tutor.rb | 30 ++ .../regular/stream_composer_helper_spec.rb | 17 +- spec/jobs/regular/stream_post_helper_spec.rb | 24 +- spec/lib/modules/ai_helper/assistant_spec.rb | 74 ++-- spec/models/completion_prompt_spec.rb | 80 ---- spec/plugin_spec.rb | 11 +- .../ai_helper/assistant_controller_spec.rb | 18 +- .../ai_helper/ai_composer_helper_spec.rb | 18 +- spec/system/ai_helper/ai_post_helper_spec.rb | 2 +- .../ai_split_topic_suggestion_spec.rb | 2 +- .../javascripts/fixtures/ai-helper-prompts.js | 15 - 38 files changed, 828 insertions(+), 663 deletions(-) delete mode 100644 db/fixtures/ai_helper/603_completion_prompts.rb create mode 100644 lib/personas/custom_prompt.rb create mode 100644 lib/personas/image_captioner.rb create mode 100644 lib/personas/markdown_table_generator.rb create mode 100644 lib/personas/post_illustrator.rb create mode 100644 lib/personas/proofreader.rb create mode 100644 lib/personas/smart_dates.rb create mode 100644 lib/personas/titles_generator.rb create mode 100644 lib/personas/translator.rb create mode 100644 lib/personas/tutor.rb delete mode 100644 spec/models/completion_prompt_spec.rb diff --git a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb index 9f3a1baa..4558c5d6 100644 --- a/app/controllers/discourse_ai/ai_helper/assistant_controller.rb +++ b/app/controllers/discourse_ai/ai_helper/assistant_controller.rb @@ -25,25 +25,24 @@ module DiscourseAi input = get_text_param! 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 prompt.id == CompletionPrompt::CUSTOM_PROMPT + if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank? - - prompt.custom_instruction = params[:custom_prompt] 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 render json: DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt( - prompt, + params[:mode], input, current_user, force_default_locale: force_default_locale, + custom_prompt: params[:custom_prompt], ), status: 200 end @@ -60,13 +59,10 @@ module DiscourseAi input = get_text_param! end - prompt = CompletionPrompt.enabled_by_name("generate_titles") - raise Discourse::InvalidParameters.new(:mode) if !prompt - hijack do render json: DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt( - prompt, + DiscourseAi::AiHelper::Assistant::GENERATE_TITLES, input, current_user, ), @@ -115,12 +111,12 @@ module DiscourseAi location = params[: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? - return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST - - if prompt.id == CompletionPrompt::CUSTOM_PROMPT + if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank? end @@ -133,7 +129,7 @@ module DiscourseAi :stream_composer_helper, user_id: current_user.id, text: text, - prompt: prompt.name, + prompt: params[:mode], custom_prompt: params[:custom_prompt], force_default_locale: params[:force_default_locale] || false, client_id: params[:client_id], @@ -149,7 +145,7 @@ module DiscourseAi post_id: post.id, user_id: current_user.id, text: text, - prompt: prompt.name, + prompt: params[:mode], custom_prompt: params[:custom_prompt], client_id: params[:client_id], ) diff --git a/app/jobs/regular/stream_composer_helper.rb b/app/jobs/regular/stream_composer_helper.rb index c3066c32..ae3c5017 100644 --- a/app/jobs/regular/stream_composer_helper.rb +++ b/app/jobs/regular/stream_composer_helper.rb @@ -10,19 +10,16 @@ module Jobs return unless args[:text] return unless args[:client_id] - prompt = CompletionPrompt.enabled_by_name(args[:prompt]) - - if prompt.id == CompletionPrompt::CUSTOM_PROMPT - prompt.custom_instruction = args[:custom_prompt] - end + helper_mode = args[:prompt] DiscourseAi::AiHelper::Assistant.new.stream_prompt( - prompt, + helper_mode, args[:text], user, "/discourse-ai/ai-helper/stream_composer_suggestion", force_default_locale: args[:force_default_locale], client_id: args[:client_id], + custom_prompt: args[:custom_prompt], ) end end diff --git a/app/jobs/regular/stream_post_helper.rb b/app/jobs/regular/stream_post_helper.rb index 9272e295..56a3149f 100644 --- a/app/jobs/regular/stream_post_helper.rb +++ b/app/jobs/regular/stream_post_helper.rb @@ -14,16 +14,12 @@ module Jobs return unless user.guardian.can_see?(post) - prompt = CompletionPrompt.enabled_by_name(args[:prompt]) + helper_mode = args[:prompt] - if prompt.id == CompletionPrompt::CUSTOM_PROMPT - prompt.custom_instruction = args[:custom_prompt] - end - - if prompt.name == "explain" - input = <<~TEXT - #{args[:text]} - #{post.raw} + if helper_mode == DiscourseAi::AiHelper::Assistant::EXPLAIN + input = <<~TEXT.strip + #{args[:text]} + #{post.raw} #{topic.title} #{reply_to ? "#{reply_to.raw}" : nil} TEXT @@ -32,10 +28,11 @@ module Jobs end DiscourseAi::AiHelper::Assistant.new.stream_prompt( - prompt, + helper_mode, input, user, "/discourse-ai/ai-helper/stream_suggestion/#{post.id}", + custom_prompt: args[:custom_prompt], ) end end diff --git a/app/jobs/scheduled/topics_locale_detection_backfill.rb b/app/jobs/scheduled/topics_locale_detection_backfill.rb index 6cb3b733..075c36c1 100644 --- a/app/jobs/scheduled/topics_locale_detection_backfill.rb +++ b/app/jobs/scheduled/topics_locale_detection_backfill.rb @@ -10,6 +10,7 @@ module Jobs return if !SiteSetting.discourse_ai_enabled return if !SiteSetting.ai_translation_enabled limit = SiteSetting.ai_translation_backfill_rate + return if limit == 0 topics = Topic.where(locale: nil, deleted_at: nil).where("topics.user_id > 0") diff --git a/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs index 51d9efd8..cb1aa2e4 100644 --- a/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs +++ b/assets/javascripts/discourse/components/ai-composer-helper-menu.gjs @@ -73,7 +73,7 @@ export default class AiComposerHelperMenu extends Component { } prompts.forEach((p) => { - this.prompts[p.id] = p; + this.prompts[p.name] = p; }); this.promptTypes = prompts.reduce((memo, p) => { @@ -116,7 +116,7 @@ export default class AiComposerHelperMenu extends Component { if (option.name === "illustrate_post") { return this.modal.show(ThumbnailSuggestion, { model: { - mode: option.id, + mode: option.name, selectedText: this.args.data.selectedText, thumbnails: this.thumbnailSuggestions, }, @@ -128,7 +128,7 @@ export default class AiComposerHelperMenu extends Component { return this.modal.show(ModalDiffModal, { model: { - mode: option.id, + mode: option.name, selectedText: this.args.data.selectedText, revert: this.undoAiAction, toolbarEvent: this.args.data.toolbarEvent, diff --git a/assets/javascripts/discourse/components/ai-helper-options-list.gjs b/assets/javascripts/discourse/components/ai-helper-options-list.gjs index 30bd10c0..eb1ce792 100644 --- a/assets/javascripts/discourse/components/ai-helper-options-list.gjs +++ b/assets/javascripts/discourse/components/ai-helper-options-list.gjs @@ -29,13 +29,12 @@ export default class AiHelperOptionsList extends Component { @submit={{@performAction}} /> {{else}} -
  • +
  • {{#if (and (eq option.name "proofread") this.showShortcut)}} diff --git a/assets/javascripts/discourse/components/ai-post-helper-menu.gjs b/assets/javascripts/discourse/components/ai-post-helper-menu.gjs index 52c34f82..898edb67 100644 --- a/assets/javascripts/discourse/components/ai-post-helper-menu.gjs +++ b/assets/javascripts/discourse/components/ai-post-helper-menu.gjs @@ -202,7 +202,7 @@ export default class AiPostHelperMenu extends Component { this._activeAiRequest = ajax("/discourse-ai/ai-helper/suggest", { method: "POST", data: { - mode: option.id, + mode: option.name, text: this.args.data.quoteState.buffer, custom_prompt: this.customPromptValue, }, @@ -238,7 +238,7 @@ export default class AiPostHelperMenu extends Component { method: "POST", data: { location: "post", - mode: option.id, + mode: option.name, text: this.args.data.selectedText, post_id: this.args.data.quoteState.postId, custom_prompt: this.customPromptValue, diff --git a/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs b/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs index 1d3b820e..4da354cf 100644 --- a/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs +++ b/assets/javascripts/discourse/connectors/fast-edit-footer-after/ai-edit-suggestion-button.gjs @@ -34,7 +34,7 @@ export default class AiEditSuggestionButton extends Component { this._activeAIRequest = ajax("/discourse-ai/ai-helper/suggest", { method: "POST", data: { - mode: this.mode.id, + mode: this.mode.name, text: this.args.outletArgs.initialValue, custom_prompt: "", }, diff --git a/assets/javascripts/initializers/ai-helper.js b/assets/javascripts/initializers/ai-helper.js index f949cf13..0ca6b78f 100644 --- a/assets/javascripts/initializers/ai-helper.js +++ b/assets/javascripts/initializers/ai-helper.js @@ -44,7 +44,7 @@ function initializeAiHelperTrigger(api) { const mode = currentUser?.ai_helper_prompts.find( (p) => p.name === "proofread" - ).id; + ).name; modal.show(ModalDiffModal, { model: { diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index f8a8f940..f321dcb7 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -339,6 +339,33 @@ en: concept_deduplicator: name: "Concept Deduplicator" description: "AI Bot specialized in deduplicating concepts" + custom_prompt: + name: "Custom Prompt" + description: "Default persona powering the AI helper's custom prompt feature" + smart_dates: + name: "Smart Dates" + description: "Default persona powering the AI helper's smart dates feature" + markdown_table_generator: + name: "Markdown Table Generator" + description: "Default persona powering the AI helper's generate Markdown table feature" + post_illustrator: + name: "Post Illustrator" + description: "Generates StableDiffusion prompts to power the AI helper's illustrate post feature" + proofreader: + name: "Proofreader" + description: "Default persona powering the AI helper's proofread text feature" + titles_generator: + name: "Titles Generator" + description: "Default persona powering the AI helper's suggest topic titles feature" + tutor: + name: "Tutor" + description: "Default persona powering the AI helper's explain feature" + translator: + name: "Translator" + description: "Default persona powering the AI helper's translator feature" + image_captioner: + name: "Image Captions" + description: "Default persona powering the AI helper's image caption feature" topic_not_found: "Summary unavailable, topic not found!" summarizing: "Summarizing topic" searching: "Searching for: '%{query}'" diff --git a/config/settings.yml b/config/settings.yml index 6ab578da..071857fe 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -104,13 +104,14 @@ discourse_ai: allow_any: false type: enum enum: "DiscourseAi::Configuration::LlmEnumerator" - validator: "DiscourseAi::Configuration::LlmValidator" - ai_helper_custom_prompts_allowed_groups: + hidden: true + ai_helper_custom_prompts_allowed_groups: # Deprecated. TODO(roman): Remove 2025-09-01 type: group_list list_type: compact default: "3" # 3: @staff allow_any: false refresh: true + hidden: true post_ai_helper_allowed_groups: type: group_list list_type: compact @@ -143,6 +144,7 @@ discourse_ai: default: "" type: enum enum: "DiscourseAi::Configuration::LlmVisionEnumerator" + hidden: true ai_auto_image_caption_allowed_groups: client: true type: group_list @@ -160,6 +162,42 @@ discourse_ai: hidden: true type: list list_type: compact + ai_helper_proofreader_persona: + default: "-22" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_tittle_suggestions_persona: + default: "-23" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_explain_persona: + default: "-24" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_post_illustrator_persona: + default: "-21" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_smart_dates_persona: + default: "-19" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_translator_persona: + default: "-25" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_markdown_tables_persona: + default: "-20" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_custom_prompt_persona: + default: "-18" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" + ai_helper_image_caption_persona: + default: "-26" + type: enum + enum: "DiscourseAi::Configuration::PersonaEnumerator" ai_embeddings_enabled: default: false diff --git a/db/fixtures/ai_helper/603_completion_prompts.rb b/db/fixtures/ai_helper/603_completion_prompts.rb deleted file mode 100644 index bab854b1..00000000 --- a/db/fixtures/ai_helper/603_completion_prompts.rb +++ /dev/null @@ -1,269 +0,0 @@ -# frozen_string_literal: true -CompletionPrompt.seed do |cp| - cp.id = -301 - cp.name = "translate" - cp.prompt_type = CompletionPrompt.prompt_types[:text] - cp.stop_sequences = ["\n", ""] - cp.temperature = 0.2 - cp.messages = { - insts: <<~TEXT, - I want you to act as an %LANGUAGE% translator, spelling corrector and improver. I will write to you - in any language and you will detect the language, translate it and answer in the corrected and - improved version of my text, in %LANGUAGE%. I want you to replace my simplified A0-level words and - sentences with more beautiful and elegant, upper level %LANGUAGE% words and sentences. - Keep the meaning same, but make them more literary. I want you to only reply the correction, - the improvements and nothing else, do not write explanations. - You will find the text between XML tags. - Include your translation between XML tags. - TEXT - examples: [["Hello", "...%LANGUAGE% translation..."]], - } -end - -CompletionPrompt.seed do |cp| - cp.id = -303 - cp.name = "proofread" - cp.prompt_type = CompletionPrompt.prompt_types[:diff] - cp.temperature = 0 - cp.stop_sequences = ["\n"] - cp.messages = { - insts: <<~TEXT, - You are a markdown proofreader. You correct egregious typos and phrasing issues but keep the user's original voice. - You do not touch code blocks. I will provide you with text to proofread. If nothing needs fixing, then you will echo the text back. - You will find the text between XML tags. - You will ALWAYS return the corrected text between XML tags. - TEXT - examples: [ - [ - "![amazing car|100x100, 22%](upload://hapy.png)", - "![Amazing car|100x100, 22%](upload://hapy.png)", - ], - [<<~TEXT, "The rain in Spain, stays mainly in the Plane."], - - The rain in spain stays mainly in the plane. - - TEXT - [ - "The rain in Spain, stays mainly in the Plane.", - "The rain in Spain, stays mainly in the Plane.", - ], - [<<~TEXT, <<~TEXT], - - Hello, - - Sometimes the logo isn't changing automatically when color scheme changes. - - ![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov) - - TEXT - - Hello, - Sometimes the logo does not change automatically when the color scheme changes. - ![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov) - - TEXT - [<<~TEXT, <<~TEXT], - - Any ideas what is wrong with this peace of cod? - > This quot contains a typo - ```ruby - # this has speling mistakes - testin.atypo = 11 - baad = "bad" - ``` - - TEXT - - Any ideas what is wrong with this piece of code? - > This quot contains a typo - ```ruby - # This has spelling mistakes - testing.a_typo = 11 - bad = "bad" - ``` - - TEXT - ], - } -end - -CompletionPrompt.seed do |cp| - cp.id = -304 - cp.name = "markdown_table" - cp.prompt_type = CompletionPrompt.prompt_types[:diff] - cp.temperature = 0.5 - cp.stop_sequences = ["\n"] - cp.messages = { - insts: <<~TEXT, - You are a markdown table formatter, I will provide you text inside XML tags and you will format it into a markdown table - TEXT - examples: [ - ["sam,joe,jane\nage: 22| 10|11", <<~TEXT], - - | | sam | joe | jane | - |---|---|---|---| - | age | 22 | 10 | 11 | - - TEXT - [<<~TEXT, <<~TEXT], - - sam: speed 100, age 22 - jane: age 10 - fred: height 22 - - TEXT - - | | speed | age | height | - |---|---|---|---| - | sam | 100 | 22 | - | - | jane | - | 10 | - | - | fred | - | - | 22 | - - TEXT - [<<~TEXT, <<~TEXT], - - chrome 22ms (first load 10ms) - firefox 10ms (first load: 9ms) - - TEXT - - | Browser | Load Time (ms) | First Load Time (ms) | - |---|---|---| - | Chrome | 22 | 10 | - | Firefox | 10 | 9 | - - TEXT - ], - } -end - -CompletionPrompt.seed do |cp| - cp.id = -305 - cp.name = "custom_prompt" - cp.prompt_type = CompletionPrompt.prompt_types[:diff] - cp.messages = { insts: <<~TEXT } - You are a helpful assistant. I will give you instructions inside XML tags. - You will look at them and reply with a result. - TEXT -end - -CompletionPrompt.seed do |cp| - cp.id = -306 - cp.name = "explain" - cp.prompt_type = CompletionPrompt.prompt_types[:text] - cp.messages = { insts: <<~TEXT } - You are a tutor explaining a term to a student in a specific context. - - I will provide everything you need to know inside tags, which consists of the term I want you - to explain inside tags, the context of where it was used inside tags, the title of - the topic where it was used inside tags, and optionally, the previous post in the conversation - in tags. - - Using all this information, write a paragraph with a brief explanation - of what the term means. Format the response using Markdown. Reply only with the explanation and - nothing more. - TEXT -end - -CompletionPrompt.seed do |cp| - cp.id = -307 - cp.name = "generate_titles" - cp.prompt_type = CompletionPrompt.prompt_types[:list] - cp.messages = { - insts: <<~TEXT, - I want you to act as a title generator for written pieces. I will provide you with a text, - and you will generate five titles. Please keep the title concise and under 20 words, - and ensure that the meaning is maintained. Replies will utilize the language type of the topic. - I want you to only reply the list of options and nothing else, do not write explanations. - Never ever use colons in the title. Always use sentence case, using a capital letter at - the start of the title, never start the title with a lower case letter. Proper nouns in the title - can have a capital letter, and acronyms like LLM can use capital letters. Format some titles - as questions, some as statements. Make sure to use question marks if the title is a question. - Each title you generate must be separated by * - You will find the text between XML tags. - TEXT - examples: [ - [ - "In the labyrinth of time, a solitary horse, etched in gold by the setting sun, embarked on an infinite journey.", - "The solitary horseThe horse etched in goldA horse's infinite journeyA horse lost in timeA horse's last ride", - ], - ], - post_insts: "Wrap each title between XML tags.", - } -end - -CompletionPrompt.seed do |cp| - cp.id = -308 - cp.name = "illustrate_post" - cp.prompt_type = CompletionPrompt.prompt_types[:list] - cp.messages = {} -end - -CompletionPrompt.seed do |cp| - cp.id = -309 - cp.name = "detect_text_locale" - cp.prompt_type = CompletionPrompt.prompt_types[:text] - cp.messages = { - insts: <<~TEXT, - I want you to act as a language expert, determining the locale for a set of text. - The locale is a language identifier, such as "en" for English, "de" for German, etc, - and can also include a region identifier, such as "en-GB" for British English, or "zh-Hans" for Simplified Chinese. - I will provide you with text, and you will determine the locale of the text. - You will find the text between XML tags. - Include your locale between XML tags. - TEXT - examples: [["Hello my favourite colour is red", "en-GB"]], - } -end - -CompletionPrompt.seed do |cp| - cp.id = -310 - cp.name = "replace_dates" - cp.prompt_type = CompletionPrompt.prompt_types[:diff] - cp.temperature = 0 - cp.stop_sequences = ["\n"] - cp.messages = { - insts: <<~TEXT, - You are a date and time formatter for Discourse posts. Convert natural language time references into date placeholders. - Do not modify any markdown, code blocks, or existing date formats. - - Here's the temporal context: - {{temporal_context}} - - Available date placeholder formats: - - Simple day without time: {{date:1}} for tomorrow, {{date:7}} for a week from today - - Specific time: {{datetime:2pm+1}} for 2 PM tomorrow - - Time range: {{datetime:2pm+1:4pm+1}} for tomorrow 2 PM to 4 PM - - You will find the text between XML tags. - Return the text with dates converted between XML tags. - TEXT - examples: [ - [ - "The meeting is at 2pm tomorrow", - "The meeting is at {{datetime:2pm+1}}", - ], - ["Due in 3 days", "Due {{date:3}}"], - [ - "Meeting next Tuesday at 2pm", - "Meeting {{next_week:tuesday-2pm}}", - ], - [ - "Meeting from 2pm to 4pm tomorrow", - "Meeting {{datetime:2pm+1:4pm+1}}", - ], - [ - "Meeting notes for tomorrow: -* Action items in `config.rb` -* Review PR #1234 -* Deadline is 5pm -* Check [this link](https://example.com)", - "Meeting notes for {{date:1}}: -* Action items in `config.rb` -* Review PR #1234 -* Deadline is {{datetime:5pm+1}} -* Check [this link](https://example.com)", - ], - ], - } -end diff --git a/db/fixtures/personas/603_ai_personas.rb b/db/fixtures/personas/603_ai_personas.rb index dee7c126..f06b9427 100644 --- a/db/fixtures/personas/603_ai_personas.rb +++ b/db/fixtures/personas/603_ai_personas.rb @@ -3,10 +3,13 @@ summarization_personas = [DiscourseAi::Personas::Summarizer, DiscourseAi::Personas::ShortSummarizer] def from_setting(setting_name) - DB.query_single( - "SELECT value FROM site_settings WHERE name = :setting_name", - setting_name: setting_name, - ) + DB + .query_single( + "SELECT value FROM site_settings WHERE name = :setting_name", + setting_name: setting_name, + ) + &.first + &.split("|") end DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id| @@ -28,7 +31,11 @@ DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id| default_groups = [Group::AUTO_GROUPS[:everyone]] end - persona.allowed_group_ids = from_setting(setting_name).first&.split("|") || default_groups + persona.allowed_group_ids = from_setting(setting_name) || default_groups + elsif persona_class == DiscourseAi::Personas::CustomPrompt + setting_name = "ai_helper_custom_prompts_allowed_groups" + default_groups = [Group::AUTO_GROUPS[:staff]] + persona.allowed_group_ids = from_setting(setting_name) || default_groups else persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]] end diff --git a/evals/lib/eval.rb b/evals/lib/eval.rb index a39c6bd7..2a7bf681 100644 --- a/evals/lib/eval.rb +++ b/evals/lib/eval.rb @@ -188,8 +188,7 @@ class DiscourseAi::Evals::Eval end def helper(llm, input:, name:, locale: nil) - completion_prompt = CompletionPrompt.find_by(name: name) - helper = DiscourseAi::AiHelper::Assistant.new(helper_llm: llm.llm_proxy) + helper = DiscourseAi::AiHelper::Assistant.new(helper_llm: llm.llm_model) user = Discourse.system_user if locale user = User.new @@ -202,7 +201,7 @@ class DiscourseAi::Evals::Eval end result = helper.generate_and_send_prompt( - completion_prompt, + name, input, current_user = user, _force_default_locale = false, diff --git a/lib/ai_helper/assistant.rb b/lib/ai_helper/assistant.rb index a771b9cd..b697ef62 100644 --- a/lib/ai_helper/assistant.rb +++ b/lib/ai_helper/assistant.rb @@ -5,6 +5,16 @@ module DiscourseAi class Assistant IMAGE_CAPTION_MAX_WORDS = 50 + TRANSLATE = "translate" + GENERATE_TITLES = "generate_titles" + PROOFREAD = "proofread" + MARKDOWN_TABLE = "markdown_table" + CUSTOM_PROMPT = "custom_prompt" + EXPLAIN = "explain" + ILLUSTRATE_POST = "illustrate_post" + REPLACE_DATES = "replace_dates" + IMAGE_CAPTION = "image_caption" + def self.prompt_cache @prompt_cache ||= ::DiscourseAi::MultisiteHash.new("prompt_cache") end @@ -18,58 +28,37 @@ module DiscourseAi @image_caption_llm = image_caption_llm end - def helper_llm - @helper_llm || DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_model) - end - - def image_caption_llm - @image_caption_llm || - DiscourseAi::Completions::Llm.proxy(SiteSetting.ai_helper_image_caption_model) - end - def available_prompts(user) key = "prompt_cache_#{I18n.locale}" - self - .class - .prompt_cache - .fetch(key) do - prompts = CompletionPrompt.where(enabled: true) + prompts = self.class.prompt_cache.fetch(key) { self.all_prompts } - # Hide illustrate_post if disabled - prompts = - prompts.where.not( - name: "illustrate_post", - ) if SiteSetting.ai_helper_illustrate_post_model == "disabled" + prompts + .map do |prompt| + next if !user.in_any_groups?(prompt[:allowed_group_ids]) - prompts = - prompts.map do |prompt| - if prompt.name == "translate" - locale = user.effective_locale - locale_hash = - LocaleSiteSetting.language_names[locale] || - LocaleSiteSetting.language_names[locale.split("_")[0]] - translation = - I18n.t( - "discourse_ai.ai_helper.prompts.translate", - language: locale_hash["nativeName"], - ) || prompt.translated_name || prompt.name - else - translation = - I18n.t("discourse_ai.ai_helper.prompts.#{prompt.name}", default: nil) || - prompt.translated_name || prompt.name - end + if prompt[:name] == ILLUSTRATE_POST && + SiteSetting.ai_helper_illustrate_post_model == "disabled" + next + end - { - id: prompt.id, - name: prompt.name, - translated_name: translation, - prompt_type: prompt.prompt_type, - icon: icon_map(prompt.name), - location: location_map(prompt.name), - } - end - prompts + # We cannot cache this. It depends on the user's effective_locale. + if prompt[:name] == TRANSLATE + locale = user.effective_locale + locale_hash = + LocaleSiteSetting.language_names[locale] || + LocaleSiteSetting.language_names[locale.split("_")[0]] + translation = + I18n.t( + "discourse_ai.ai_helper.prompts.translate", + language: locale_hash["nativeName"], + ) || prompt[:name] + + prompt.merge(translated_name: translation) + else + prompt + end end + .compact end def custom_locale_instructions(user = nil, force_default_locale) @@ -85,26 +74,14 @@ module DiscourseAi end end - def localize_prompt!(prompt, user = nil, force_default_locale: false) - locale_instructions = custom_locale_instructions(user, force_default_locale) - if locale_instructions - prompt.messages[0][:content] = prompt.messages[0][:content] + locale_instructions - end + def attach_user_context(context, user = nil, force_default_locale: false) + locale = SiteSetting.default_locale + locale = user.effective_locale if user && !force_default_locale + locale_hash = LocaleSiteSetting.language_names[locale] - if prompt.messages[0][:content].include?("%LANGUAGE%") - locale = SiteSetting.default_locale + context.user_language = "#{locale_hash["name"]}" - locale = user.effective_locale if user && !force_default_locale - - locale_hash = LocaleSiteSetting.language_names[locale] - - prompt.messages[0][:content] = prompt.messages[0][:content].gsub( - "%LANGUAGE%", - "#{locale_hash["name"]}", - ) - end - - if user && prompt.messages[0][:content].include?("{{temporal_context}}") + if user timezone = user.user_option.timezone || "UTC" current_time = Time.now.in_time_zone(timezone) @@ -117,48 +94,85 @@ module DiscourseAi }, } - prompt.messages[0][:content] = prompt.messages[0][:content].gsub( - "{{temporal_context}}", - temporal_context.to_json, - ) - - prompt.messages.each do |message| - message[:content] = DateFormatter.process_date_placeholders(message[:content], user) - end + context.temporal_context = temporal_context.to_json end + + context end - def generate_prompt(completion_prompt, input, user, force_default_locale: false, &block) - llm = helper_llm - prompt = completion_prompt.messages_with_input(input) - localize_prompt!(prompt, user, force_default_locale: force_default_locale) + def generate_prompt( + helper_mode, + input, + user, + force_default_locale: false, + custom_prompt: nil, + &block + ) + bot = build_bot(helper_mode, user) - llm.generate( - prompt, - user: user, - temperature: completion_prompt.temperature, - stop_sequences: completion_prompt.stop_sequences, - feature_name: "ai_helper", - &block - ) + user_input = "#{input}" + if helper_mode == CUSTOM_PROMPT && custom_prompt.present? + user_input = "#{custom_prompt}:\n#{input}" + end + + context = + DiscourseAi::Personas::BotContext.new( + user: user, + skip_tool_details: true, + feature_name: "ai_helper", + messages: [{ type: :user, content: user_input }], + format_dates: helper_mode == REPLACE_DATES, + custom_instructions: custom_locale_instructions(user, force_default_locale), + ) + context = attach_user_context(context, user, force_default_locale: force_default_locale) + + helper_response = +"" + + buffer_blk = + Proc.new do |partial, _, type| + if type == :structured_output + json_summary_schema_key = bot.persona.response_format&.first.to_h + helper_chunk = partial.read_buffered_property(json_summary_schema_key["key"]&.to_sym) + + if helper_chunk.present? + helper_response << helper_chunk + block.call(helper_chunk) if block + end + elsif type.blank? + # Assume response is a regular completion. + helper_response << helper_chunk + block.call(helper_chunk) if block + end + end + + bot.reply(context, &buffer_blk) + + helper_response end - def generate_and_send_prompt(completion_prompt, input, user, force_default_locale: false) - completion_result = + def generate_and_send_prompt( + helper_mode, + input, + user, + force_default_locale: false, + custom_prompt: nil + ) + helper_response = generate_prompt( - completion_prompt, + helper_mode, input, user, force_default_locale: force_default_locale, + custom_prompt: custom_prompt, ) - result = { type: completion_prompt.prompt_type } + result = { type: prompt_type(helper_mode) } result[:suggestions] = ( - if completion_prompt.list? - parse_list(completion_result).map { |suggestion| sanitize_result(suggestion) } + if result[:type] == :list + parse_list(helper_response).map { |suggestion| sanitize_result(suggestion) } else - sanitized = sanitize_result(completion_result) - result[:diff] = parse_diff(input, sanitized) if completion_prompt.diff? + sanitized = sanitize_result(helper_response) + result[:diff] = parse_diff(input, sanitized) if result[:type] == :diff [sanitized] end ) @@ -167,25 +181,28 @@ module DiscourseAi end def stream_prompt( - completion_prompt, + helper_mode, input, user, channel, force_default_locale: false, - client_id: nil + client_id: nil, + custom_prompt: nil ) streamed_diff = +"" streamed_result = +"" start = Time.now + type = prompt_type(helper_mode) generate_prompt( - completion_prompt, + helper_mode, input, user, force_default_locale: force_default_locale, - ) do |partial_response, cancel_function| + custom_prompt: custom_prompt, + ) do |partial_response| streamed_result << partial_response - streamed_diff = parse_diff(input, partial_response) if completion_prompt.diff? + streamed_diff = parse_diff(input, partial_response) if type == :diff # Throttle updates and check for safe stream points if (streamed_result.length > 10 && (Time.now - start > 0.3)) || Rails.env.test? @@ -197,7 +214,7 @@ module DiscourseAi end end - final_diff = parse_diff(input, streamed_result) if completion_prompt.diff? + final_diff = parse_diff(input, streamed_result) if type == :diff sanitized_result = sanitize_result(streamed_result) if sanitized_result.present? @@ -211,33 +228,126 @@ module DiscourseAi end def generate_image_caption(upload, user) - prompt = - DiscourseAi::Completions::Prompt.new( - "You are a bot specializing in image captioning.", + bot = build_bot(IMAGE_CAPTION, user) + force_default_locale = false + + context = + DiscourseAi::Personas::BotContext.new( + user: user, + skip_tool_details: true, + feature_name: IMAGE_CAPTION, messages: [ { type: :user, - content: [ - "Describe this image in a single sentence#{custom_locale_instructions(user)}", - { upload_id: upload.id }, - ], + content: ["Describe this image in a single sentence.", { upload_id: upload.id }], }, ], + custom_instructions: custom_locale_instructions(user, force_default_locale), ) - raw_caption = - image_caption_llm.generate( - prompt, - user: user, - max_tokens: 1024, - feature_name: "image_caption", - ) + structured_output = nil + + buffer_blk = + Proc.new do |partial, _, type| + if type == :structured_output + structured_output = partial + json_summary_schema_key = bot.persona.response_format&.first.to_h + end + end + + bot.reply(context, llm_args: { max_tokens: 1024 }, &buffer_blk) + + raw_caption = "" + + if structured_output + json_summary_schema_key = bot.persona.response_format&.first.to_h + raw_caption = + structured_output.read_buffered_property(json_summary_schema_key["key"]&.to_sym) + end raw_caption.delete("|").squish.truncate_words(IMAGE_CAPTION_MAX_WORDS) end private + def build_bot(helper_mode, user) + persona_id = personas_prompt_map(include_image_caption: true).invert[helper_mode] + raise Discourse::InvalidParameters.new(:mode) if persona_id.blank? + + persona_klass = AiPersona.find_by(id: persona_id)&.class_instance + return if persona_klass.nil? + + llm_model = find_ai_helper_model(helper_mode, persona_klass) + + DiscourseAi::Personas::Bot.as(user, persona: persona_klass.new, model: llm_model) + end + + # Priorities are: + # 1. Persona's default LLM + # 2. Hidden `ai_helper_model` setting, or `ai_helper_image_caption_model` for image_caption. + # 3. Newest LLM config + def find_ai_helper_model(helper_mode, persona_klass) + model_id = persona_klass.default_llm_id + + if !model_id + if helper_mode == IMAGE_CAPTION + model_id = @helper_llm || SiteSetting.ai_helper_image_caption_model&.split(":")&.last + else + model_id = @image_caption_llm || SiteSetting.ai_helper_model&.split(":")&.last + end + end + + if model_id.present? + LlmModel.find_by(id: model_id) + else + LlmModel.last + end + end + + def personas_prompt_map(include_image_caption: false) + map = { + SiteSetting.ai_helper_translator_persona.to_i => TRANSLATE, + SiteSetting.ai_helper_tittle_suggestions_persona.to_i => GENERATE_TITLES, + SiteSetting.ai_helper_proofreader_persona.to_i => PROOFREAD, + SiteSetting.ai_helper_markdown_tables_persona.to_i => MARKDOWN_TABLE, + SiteSetting.ai_helper_custom_prompt_persona.to_i => CUSTOM_PROMPT, + SiteSetting.ai_helper_explain_persona.to_i => EXPLAIN, + SiteSetting.ai_helper_post_illustrator_persona.to_i => ILLUSTRATE_POST, + SiteSetting.ai_helper_smart_dates_persona.to_i => REPLACE_DATES, + } + + if include_image_caption + image_caption_persona = SiteSetting.ai_helper_image_caption_persona.to_i + map[image_caption_persona] = IMAGE_CAPTION if image_caption_persona + end + + map + end + + def all_prompts + personas_and_prompts = personas_prompt_map + + AiPersona + .where(id: personas_prompt_map.keys) + .map do |ai_persona| + prompt_name = personas_prompt_map[ai_persona.id] + + if prompt_name + { + name: prompt_name, + translated_name: + I18n.t("discourse_ai.ai_helper.prompts.#{prompt_name}", default: nil) || + prompt_name, + prompt_type: prompt_type(prompt_name), + icon: icon_map(prompt_name), + location: location_map(prompt_name), + allowed_group_ids: ai_persona.allowed_group_ids, + } + end + end + .compact + end + SANITIZE_REGEX_STR = %w[term context topic replyTo input output result] .map { |tag| "<#{tag}>\\n?|\\n?" } @@ -268,25 +378,21 @@ module DiscourseAi def icon_map(name) case name - when "translate" + when TRANSLATE "language" - when "generate_titles" + when GENERATE_TITLES "heading" - when "proofread" + when PROOFREAD "spell-check" - when "markdown_table" + when MARKDOWN_TABLE "table" - when "tone" - "microphone" - when "custom_prompt" + when CUSTOM_PROMPT "comment" - when "rewrite" - "pen" - when "explain" + when EXPLAIN "question" - when "illustrate_post" + when ILLUSTRATE_POST "images" - when "replace_dates" + when REPLACE_DATES "calendar-days" else nil @@ -295,33 +401,37 @@ module DiscourseAi def location_map(name) case name - when "translate" + when TRANSLATE %w[composer post] - when "generate_titles" + when GENERATE_TITLES %w[composer] - when "proofread" + when PROOFREAD %w[composer post] - when "markdown_table" + when MARKDOWN_TABLE %w[composer] - when "tone" - %w[composer] - when "custom_prompt" + when CUSTOM_PROMPT %w[composer post] - when "rewrite" - %w[composer] - when "explain" + when EXPLAIN %w[post] - when "summarize" - %w[post] - when "illustrate_post" + when ILLUSTRATE_POST %w[composer] - when "replace_dates" + when REPLACE_DATES %w[composer] else %w[] end end + def prompt_type(prompt_name) + if [PROOFREAD, MARKDOWN_TABLE, REPLACE_DATES, CUSTOM_PROMPT].include?(prompt_name) + return :diff + end + + return :list if [ILLUSTRATE_POST, GENERATE_TITLES].include?(prompt_name) + + :text + end + def parse_diff(text, suggestion) cooked_text = PrettyText.cook(text) cooked_suggestion = PrettyText.cook(suggestion) diff --git a/lib/completions/endpoints/canned_response.rb b/lib/completions/endpoints/canned_response.rb index b31b2bb4..9f2b10c3 100644 --- a/lib/completions/endpoints/canned_response.rb +++ b/lib/completions/endpoints/canned_response.rb @@ -41,8 +41,6 @@ module DiscourseAi "The number of completions you requested exceed the number of canned responses" end - response = as_structured_output(response) if model_params[:response_format].present? - raise response if response.is_a?(StandardError) @completions += 1 @@ -57,8 +55,9 @@ module DiscourseAi yield(response, cancel_fn) elsif is_thinking?(response) yield(response, cancel_fn) - elsif is_structured_output?(response) - yield(response, cancel_fn) + elsif model_params[:response_format].present? + structured_output = as_structured_output(response) + yield(structured_output, cancel_fn) else response.each_char do |char| break if cancelled @@ -69,6 +68,7 @@ module DiscourseAi end response = response.first if response.is_a?(Array) && response.length == 1 + response = as_structured_output(response) if model_params[:response_format].present? response end @@ -87,10 +87,6 @@ module DiscourseAi response.is_a?(DiscourseAi::Completions::ToolCall) end - def is_structured_output?(response) - response.is_a?(DiscourseAi::Completions::StructuredOutput) - end - def as_structured_output(response) schema_properties = model_params[:response_format].dig(:json_schema, :schema, :properties) return response if schema_properties.blank? diff --git a/lib/configuration/llm_dependency_validator.rb b/lib/configuration/llm_dependency_validator.rb index cfdb0c80..0cf715fe 100644 --- a/lib/configuration/llm_dependency_validator.rb +++ b/lib/configuration/llm_dependency_validator.rb @@ -10,7 +10,7 @@ module DiscourseAi def valid_value?(val) return true if val == "f" - if @opts[:name] == :ai_summarization_enabled + if @opts[:name] == :ai_summarization_enabled || @opts[:name] == :ai_helper_enabled has_llms = LlmModel.count > 0 @no_llms_configured = !has_llms has_llms diff --git a/lib/personas/bot_context.rb b/lib/personas/bot_context.rb index 8ee81404..3853e2bd 100644 --- a/lib/personas/bot_context.rb +++ b/lib/personas/bot_context.rb @@ -18,7 +18,10 @@ module DiscourseAi :feature_name, :resource_url, :cancel_manager, - :inferred_concepts + :inferred_concepts, + :format_dates, + :temporal_context, + :user_language def initialize( post: nil, @@ -37,13 +40,15 @@ module DiscourseAi feature_name: "bot", resource_url: nil, cancel_manager: nil, - inferred_concepts: [] + inferred_concepts: [], + format_dates: false ) @participants = participants @user = user @skip_tool_details = skip_tool_details @messages = messages @custom_instructions = custom_instructions + @format_dates = format_dates @message_id = message_id @channel_id = channel_id @@ -78,6 +83,8 @@ module DiscourseAi participants resource_url inferred_concepts + user_language + temporal_context ] def lookup_template_param(key) @@ -125,6 +132,8 @@ module DiscourseAi feature_name: @feature_name, resource_url: @resource_url, inferred_concepts: @inferred_concepts, + user_language: @user_language, + temporal_context: @temporal_context, } end end diff --git a/lib/personas/custom_prompt.rb b/lib/personas/custom_prompt.rb new file mode 100644 index 00000000..22b21f94 --- /dev/null +++ b/lib/personas/custom_prompt.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class CustomPrompt < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + You are a helpful assistant. I will give you instructions inside XML tags. + You will look at them and reply with a result. + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + end + end +end diff --git a/lib/personas/image_captioner.rb b/lib/personas/image_captioner.rb new file mode 100644 index 00000000..1cde908c --- /dev/null +++ b/lib/personas/image_captioner.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class ImageCaptioner < Persona + def self.default_enabled + false + end + + def system_prompt + "You are a bot specializing in image captioning." + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + end + end +end diff --git a/lib/personas/markdown_table_generator.rb b/lib/personas/markdown_table_generator.rb new file mode 100644 index 00000000..939c0185 --- /dev/null +++ b/lib/personas/markdown_table_generator.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class MarkdownTableGenerator < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + You are a markdown table formatter, I will provide you text inside XML tags and you will format it into a markdown table + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + + def temperature + 0.5 + end + + def examples + [ + ["sam,joe,jane\nage: 22| 10|11", { output: <<~TEXT }.to_json], + | | sam | joe | jane | + |---|---|---|---| + | age | 22 | 10 | 11 | + TEXT + [<<~TEXT, { output: <<~TEXT }.to_json], + + sam: speed 100, age 22 + jane: age 10 + fred: height 22 + + TEXT + | | speed | age | height | + |---|---|---|---| + | sam | 100 | 22 | - | + | jane | - | 10 | - | + | fred | - | - | 22 | + TEXT + [<<~TEXT, { output: <<~TEXT }.to_json], + + chrome 22ms (first load 10ms) + firefox 10ms (first load: 9ms) + + TEXT + | Browser | Load Time (ms) | First Load Time (ms) | + |---|---|---| + | Chrome | 22 | 10 | + | Firefox | 10 | 9 | + TEXT + ] + end + end + end +end diff --git a/lib/personas/persona.rb b/lib/personas/persona.rb index 002e8f4e..53170bcc 100644 --- a/lib/personas/persona.rb +++ b/lib/personas/persona.rb @@ -55,6 +55,15 @@ module DiscourseAi ConceptFinder => -15, ConceptMatcher => -16, ConceptDeduplicator => -17, + CustomPrompt => -18, + SmartDates => -19, + MarkdownTableGenerator => -20, + PostIllustrator => -21, + Proofreader => -22, + TitlesGenerator => -23, + Tutor => -24, + Translator => -25, + ImageCaptioner => -26, } end @@ -260,10 +269,15 @@ module DiscourseAi 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 + replaced = + content.gsub(/\{(\w+)\}/) do |match| + found = context.lookup_template_param(match[1..-2]) + found.nil? ? match : found.to_s + end + + return replaced if !context.format_dates + + ::DiscourseAi::AiHelper::DateFormatter.process_date_placeholders(replaced, context.user) end def tool_instance(tool_call, bot_user:, llm:, context:, existing_tools:) diff --git a/lib/personas/post_illustrator.rb b/lib/personas/post_illustrator.rb new file mode 100644 index 00000000..c05dcbd8 --- /dev/null +++ b/lib/personas/post_illustrator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class PostIllustrator < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + Provide me a StableDiffusion prompt to generate an image that illustrates the following post in 40 words or less, be creative. + You'll find the post between XML tags. + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + end + end +end diff --git a/lib/personas/proofreader.rb b/lib/personas/proofreader.rb new file mode 100644 index 00000000..4a6ca778 --- /dev/null +++ b/lib/personas/proofreader.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class Proofreader < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + You are a markdown proofreader. You correct egregious typos and phrasing issues but keep the user's original voice. + You do not touch code blocks. I will provide you with text to proofread. If nothing needs fixing, then you will echo the text back. + You will find the text between XML tags. + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + + def examples + [ + [ + "![amazing car|100x100, 22%](upload://hapy.png)", + { output: "![Amazing car|100x100, 22%](upload://hapy.png)" }.to_json, + ], + [ + "The rain in spain stays mainly in the plane.", + { output: "The rain in Spain, stays mainly in the Plane." }.to_json, + ], + [ + "The rain in Spain, stays mainly in the Plane.", + { output: "The rain in Spain, stays mainly in the Plane." }.to_json, + ], + [<<~TEXT, { output: <<~TEXT }.to_json], + + Hello, + + Sometimes the logo isn't changing automatically when color scheme changes. + + ![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov) + + TEXT + Hello, + Sometimes the logo does not change automatically when the color scheme changes. + ![Screen Recording 2023-03-17 at 18.04.22|video](upload://2rcVL0ZMxHPNtPWQbZjwufKpWVU.mov) + TEXT + [<<~TEXT, { output: <<~TEXT }.to_json], + + Any ideas what is wrong with this peace of cod? + > This quot contains a typo + ```ruby + # this has speling mistakes + testin.atypo = 11 + baad = "bad" + ``` + + TEXT + Any ideas what is wrong with this piece of code? + > This quot contains a typo + ```ruby + # This has spelling mistakes + testing.a_typo = 11 + bad = "bad" + ``` + TEXT + ] + end + end + end +end diff --git a/lib/personas/smart_dates.rb b/lib/personas/smart_dates.rb new file mode 100644 index 00000000..609154de --- /dev/null +++ b/lib/personas/smart_dates.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class SmartDates < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + You are a date and time formatter for Discourse posts. Convert natural language time references into date placeholders. + Do not modify any markdown, code blocks, or existing date formats. + + Here's the temporal context: + {temporal_context} + + Available date placeholder formats: + - Simple day without time: {{date:1}} for tomorrow, {{date:7}} for a week from today + - Specific time: {{datetime:2pm+1}} for 2 PM tomorrow + - Time range: {{datetime:2pm+1:4pm+1}} for tomorrow 2 PM to 4 PM + + You will find the text between XML tags. + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + + def examples + [ + [ + "The meeting is at 2pm tomorrow", + { output: "The meeting is at {{datetime:2pm+1}}" }.to_json, + ], + ["Due in 3 days", { output: "Due {{date:3}}" }.to_json], + [ + "Meeting next Tuesday at 2pm", + { output: "Meeting {{next_week:tuesday-2pm}}" }.to_json, + ], + [ + "Meeting from 2pm to 4pm tomorrow", + { output: "Meeting {{datetime:2pm+1:4pm+1}}" }.to_json, + ], + [<<~TEXT, { output: <<~TEXT }.to_json], + Meeting notes for tomorrow: + * Action items in `config.rb` + * Review PR #1234 + * Deadline is 5pm + * Check [this link](https://example.com) + TEXT + Meeting notes for {{date:1}}: + * Action items in `config.rb` + * Review PR #1234 + * Deadline is {{datetime:5pm+1}} + * Check [this link](https://example.com) + TEXT + ] + end + end + end +end diff --git a/lib/personas/titles_generator.rb b/lib/personas/titles_generator.rb new file mode 100644 index 00000000..2f502601 --- /dev/null +++ b/lib/personas/titles_generator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class TitlesGenerator < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + I want you to act as a title generator for written pieces. I will provide you with a text, + and you will generate five titles. Please keep the title concise and under 20 words, + and ensure that the meaning is maintained. Replies will utilize the language type of the topic. + I want you to only reply the list of options and nothing else, do not write explanations. + Never ever use colons in the title. Always use sentence case, using a capital letter at + the start of the title, never start the title with a lower case letter. Proper nouns in the title + can have a capital letter, and acronyms like LLM can use capital letters. Format some titles + as questions, some as statements. Make sure to use question marks if the title is a question. + You will find the text between XML tags. + Wrap each title between XML tags. + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + + def examples + [ + [ + "In the labyrinth of time, a solitary horse, etched in gold by the setting sun, embarked on an infinite journey.", + "The solitary horseThe horse etched in goldA horse's infinite journeyA horse lost in timeA horse's last ride", + ], + ] + end + end + end +end diff --git a/lib/personas/translator.rb b/lib/personas/translator.rb new file mode 100644 index 00000000..c07a46e7 --- /dev/null +++ b/lib/personas/translator.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class Translator < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + I want you to act as an {user_language} translator, spelling corrector and improver. I will write to you + in any language and you will detect the language, translate it and answer in the corrected and + improved version of my text, in {user_language}. I want you to replace my simplified A0-level words and + sentences with more beautiful and elegant, upper level {user_language} words and sentences. + Keep the meaning same, but make them more literary. I want you to only reply the correction, + the improvements and nothing else, do not write explanations. + You will find the text between XML tags. + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + + def temperature + 0.2 + end + end + end +end diff --git a/lib/personas/tutor.rb b/lib/personas/tutor.rb new file mode 100644 index 00000000..22da11c8 --- /dev/null +++ b/lib/personas/tutor.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module DiscourseAi + module Personas + class Tutor < Persona + def self.default_enabled + false + end + + def system_prompt + <<~PROMPT.strip + You are a tutor explaining a term to a student in a specific context. + + I will provide everything you need to know inside tags, which consists of the term I want you + to explain inside tags, the context of where it was used inside tags, the title of + the topic where it was used inside tags, and optionally, the previous post in the conversation + in tags. + + Using all this information, write a paragraph with a brief explanation + of what the term means. Format the response using Markdown. Reply only with the explanation and + nothing more. + PROMPT + end + + def response_format + [{ "key" => "output", "type" => "string" }] + end + end + end +end diff --git a/spec/jobs/regular/stream_composer_helper_spec.rb b/spec/jobs/regular/stream_composer_helper_spec.rb index 7d6c623b..2d1cb623 100644 --- a/spec/jobs/regular/stream_composer_helper_spec.rb +++ b/spec/jobs/regular/stream_composer_helper_spec.rb @@ -15,13 +15,12 @@ RSpec.describe Jobs::StreamComposerHelper do end describe "validates params" do - let(:mode) { CompletionPrompt::PROOFREAD } - let(:prompt) { CompletionPrompt.find_by(id: mode) } + let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } it "does nothing if there is no user" do messages = MessageBus.track_publish("/discourse-ai/ai-helper/stream_suggestion") do - job.execute(user_id: nil, text: input, prompt: prompt.name, force_default_locale: false) + job.execute(user_id: nil, text: input, prompt: mode, force_default_locale: false) end expect(messages).to be_empty @@ -33,7 +32,7 @@ RSpec.describe Jobs::StreamComposerHelper do job.execute( user_id: user.id, text: nil, - prompt: prompt.name, + prompt: mode, force_default_locale: false, client_id: "123", ) @@ -44,12 +43,10 @@ RSpec.describe Jobs::StreamComposerHelper do end context "when all params are provided" do - let(:mode) { CompletionPrompt::PROOFREAD } - let(:prompt) { CompletionPrompt.find_by(id: mode) } + let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } it "publishes updates with a partial result" do proofread_result = "I like to eat pie for breakfast because it is delicious." - partial_result = "I" DiscourseAi::Completions::Llm.with_prepared_responses([proofread_result]) do messages = @@ -57,7 +54,7 @@ RSpec.describe Jobs::StreamComposerHelper do job.execute( user_id: user.id, text: input, - prompt: prompt.name, + prompt: mode, force_default_locale: true, client_id: "123", ) @@ -65,7 +62,7 @@ RSpec.describe Jobs::StreamComposerHelper do partial_result_update = messages.first.data expect(partial_result_update[:done]).to eq(false) - expect(partial_result_update[:result]).to eq(partial_result) + expect(partial_result_update[:result]).to eq(proofread_result) end end @@ -78,7 +75,7 @@ RSpec.describe Jobs::StreamComposerHelper do job.execute( user_id: user.id, text: input, - prompt: prompt.name, + prompt: mode, force_default_locale: true, client_id: "123", ) diff --git a/spec/jobs/regular/stream_post_helper_spec.rb b/spec/jobs/regular/stream_post_helper_spec.rb index 9e811711..3c492929 100644 --- a/spec/jobs/regular/stream_post_helper_spec.rb +++ b/spec/jobs/regular/stream_post_helper_spec.rb @@ -23,8 +23,7 @@ RSpec.describe Jobs::StreamPostHelper do end describe "validates params" do - let(:mode) { CompletionPrompt::EXPLAIN } - let(:prompt) { CompletionPrompt.find_by(id: mode) } + let(:mode) { DiscourseAi::AiHelper::Assistant::EXPLAIN } it "does nothing if there is no post" do messages = @@ -55,24 +54,21 @@ RSpec.describe Jobs::StreamPostHelper do end context "when the prompt is explain" do - let(:mode) { CompletionPrompt::EXPLAIN } - let(:prompt) { CompletionPrompt.find_by(id: mode) } + let(:mode) { DiscourseAi::AiHelper::Assistant::EXPLAIN } it "publishes updates with a partial result" do explanation = "In this context, \"pie\" refers to a baked dessert typically consisting of a pastry crust and filling." - partial_explanation = "I" - DiscourseAi::Completions::Llm.with_prepared_responses([explanation]) do messages = MessageBus.track_publish("/discourse-ai/ai-helper/stream_suggestion/#{post.id}") do - job.execute(post_id: post.id, user_id: user.id, text: "pie", prompt: prompt.name) + job.execute(post_id: post.id, user_id: user.id, text: "pie", prompt: mode) end partial_result_update = messages.first.data expect(partial_result_update[:done]).to eq(false) - expect(partial_result_update[:result]).to eq(partial_explanation) + expect(partial_result_update[:result]).to eq(explanation) end end @@ -83,7 +79,7 @@ RSpec.describe Jobs::StreamPostHelper do DiscourseAi::Completions::Llm.with_prepared_responses([explanation]) do messages = MessageBus.track_publish("/discourse-ai/ai-helper/stream_suggestion/#{post.id}") do - job.execute(post_id: post.id, user_id: user.id, text: "pie", prompt: prompt.name) + job.execute(post_id: post.id, user_id: user.id, text: "pie", prompt: mode) end final_update = messages.last.data @@ -94,23 +90,21 @@ RSpec.describe Jobs::StreamPostHelper do end context "when the prompt is translate" do - let(:mode) { CompletionPrompt::TRANSLATE } - let(:prompt) { CompletionPrompt.find_by(id: mode) } + let(:mode) { DiscourseAi::AiHelper::Assistant::TRANSLATE } it "publishes updates with a partial result" do sentence = "I like to eat pie." translation = "Me gusta comer pastel." - partial_translation = "M" DiscourseAi::Completions::Llm.with_prepared_responses([translation]) do messages = MessageBus.track_publish("/discourse-ai/ai-helper/stream_suggestion/#{post.id}") do - job.execute(post_id: post.id, user_id: user.id, text: sentence, prompt: prompt.name) + job.execute(post_id: post.id, user_id: user.id, text: sentence, prompt: mode) end partial_result_update = messages.first.data expect(partial_result_update[:done]).to eq(false) - expect(partial_result_update[:result]).to eq(partial_translation) + expect(partial_result_update[:result]).to eq(translation) end end @@ -121,7 +115,7 @@ RSpec.describe Jobs::StreamPostHelper do DiscourseAi::Completions::Llm.with_prepared_responses([translation]) do messages = MessageBus.track_publish("/discourse-ai/ai-helper/stream_suggestion/#{post.id}") do - job.execute(post_id: post.id, user_id: user.id, text: sentence, prompt: prompt.name) + job.execute(post_id: post.id, user_id: user.id, text: sentence, prompt: mode) end final_update = messages.last.data diff --git a/spec/lib/modules/ai_helper/assistant_spec.rb b/spec/lib/modules/ai_helper/assistant_spec.rb index 125df1f9..6ac8ef04 100644 --- a/spec/lib/modules/ai_helper/assistant_spec.rb +++ b/spec/lib/modules/ai_helper/assistant_spec.rb @@ -3,9 +3,11 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do fab!(:user) fab!(:empty_locale_user) { Fabricate(:user, locale: "") } - let(:prompt) { CompletionPrompt.find_by(id: mode) } - before { assign_fake_provider_to(:ai_helper_model) } + before do + assign_fake_provider_to(:ai_helper_model) + Group.refresh_automatic_groups! + end let(:english_text) { <<~STRING } To perfect his horror, Caesar, surrounded at the base of the statue by the impatient daggers of his friends, @@ -48,15 +50,12 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do it "returns all available prompts" do prompts = subject.available_prompts(user) - expect(prompts.length).to eq(8) expect(prompts.map { |p| p[:name] }).to contain_exactly( "translate", "generate_titles", "proofread", "markdown_table", - "custom_prompt", "explain", - "detect_text_locale", "replace_dates", ) end @@ -64,13 +63,12 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do it "returns all prompts to be shown in the composer" do prompts = subject.available_prompts(user) filtered_prompts = prompts.select { |prompt| prompt[:location].include?("composer") } - expect(filtered_prompts.length).to eq(6) + expect(filtered_prompts.map { |p| p[:name] }).to contain_exactly( "translate", "generate_titles", "proofread", "markdown_table", - "custom_prompt", "replace_dates", ) end @@ -78,12 +76,11 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do it "returns all prompts to be shown in the post menu" do prompts = subject.available_prompts(user) filtered_prompts = prompts.select { |prompt| prompt[:location].include?("post") } - expect(filtered_prompts.length).to eq(4) + expect(filtered_prompts.map { |p| p[:name] }).to contain_exactly( "translate", "explain", "proofread", - "custom_prompt", ) end @@ -101,90 +98,69 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do it "returns the illustrate_post prompt in the list of all prompts" do prompts = subject.available_prompts(user) - expect(prompts.length).to eq(9) expect(prompts.map { |p| p[:name] }).to contain_exactly( "translate", "generate_titles", "proofread", "markdown_table", - "custom_prompt", "explain", "illustrate_post", - "detect_text_locale", "replace_dates", ) end end end - describe("#localize_prompt!") do + describe("#attach_user_context") do before { SiteSetting.allow_user_locale = true } + let(:context) { DiscourseAi::Personas::BotContext.new(user: user) } + it "is able to perform %LANGUAGE% replacements" do - prompt = - CompletionPrompt.new(messages: { insts: "This is a %LANGUAGE% test" }).messages_with_input( - "test", - ) + subject.attach_user_context(context, user) - subject.localize_prompt!(prompt, user) - - expect(prompt.messages[0][:content].strip).to eq("This is a English (US) test") + expect(context.user_language).to eq("English (US)") end it "handles users with empty string locales" do - prompt = - CompletionPrompt.new(messages: { insts: "This is a %LANGUAGE% test" }).messages_with_input( - "test", - ) + subject.attach_user_context(context, empty_locale_user) - subject.localize_prompt!(prompt, empty_locale_user) - - expect(prompt.messages[0][:content].strip).to eq("This is a English (US) test") + expect(context.user_language).to eq("English (US)") end context "with temporal context" do - let(:prompt) do - CompletionPrompt.new( - messages: { - insts: "Current context: {{temporal_context}}", - }, - ).messages_with_input("test") - end - it "replaces temporal context with timezone information" do timezone = "America/New_York" user.user_option.update!(timezone: timezone) freeze_time "2024-01-01 12:00:00" - subject.localize_prompt!(prompt, user) + subject.attach_user_context(context, user) - content = prompt.messages[0][:content] - - expect(content).to include(%("timezone":"America/New_York")) + expect(context.temporal_context).to include(%("timezone":"America/New_York")) end it "uses UTC as default timezone when user timezone is not set" do user.user_option.update!(timezone: nil) freeze_time "2024-01-01 12:00:00" do - subject.localize_prompt!(prompt, user) + subject.attach_user_context(context, user) - parsed_context = JSON.parse(prompt.messages[0][:content].match(/context: (.+)$/)[1]) - expect(parsed_context["user"]["timezone"]).to eq("UTC") + parsed_context = JSON.parse(context.temporal_context) + expect(parsed_context.dig("user", "timezone")).to eq("UTC") end end it "does not replace temporal context when user is nil" do - prompt_content = prompt.messages[0][:content].dup - subject.localize_prompt!(prompt, nil) - expect(prompt.messages[0][:content]).to eq(prompt_content) + subject.attach_user_context(context, nil) + + expect(context.temporal_context).to be_nil end end end describe "#generate_and_send_prompt" do context "when using a prompt that returns text" do - let(:mode) { CompletionPrompt::TRANSLATE } + let(:mode) { described_class::TRANSLATE } let(:text_to_translate) { <<~STRING } Para que su horror sea perfecto, César, acosado al pie de la estatua por lo impacientes puñales de sus amigos, @@ -195,7 +171,7 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do it "Sends the prompt to the LLM and returns the response" do response = DiscourseAi::Completions::Llm.with_prepared_responses([english_text]) do - subject.generate_and_send_prompt(prompt, text_to_translate, user) + subject.generate_and_send_prompt(mode, text_to_translate, user) end expect(response[:suggestions]).to contain_exactly(english_text) @@ -203,7 +179,7 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do end context "when using a prompt that returns a list" do - let(:mode) { CompletionPrompt::GENERATE_TITLES } + let(:mode) { described_class::GENERATE_TITLES } let(:titles) do "The solitary horseThe horse etched in goldA horse's infinite journeyA horse lost in timeA horse's last ride" @@ -220,7 +196,7 @@ RSpec.describe DiscourseAi::AiHelper::Assistant do response = DiscourseAi::Completions::Llm.with_prepared_responses([titles]) do - subject.generate_and_send_prompt(prompt, english_text, user) + subject.generate_and_send_prompt(mode, english_text, user) end expect(response[:suggestions]).to contain_exactly(*expected) diff --git a/spec/models/completion_prompt_spec.rb b/spec/models/completion_prompt_spec.rb deleted file mode 100644 index dd44c85c..00000000 --- a/spec/models/completion_prompt_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe CompletionPrompt do - describe "validations" do - context "when there are too many messages" do - it "doesn't accept more than 20 messages" do - prompt = described_class.new(messages: [{ role: "system", content: "a" }] * 21) - - expect(prompt.valid?).to eq(false) - end - end - - context "when the message is over the max length" do - it "doesn't accept messages when the length is more than 1000 characters" do - prompt = described_class.new(messages: [{ role: "system", content: "a" * 1001 }]) - - expect(prompt.valid?).to eq(false) - end - end - end - - describe "messages_with_input" do - let(:user_input) { "A user wrote this." } - - context "when mapping to a prompt" do - it "correctly maps everything to the prompt" do - cp = - CompletionPrompt.new( - messages: { - insts: "Instructions", - post_insts: "Post Instructions", - examples: [["Request 1", "Response 1"]], - }, - ) - - prompt = cp.messages_with_input("hello") - - expected = [ - { type: :system, content: "Instructions\nPost Instructions" }, - { type: :user, content: "Request 1" }, - { type: :model, content: "Response 1" }, - { type: :user, content: "hello" }, - ] - - expect(prompt.messages).to eq(expected) - end - end - - context "when the record has the custom_prompt type" do - let(:custom_prompt) { described_class.find(described_class::CUSTOM_PROMPT) } - - it "wraps the user input with XML tags and adds a custom instruction if given" do - expected = <<~TEXT.strip - Translate to Turkish: - #{user_input} - TEXT - - custom_prompt.custom_instruction = "Translate to Turkish" - - prompt = custom_prompt.messages_with_input(user_input) - - expect(prompt.messages.last[:content]).to eq(expected) - end - end - - context "when the records don't have the custom_prompt type" do - let(:title_prompt) { described_class.find(described_class::GENERATE_TITLES) } - - it "wraps user input with XML tags" do - expected = "#{user_input}" - - title_prompt.custom_instruction = "Translate to Turkish" - - prompt = title_prompt.messages_with_input(user_input) - - expect(prompt.messages.last[:content]).to eq(expected) - end - end - end -end diff --git a/spec/plugin_spec.rb b/spec/plugin_spec.rb index 3ddc398f..9a69041f 100644 --- a/spec/plugin_spec.rb +++ b/spec/plugin_spec.rb @@ -11,6 +11,7 @@ describe Plugin::Instance do SiteSetting.ai_helper_enabled = true SiteSetting.ai_helper_illustrate_post_model = "disabled" Group.find_by(id: Group::AUTO_GROUPS[:admins]).add(user) + Group.refresh_automatic_groups! DiscourseAi::AiHelper::Assistant.clear_prompt_cache! end @@ -19,7 +20,15 @@ describe Plugin::Instance do it "returns the available prompts" do expect(serializer.ai_helper_prompts).to be_present - expect(serializer.ai_helper_prompts.object.count).to eq(8) + + expect(serializer.ai_helper_prompts.object.map { |p| p[:name] }).to contain_exactly( + "translate", + "generate_titles", + "proofread", + "markdown_table", + "explain", + "replace_dates", + ) end end end diff --git a/spec/requests/ai_helper/assistant_controller_spec.rb b/spec/requests/ai_helper/assistant_controller_spec.rb index deae8a2a..61051a75 100644 --- a/spec/requests/ai_helper/assistant_controller_spec.rb +++ b/spec/requests/ai_helper/assistant_controller_spec.rb @@ -22,7 +22,7 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do text: "hello wrld", location: "composer", client_id: "1234", - mode: CompletionPrompt::PROOFREAD, + mode: DiscourseAi::AiHelper::Assistant::PROOFREAD, } expect(response.status).to eq(200) @@ -41,7 +41,7 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do describe "#suggest" do let(:text_to_proofread) { "The rain in spain stays mainly in the plane." } let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." } - let(:mode) { CompletionPrompt::PROOFREAD } + let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } context "when not logged in" do it "returns a 403 response" do @@ -121,7 +121,7 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do DiscourseAi::Completions::Llm.with_prepared_responses([translated_text]) do post "/discourse-ai/ai-helper/suggest", params: { - mode: CompletionPrompt::CUSTOM_PROMPT, + mode: DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT, text: "A user wrote this", custom_prompt: "Translate to Spanish", } @@ -137,7 +137,7 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do expect { post "/discourse-ai/ai-helper/suggest", params: { - mode: CompletionPrompt::ILLUSTRATE_POST, + mode: DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST, text: text_to_proofread, force_default_locale: true, } @@ -153,8 +153,14 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do amount = rate_limit[:amount] amount.times do - post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text_to_proofread } - expect(response.status).to eq(200) + DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do + post "/discourse-ai/ai-helper/suggest", + params: { + mode: mode, + text: text_to_proofread, + } + expect(response.status).to eq(200) + end end DiscourseAi::Completions::Llm.with_prepared_responses([proofread_text]) do post "/discourse-ai/ai-helper/suggest", params: { mode: mode, text: text_to_proofread } diff --git a/spec/system/ai_helper/ai_composer_helper_spec.rb b/spec/system/ai_helper/ai_composer_helper_spec.rb index 84b56f70..c0fbed81 100644 --- a/spec/system/ai_helper/ai_composer_helper_spec.rb +++ b/spec/system/ai_helper/ai_composer_helper_spec.rb @@ -62,7 +62,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when using custom prompt" do - let(:mode) { CompletionPrompt::CUSTOM_PROMPT } + let(:mode) { DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT } let(:custom_prompt_input) { "Translate to French" } let(:custom_prompt_response) { "La pluie en Espagne reste principalement dans l'avion." } @@ -94,7 +94,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when not a member of custom prompt group" do - let(:mode) { CompletionPrompt::CUSTOM_PROMPT } + let(:mode) { DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT } before { SiteSetting.ai_helper_custom_prompts_allowed_groups = non_member_group.id.to_s } it "does not show custom prompt option" do @@ -104,7 +104,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when using translation mode" do - let(:mode) { CompletionPrompt::TRANSLATE } + let(:mode) { DiscourseAi::AiHelper::Assistant::TRANSLATE } let(:spanish_input) { "La lluvia en España se queda principalmente en el avión." } @@ -163,7 +163,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when using the proofreading mode" do - let(:mode) { CompletionPrompt::PROOFREAD } + let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } let(:proofread_text) { "The rain in Spain, stays mainly in the Plane." } @@ -182,7 +182,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when suggesting titles with AI title suggester" do - let(:mode) { CompletionPrompt::GENERATE_TITLES } + let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } let(:titles) do "Rainy SpainPlane-Bound DelightsMysterious SpainPlane-Rain ChroniclesUnveiling Spain" @@ -330,7 +330,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when AI helper is disabled" do - let(:mode) { CompletionPrompt::GENERATE_TITLES } + let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } before { SiteSetting.ai_helper_enabled = false } it "does not show the AI helper button in the composer toolbar" do @@ -349,7 +349,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when user is not a member of AI helper allowed group" do - let(:mode) { CompletionPrompt::GENERATE_TITLES } + let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } before { SiteSetting.composer_ai_helper_allowed_groups = non_member_group.id.to_s } it "does not show the AI helper button in the composer toolbar" do @@ -368,7 +368,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do end context "when suggestion features are disabled" do - let(:mode) { CompletionPrompt::GENERATE_TITLES } + let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } before { SiteSetting.ai_helper_enabled_features = "context_menu" } it "does not show suggestion buttons in the composer" do @@ -398,7 +398,7 @@ RSpec.describe "AI Composer helper", type: :system, js: true do composer.click_toolbar_button("ai-helper-trigger") DiscourseAi::Completions::Llm.with_prepared_responses([input]) do - ai_helper_menu.select_helper_model(CompletionPrompt::TRANSLATE) + ai_helper_menu.select_helper_model(DiscourseAi::AiHelper::Assistant::TRANSLATE) expect(ai_helper_menu).to have_no_context_menu expect(diff_modal).to be_visible end diff --git a/spec/system/ai_helper/ai_post_helper_spec.rb b/spec/system/ai_helper/ai_post_helper_spec.rb index 93c5244c..3ed32b19 100644 --- a/spec/system/ai_helper/ai_post_helper_spec.rb +++ b/spec/system/ai_helper/ai_post_helper_spec.rb @@ -80,7 +80,7 @@ RSpec.describe "AI Post helper", type: :system, js: true do end context "when using proofread mode" do - let(:mode) { CompletionPrompt::PROOFREAD } + let(:mode) { DiscourseAi::AiHelper::Assistant::PROOFREAD } let(:proofread_response) do "The Toyota Supra delivers 382 horsepower making it a very fast car." end diff --git a/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb b/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb index 12a1109d..1bc4221f 100644 --- a/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb +++ b/spec/system/ai_helper/ai_split_topic_suggestion_spec.rb @@ -54,7 +54,7 @@ RSpec.describe "AI Post helper", type: :system, js: true do describe "moving posts to a new topic" do context "when suggesting titles with AI title suggester" do - let(:mode) { CompletionPrompt::GENERATE_TITLES } + let(:mode) { DiscourseAi::AiHelper::Assistant::GENERATE_TITLES } let(:titles) do "Pie: A delicious dessertCake is the best!Croissants are delightfulSome great dessertsWhat is the best dessert?" end diff --git a/test/javascripts/fixtures/ai-helper-prompts.js b/test/javascripts/fixtures/ai-helper-prompts.js index 79054ef8..7fac2500 100644 --- a/test/javascripts/fixtures/ai-helper-prompts.js +++ b/test/javascripts/fixtures/ai-helper-prompts.js @@ -1,6 +1,5 @@ export default [ { - id: -301, name: "translate", translated_name: "Translate to English (US)", prompt_type: "text", @@ -8,7 +7,6 @@ export default [ location: ["composer", "post"], }, { - id: -303, name: "proofread", translated_name: "Proofread text", prompt_type: "diff", @@ -16,7 +14,6 @@ export default [ location: ["composer", "post"], }, { - id: -304, name: "markdown_table", translated_name: "Generate Markdown table", prompt_type: "diff", @@ -24,7 +21,6 @@ export default [ location: ["composer"], }, { - id: -305, name: "custom_prompt", translated_name: "Custom Prompt", prompt_type: "diff", @@ -32,7 +28,6 @@ export default [ location: ["composer", "post"], }, { - id: -306, name: "explain", translated_name: "Explain", prompt_type: "text", @@ -40,7 +35,6 @@ export default [ location: ["post"], }, { - id: -307, name: "generate_titles", translated_name: "Suggest topic titles", prompt_type: "list", @@ -48,19 +42,10 @@ export default [ location: ["composer"], }, { - id: -308, name: "illustrate_post", translated_name: "Illustrate Post", prompt_type: "list", icon: "images", location: ["composer"], }, - { - id: -309, - name: "detect_text_locale", - translated_name: "detect_text_locale", - prompt_type: "text", - icon: null, - location: [], - }, ];