2023-11-27 09:33:31 -03:00
# frozen_string_literal: true
module DiscourseAi
module AiHelper
class Assistant
2024-11-12 15:52:46 -03:00
IMAGE_CAPTION_MAX_WORDS = 50
2025-05-27 10:37:30 -03:00
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 "
2024-04-26 22:28:35 +10:00
def self . prompt_cache
@prompt_cache || = :: DiscourseAi :: MultisiteHash . new ( " prompt_cache " )
end
2024-02-16 10:57:14 -08:00
2024-02-19 15:21:55 +11:00
def self . clear_prompt_cache!
2024-04-26 22:28:35 +10:00
prompt_cache . flush!
2024-02-19 15:21:55 +11:00
end
FEATURE: PDF support for rag pipeline (#1118)
This PR introduces several enhancements and refactorings to the AI Persona and RAG (Retrieval-Augmented Generation) functionalities within the discourse-ai plugin. Here's a breakdown of the changes:
**1. LLM Model Association for RAG and Personas:**
- **New Database Columns:** Adds `rag_llm_model_id` to both `ai_personas` and `ai_tools` tables. This allows specifying a dedicated LLM for RAG indexing, separate from the persona's primary LLM. Adds `default_llm_id` and `question_consolidator_llm_id` to `ai_personas`.
- **Migration:** Includes a migration (`20250210032345_migrate_persona_to_llm_model_id.rb`) to populate the new `default_llm_id` and `question_consolidator_llm_id` columns in `ai_personas` based on the existing `default_llm` and `question_consolidator_llm` string columns, and a post migration to remove the latter.
- **Model Changes:** The `AiPersona` and `AiTool` models now `belong_to` an `LlmModel` via `rag_llm_model_id`. The `LlmModel.proxy` method now accepts an `LlmModel` instance instead of just an identifier. `AiPersona` now has `default_llm_id` and `question_consolidator_llm_id` attributes.
- **UI Updates:** The AI Persona and AI Tool editors in the admin panel now allow selecting an LLM for RAG indexing (if PDF/image support is enabled). The RAG options component displays an LLM selector.
- **Serialization:** The serializers (`AiCustomToolSerializer`, `AiCustomToolListSerializer`, `LocalizedAiPersonaSerializer`) have been updated to include the new `rag_llm_model_id`, `default_llm_id` and `question_consolidator_llm_id` attributes.
**2. PDF and Image Support for RAG:**
- **Site Setting:** Introduces a new hidden site setting, `ai_rag_pdf_images_enabled`, to control whether PDF and image files can be indexed for RAG. This defaults to `false`.
- **File Upload Validation:** The `RagDocumentFragmentsController` now checks the `ai_rag_pdf_images_enabled` setting and allows PDF, PNG, JPG, and JPEG files if enabled. Error handling is included for cases where PDF/image indexing is attempted with the setting disabled.
- **PDF Processing:** Adds a new utility class, `DiscourseAi::Utils::PdfToImages`, which uses ImageMagick (`magick`) to convert PDF pages into individual PNG images. A maximum PDF size and conversion timeout are enforced.
- **Image Processing:** A new utility class, `DiscourseAi::Utils::ImageToText`, is included to handle OCR for the images and PDFs.
- **RAG Digestion Job:** The `DigestRagUpload` job now handles PDF and image uploads. It uses `PdfToImages` and `ImageToText` to extract text and create document fragments.
- **UI Updates:** The RAG uploader component now accepts PDF and image file types if `ai_rag_pdf_images_enabled` is true. The UI text is adjusted to indicate supported file types.
**3. Refactoring and Improvements:**
- **LLM Enumeration:** The `DiscourseAi::Configuration::LlmEnumerator` now provides a `values_for_serialization` method, which returns a simplified array of LLM data (id, name, vision_enabled) suitable for use in serializers. This avoids exposing unnecessary details to the frontend.
- **AI Helper:** The `AiHelper::Assistant` now takes optional `helper_llm` and `image_caption_llm` parameters in its constructor, allowing for greater flexibility.
- **Bot and Persona Updates:** Several updates were made across the codebase, changing the string based association to a LLM to the new model based.
- **Audit Logs:** The `DiscourseAi::Completions::Endpoints::Base` now formats raw request payloads as pretty JSON for easier auditing.
- **Eval Script:** An evaluation script is included.
**4. Testing:**
- The PR introduces a new eval system for LLMs, this allows us to test how functionality works across various LLM providers. This lives in `/evals`
2025-02-14 12:15:07 +11:00
def initialize ( helper_llm : nil , image_caption_llm : nil )
@helper_llm = helper_llm
@image_caption_llm = image_caption_llm
end
2024-07-04 08:23:37 -07:00
def available_prompts ( user )
2024-04-26 22:28:35 +10:00
key = " prompt_cache_ #{ I18n . locale } "
2025-05-27 10:37:30 -03:00
prompts = self . class . prompt_cache . fetch ( key ) { self . all_prompts }
prompts
. map do | prompt |
next if ! user . in_any_groups? ( prompt [ :allowed_group_ids ] )
if prompt [ :name ] == ILLUSTRATE_POST &&
SiteSetting . ai_helper_illustrate_post_model == " disabled "
next
end
# 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
2024-02-16 10:57:14 -08:00
end
2025-05-27 10:37:30 -03:00
. compact
2023-11-27 09:33:31 -03:00
end
2024-07-04 08:23:37 -07:00
def custom_locale_instructions ( user = nil , force_default_locale )
2024-02-28 06:31:51 +11:00
locale = SiteSetting . default_locale
2024-12-31 08:04:25 +11:00
locale = user . effective_locale if ! force_default_locale && user
2024-02-28 06:31:51 +11:00
locale_hash = LocaleSiteSetting . language_names [ locale ]
if locale != " en " && locale_hash
locale_description = " #{ locale_hash [ " name " ] } ( #{ locale_hash [ " nativeName " ] } ) "
" It is imperative that you write your answer in #{ locale_description } , you are interacting with a #{ locale_description } speaking user. Leave tag names in English. "
else
nil
end
end
2025-05-27 10:37:30 -03:00
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 ]
2024-02-28 06:31:51 +11:00
2025-05-27 10:37:30 -03:00
context . user_language = " #{ locale_hash [ " name " ] } "
2024-12-31 08:04:25 +11:00
2025-05-27 10:37:30 -03:00
if user
2024-12-31 08:04:25 +11:00
timezone = user . user_option . timezone || " UTC "
current_time = Time . now . in_time_zone ( timezone )
temporal_context = {
utc_date_time : current_time . iso8601 ,
local_time : current_time . strftime ( " %H:%M " ) ,
user : {
timezone : timezone ,
weekday : current_time . strftime ( " %A " ) ,
} ,
}
2025-05-27 10:37:30 -03:00
context . temporal_context = temporal_context . to_json
end
context
end
def generate_prompt (
helper_mode ,
input ,
user ,
force_default_locale : false ,
custom_prompt : nil ,
& block
)
bot = build_bot ( helper_mode , user )
user_input = " <input> #{ input } </input> "
if helper_mode == CUSTOM_PROMPT && custom_prompt . present?
user_input = " <input> #{ custom_prompt } : \n #{ input } </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 ) ,
2024-12-31 08:04:25 +11:00
)
2025-05-27 10:37:30 -03:00
context = attach_user_context ( context , user , force_default_locale : force_default_locale )
helper_response = + " "
buffer_blk =
Proc . new do | partial , _ , type |
2025-06-16 18:06:54 -03:00
json_summary_schema_key = bot . persona . response_format & . first . to_h
helper_response = [ ] if json_summary_schema_key [ " type " ] == " array "
2025-05-27 10:37:30 -03:00
if type == :structured_output
helper_chunk = partial . read_buffered_property ( json_summary_schema_key [ " key " ] & . to_sym )
2025-06-06 16:59:00 +10:00
if ! helper_chunk . nil? && ! helper_chunk . empty?
2025-06-16 18:06:54 -03:00
if json_summary_schema_key [ " type " ] != " array "
helper_response = helper_chunk
else
helper_response << helper_chunk
end
2025-05-27 10:37:30 -03:00
block . call ( helper_chunk ) if block
end
elsif type . blank?
# Assume response is a regular completion.
2025-06-12 12:33:12 -03:00
helper_response << partial
block . call ( partial ) if block
2025-05-27 10:37:30 -03:00
end
2024-12-31 08:04:25 +11:00
end
2024-02-28 06:31:51 +11:00
2025-05-27 10:37:30 -03:00
bot . reply ( context , & buffer_blk )
helper_response
2023-12-12 09:28:39 -08:00
end
2025-05-27 10:37:30 -03:00
def generate_and_send_prompt (
helper_mode ,
input ,
user ,
force_default_locale : false ,
custom_prompt : nil
)
helper_response =
2025-04-14 08:18:50 -07:00
generate_prompt (
2025-05-27 10:37:30 -03:00
helper_mode ,
2025-04-14 08:18:50 -07:00
input ,
user ,
force_default_locale : force_default_locale ,
2025-05-27 10:37:30 -03:00
custom_prompt : custom_prompt ,
2025-04-14 08:18:50 -07:00
)
2025-05-27 10:37:30 -03:00
result = { type : prompt_type ( helper_mode ) }
2023-11-27 09:33:31 -03:00
result [ :suggestions ] = (
2025-05-27 10:37:30 -03:00
if result [ :type ] == :list
2025-06-16 18:06:54 -03:00
helper_response . flatten . map { | suggestion | sanitize_result ( suggestion ) }
2023-11-27 09:33:31 -03:00
else
2025-05-27 10:37:30 -03:00
sanitized = sanitize_result ( helper_response )
result [ :diff ] = parse_diff ( input , sanitized ) if result [ :type ] == :diff
2024-01-04 23:53:47 +11:00
[ sanitized ]
2023-11-27 09:33:31 -03:00
end
)
result
end
2025-05-23 16:23:06 +10:00
def stream_prompt (
2025-05-27 10:37:30 -03:00
helper_mode ,
2025-05-23 16:23:06 +10:00
input ,
user ,
channel ,
force_default_locale : false ,
2025-05-27 10:37:30 -03:00
client_id : nil ,
custom_prompt : nil
2025-05-23 16:23:06 +10:00
)
2025-04-14 08:18:50 -07:00
streamed_diff = + " "
2023-12-12 09:28:39 -08:00
streamed_result = + " "
start = Time . now
2025-05-27 10:37:30 -03:00
type = prompt_type ( helper_mode )
2023-12-12 09:28:39 -08:00
2025-04-14 08:18:50 -07:00
generate_prompt (
2025-05-27 10:37:30 -03:00
helper_mode ,
2025-04-14 08:18:50 -07:00
input ,
user ,
force_default_locale : force_default_locale ,
2025-05-27 10:37:30 -03:00
custom_prompt : custom_prompt ,
) do | partial_response |
2023-12-12 09:28:39 -08:00
streamed_result << partial_response
2025-05-27 10:37:30 -03:00
streamed_diff = parse_diff ( input , partial_response ) if type == :diff
2025-04-14 08:18:50 -07:00
2025-05-15 11:38:46 -07:00
# Throttle updates and check for safe stream points
2025-04-14 08:18:50 -07:00
if ( streamed_result . length > 10 && ( Time . now - start > 0 . 3 ) ) || Rails . env . test?
2025-05-15 11:38:46 -07:00
sanitized = sanitize_result ( streamed_result )
2025-05-15 14:55:30 -07:00
payload = { result : sanitized , diff : streamed_diff , done : false }
2025-05-23 16:23:06 +10:00
publish_update ( channel , payload , user , client_id : client_id )
2025-05-15 14:55:30 -07:00
start = Time . now
2023-12-12 09:28:39 -08:00
end
end
2025-05-27 10:37:30 -03:00
final_diff = parse_diff ( input , streamed_result ) if type == :diff
2025-04-14 08:18:50 -07:00
2023-12-12 09:28:39 -08:00
sanitized_result = sanitize_result ( streamed_result )
if sanitized_result . present?
2025-05-23 16:23:06 +10:00
publish_update (
channel ,
{ result : sanitized_result , diff : final_diff , done : true } ,
user ,
client_id : client_id ,
)
2023-12-12 09:28:39 -08:00
end
end
2024-05-28 23:31:15 +10:00
def generate_image_caption ( upload , user )
2025-05-27 10:37:30 -03:00
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 ,
2024-07-24 16:29:47 -03:00
messages : [
{
type : :user ,
2025-05-27 10:37:30 -03:00
content : [ " Describe this image in a single sentence. " , { upload_id : upload . id } ] ,
2024-07-24 16:29:47 -03:00
} ,
] ,
2025-05-27 10:37:30 -03:00
custom_instructions : custom_locale_instructions ( user , force_default_locale ) ,
2024-02-19 09:56:28 -08:00
)
2024-07-24 16:29:47 -03:00
2025-05-27 10:37:30 -03:00
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
2024-10-23 18:38:29 -03:00
2024-11-12 15:52:46 -03:00
raw_caption . delete ( " | " ) . squish . truncate_words ( IMAGE_CAPTION_MAX_WORDS )
2024-02-19 09:56:28 -08:00
end
2023-11-27 09:33:31 -03:00
private
2025-05-27 10:37:30 -03:00
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 ,
2025-06-03 20:40:17 -03:00
SiteSetting . ai_helper_title_suggestions_persona . to_i = > GENERATE_TITLES ,
2025-05-27 10:37:30 -03:00
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
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
2024-01-04 23:53:47 +11:00
SANITIZE_REGEX_STR =
%w[ term context topic replyTo input output result ]
. map { | tag | " < #{ tag } > \\ n?| \\ n?</ #{ tag } > " }
. join ( " | " )
SANITIZE_REGEX = Regexp . new ( SANITIZE_REGEX_STR , Regexp :: IGNORECASE | Regexp :: MULTILINE )
2023-12-12 09:28:39 -08:00
def sanitize_result ( result )
2024-01-04 23:53:47 +11:00
result . gsub ( SANITIZE_REGEX , " " )
2023-12-12 09:28:39 -08:00
end
2025-05-23 16:23:06 +10:00
def publish_update ( channel , payload , user , client_id : nil )
# when publishing we make sure we do not keep large backlogs on the channel
# and make sure we clear the streaming info after 60 seconds
# this ensures we do not bloat redis
if client_id
MessageBus . publish (
channel ,
payload ,
user_ids : [ user . id ] ,
client_ids : [ client_id ] ,
max_backlog_age : 60 ,
)
else
MessageBus . publish ( channel , payload , user_ids : [ user . id ] , max_backlog_age : 60 )
end
2023-12-12 09:28:39 -08:00
end
2023-11-27 09:33:31 -03:00
def icon_map ( name )
case name
2025-05-27 10:37:30 -03:00
when TRANSLATE
2023-11-27 09:33:31 -03:00
" language "
2025-05-27 10:37:30 -03:00
when GENERATE_TITLES
2023-11-27 09:33:31 -03:00
" heading "
2025-05-27 10:37:30 -03:00
when PROOFREAD
2023-11-27 09:33:31 -03:00
" spell-check "
2025-05-27 10:37:30 -03:00
when MARKDOWN_TABLE
2023-11-27 09:33:31 -03:00
" table "
2025-05-27 10:37:30 -03:00
when CUSTOM_PROMPT
2023-11-27 09:33:31 -03:00
" comment "
2025-05-27 10:37:30 -03:00
when EXPLAIN
2023-11-27 09:33:31 -03:00
" question "
2025-05-27 10:37:30 -03:00
when ILLUSTRATE_POST
2023-12-19 11:17:34 -08:00
" images "
2025-05-27 10:37:30 -03:00
when REPLACE_DATES
2024-12-31 08:04:25 +11:00
" calendar-days "
2023-11-27 09:33:31 -03:00
else
nil
end
end
def location_map ( name )
case name
2025-05-27 10:37:30 -03:00
when TRANSLATE
2023-11-27 09:33:31 -03:00
%w[ composer post ]
2025-05-27 10:37:30 -03:00
when GENERATE_TITLES
2023-11-27 09:33:31 -03:00
%w[ composer ]
2025-05-27 10:37:30 -03:00
when PROOFREAD
2023-12-14 19:30:52 -08:00
%w[ composer post ]
2025-05-27 10:37:30 -03:00
when MARKDOWN_TABLE
2023-11-27 09:33:31 -03:00
%w[ composer ]
2025-05-27 10:37:30 -03:00
when CUSTOM_PROMPT
2023-12-14 08:47:20 -08:00
%w[ composer post ]
2025-05-27 10:37:30 -03:00
when EXPLAIN
2023-11-27 09:33:31 -03:00
%w[ post ]
2025-05-27 10:37:30 -03:00
when ILLUSTRATE_POST
2023-12-19 11:17:34 -08:00
%w[ composer ]
2025-05-27 10:37:30 -03:00
when REPLACE_DATES
2024-12-31 08:04:25 +11:00
%w[ composer ]
2023-11-27 09:33:31 -03:00
else
2024-11-28 09:14:21 +09:00
%w[ ]
2023-11-27 09:33:31 -03:00
end
end
2025-05-27 10:37:30 -03:00
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
2023-11-27 09:33:31 -03:00
def parse_diff ( text , suggestion )
cooked_text = PrettyText . cook ( text )
cooked_suggestion = PrettyText . cook ( suggestion )
DiscourseDiff . new ( cooked_text , cooked_suggestion ) . inline_html
end
end
end
end