From 1de8d3e56dfbcb2cfd6e6b51045d1bfcde21f316 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Thu, 11 Dec 2025 14:17:28 +0530
Subject: [PATCH 01/29] feat: legacy features to ruby llm (#12994)
---
Gemfile.lock | 1 +
app/models/message.rb | 15 ++
.../conversation_llm_formatter.rb | 2 +-
config/initializers/ai_agents.rb | 4 +-
.../api/v1/portals/articles_controller.rb | 2 +-
enterprise/app/helpers/captain/chat_helper.rb | 12 +-
.../captain/documents/response_builder_job.rb | 2 +-
.../jobs/captain/llm/update_embedding_job.rb | 3 +-
enterprise/app/models/article_embedding.rb | 2 +
.../app/models/captain/assistant_response.rb | 4 +-
enterprise/app/models/concerns/agentable.rb | 2 +-
.../app/models/enterprise/concerns/article.rb | 2 +-
.../captain/llm/contact_attributes_service.rb | 44 ++--
.../captain/llm/contact_notes_service.rb | 51 ++---
.../captain/llm/conversation_faq_service.rb | 54 ++---
.../services/captain/llm/embedding_service.rb | 42 ++--
.../captain/llm/faq_generator_service.rb | 46 +++--
.../llm/paginated_faq_generator_service.rb | 49 +++--
.../captain/llm/pdf_processing_service.rb | 33 ++-
.../onboarding/website_analyzer_service.rb | 37 ++--
.../content_evaluator_service.rb | 80 ++++----
.../app/services/llm/base_ai_service.rb | 2 -
.../llm/legacy_base_open_ai_service.rb | 7 +-
.../messages/audio_transcription_service.rb | 25 ++-
enterprise/lib/captain/agent.rb | 137 -------------
enterprise/lib/captain/llm_service.rb | 64 ------
enterprise/lib/captain/tool.rb | 66 ------
lib/integrations/llm_instrumentation.rb | 95 ++++-----
.../llm_instrumentation_completion_helpers.rb | 88 ++++++++
.../llm_instrumentation_helpers.rb | 58 ++++--
lib/integrations/llm_instrumentation_spans.rb | 1 -
lib/integrations/openai/processor_service.rb | 27 +--
lib/llm_constants.rb | 17 ++
lib/open_ai_constants.rb | 8 -
.../documents/response_builder_job_spec.rb | 6 +-
.../llm/conversation_faq_service_spec.rb | 130 ++++++------
.../captain/llm/faq_generator_service_spec.rb | 110 +++++-----
.../website_analyzer_service_spec.rb | 93 ++++++---
.../content_evaluator_service_spec.rb | 194 +++++++++++++-----
39 files changed, 860 insertions(+), 755 deletions(-)
delete mode 100644 enterprise/lib/captain/agent.rb
delete mode 100644 enterprise/lib/captain/llm_service.rb
delete mode 100644 enterprise/lib/captain/tool.rb
create mode 100644 lib/integrations/llm_instrumentation_completion_helpers.rb
create mode 100644 lib/llm_constants.rb
delete mode 100644 lib/open_ai_constants.rb
diff --git a/Gemfile.lock b/Gemfile.lock
index b42472ef4..15ed841ac 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -827,6 +827,7 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
+ ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
diff --git a/app/models/message.rb b/app/models/message.rb
index 2faba1215..0bf7a176b 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -254,6 +254,21 @@ class Message < ApplicationRecord
Messages::SearchDataPresenter.new(self).search_data
end
+ # Returns message content suitable for LLM consumption
+ # Falls back to audio transcription or attachment placeholder when content is nil
+ def content_for_llm
+ return content if content.present?
+
+ audio_transcription = attachments
+ .where(file_type: :audio)
+ .filter_map { |att| att.meta&.dig('transcribed_text') }
+ .join(' ')
+ .presence
+ return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
+
+ '[Attachment]' if attachments.any?
+ end
+
private
def prevent_message_flooding
diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb
index 4e0bd7013..38d7c9e26 100644
--- a/app/services/llm_formatter/conversation_llm_formatter.rb
+++ b/app/services/llm_formatter/conversation_llm_formatter.rb
@@ -48,7 +48,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
'Bot'
end
sender = "[Private Note] #{sender}" if message.private?
- "#{sender}: #{message.content}\n"
+ "#{sender}: #{message.content_for_llm}\n"
end
def build_attributes
diff --git a/config/initializers/ai_agents.rb b/config/initializers/ai_agents.rb
index 37bdd589f..be8a8a9cc 100644
--- a/config/initializers/ai_agents.rb
+++ b/config/initializers/ai_agents.rb
@@ -4,8 +4,8 @@ require 'agents'
Rails.application.config.after_initialize do
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
- model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
- api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || OpenAiConstants::DEFAULT_ENDPOINT
+ model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
+ api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT
if api_key.present?
Agents.configure do |config|
diff --git a/enterprise/app/controllers/enterprise/public/api/v1/portals/articles_controller.rb b/enterprise/app/controllers/enterprise/public/api/v1/portals/articles_controller.rb
index 240da0dca..77162dad0 100644
--- a/enterprise/app/controllers/enterprise/public/api/v1/portals/articles_controller.rb
+++ b/enterprise/app/controllers/enterprise/public/api/v1/portals/articles_controller.rb
@@ -3,7 +3,7 @@ module Enterprise::Public::Api::V1::Portals::ArticlesController
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
- @articles = @articles.vector_search(list_params) if list_params[:query].present?
+ @articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
else
super
end
diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb
index a60e3015f..9bbbd86a5 100644
--- a/enterprise/app/helpers/captain/chat_helper.rb
+++ b/enterprise/app/helpers/captain/chat_helper.rb
@@ -21,20 +21,18 @@ module Captain::ChatHelper
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
- llm_chat.with_params(response_format: { type: 'json_object' })
+ llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
- setup_system_instructions(llm_chat)
+ llm_chat = setup_system_instructions(llm_chat)
setup_event_handlers(llm_chat)
-
- llm_chat
end
- def setup_tools(chat)
+ def setup_tools(llm_chat)
@tools&.each do |tool|
- chat.with_tool(tool)
+ llm_chat = llm_chat.with_tool(tool)
end
- chat
+ llm_chat
end
def setup_system_instructions(chat)
diff --git a/enterprise/app/jobs/captain/documents/response_builder_job.rb b/enterprise/app/jobs/captain/documents/response_builder_job.rb
index b22fa5bc1..553cec110 100644
--- a/enterprise/app/jobs/captain/documents/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/documents/response_builder_job.rb
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
- Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
+ Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
end
def build_paginated_service(document, options)
diff --git a/enterprise/app/jobs/captain/llm/update_embedding_job.rb b/enterprise/app/jobs/captain/llm/update_embedding_job.rb
index 20d10f8f5..72ab540bd 100644
--- a/enterprise/app/jobs/captain/llm/update_embedding_job.rb
+++ b/enterprise/app/jobs/captain/llm/update_embedding_job.rb
@@ -2,7 +2,8 @@ class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
queue_as :low
def perform(record, content)
- embedding = Captain::Llm::EmbeddingService.new.get_embedding(content)
+ account_id = record.account_id
+ embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
record.update!(embedding: embedding)
end
end
diff --git a/enterprise/app/models/article_embedding.rb b/enterprise/app/models/article_embedding.rb
index 14665a24b..4dafcfe06 100644
--- a/enterprise/app/models/article_embedding.rb
+++ b/enterprise/app/models/article_embedding.rb
@@ -19,6 +19,8 @@ class ArticleEmbedding < ApplicationRecord
after_commit :update_response_embedding
+ delegate :account_id, to: :article
+
private
def update_response_embedding
diff --git a/enterprise/app/models/captain/assistant_response.rb b/enterprise/app/models/captain/assistant_response.rb
index 7ab2878a3..12dcab1cc 100644
--- a/enterprise/app/models/captain/assistant_response.rb
+++ b/enterprise/app/models/captain/assistant_response.rb
@@ -44,8 +44,8 @@ class Captain::AssistantResponse < ApplicationRecord
enum status: { pending: 0, approved: 1 }
- def self.search(query)
- embedding = Captain::Llm::EmbeddingService.new.get_embedding(query)
+ def self.search(query, account_id: nil)
+ embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
end
diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb
index dab76a726..e5b0b8eef 100644
--- a/enterprise/app/models/concerns/agentable.rb
+++ b/enterprise/app/models/concerns/agentable.rb
@@ -43,7 +43,7 @@ module Concerns::Agentable
end
def agent_model
- InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
+ InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
end
def agent_response_schema
diff --git a/enterprise/app/models/enterprise/concerns/article.rb b/enterprise/app/models/enterprise/concerns/article.rb
index d3a94d7b7..4527bfdf6 100644
--- a/enterprise/app/models/enterprise/concerns/article.rb
+++ b/enterprise/app/models/enterprise/concerns/article.rb
@@ -11,7 +11,7 @@ module Enterprise::Concerns::Article
add_article_embedding_association
def self.vector_search(params)
- embedding = Captain::Llm::EmbeddingService.new.get_embedding(params['query'])
+ embedding = Captain::Llm::EmbeddingService.new(account_id: params[:account_id]).get_embedding(params['query'])
records = joins(
:category
).search_by_category_slug(
diff --git a/enterprise/app/services/captain/llm/contact_attributes_service.rb b/enterprise/app/services/captain/llm/contact_attributes_service.rb
index f2c48de57..803c06f09 100644
--- a/enterprise/app/services/captain/llm/contact_attributes_service.rb
+++ b/enterprise/app/services/captain/llm/contact_attributes_service.rb
@@ -1,4 +1,5 @@
-class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
+class Captain::Llm::ContactAttributesService < Llm::BaseAiService
+ include Integrations::LlmInstrumentation
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -17,33 +18,38 @@ class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
attr_reader :content
def generate_attributes
- response = @client.chat(parameters: chat_parameters)
- parse_response(response)
- rescue OpenAI::Error => e
- Rails.logger.error "OpenAI API Error: #{e.message}"
+ response = instrument_llm_call(instrumentation_params) do
+ chat
+ .with_params(response_format: { type: 'json_object' })
+ .with_instructions(system_prompt)
+ .ask(@content)
+ end
+ parse_response(response.content)
+ rescue RubyLLM::Error => e
+ ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
[]
end
- def chat_parameters
- prompt = Captain::Llm::SystemPromptsService.attributes_generator
+ def instrumentation_params
{
+ span_name: 'llm.captain.contact_attributes',
model: @model,
- response_format: { type: 'json_object' },
+ temperature: @temperature,
+ account_id: @conversation.account_id,
+ feature_name: 'contact_attributes',
messages: [
- {
- role: 'system',
- content: prompt
- },
- {
- role: 'user',
- content: content
- }
- ]
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: @content }
+ ],
+ metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
}
end
- def parse_response(response)
- content = response.dig('choices', 0, 'message', 'content')
+ def system_prompt
+ Captain::Llm::SystemPromptsService.attributes_generator
+ end
+
+ def parse_response(content)
return [] if content.nil?
JSON.parse(content.strip).fetch('attributes', [])
diff --git a/enterprise/app/services/captain/llm/contact_notes_service.rb b/enterprise/app/services/captain/llm/contact_notes_service.rb
index 4ee4fcb5e..d37f9fea6 100644
--- a/enterprise/app/services/captain/llm/contact_notes_service.rb
+++ b/enterprise/app/services/captain/llm/contact_notes_service.rb
@@ -1,4 +1,5 @@
-class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
+class Captain::Llm::ContactNotesService < Llm::BaseAiService
+ include Integrations::LlmInstrumentation
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -18,38 +19,42 @@ class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
attr_reader :content
def generate_notes
- response = @client.chat(parameters: chat_parameters)
- parse_response(response)
- rescue OpenAI::Error => e
- Rails.logger.error "OpenAI API Error: #{e.message}"
+ response = instrument_llm_call(instrumentation_params) do
+ chat
+ .with_params(response_format: { type: 'json_object' })
+ .with_instructions(system_prompt)
+ .ask(@content)
+ end
+ parse_response(response.content)
+ rescue RubyLLM::Error => e
+ ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
[]
end
- def chat_parameters
- account_language = @conversation.account.locale_english_name
- prompt = Captain::Llm::SystemPromptsService.notes_generator(account_language)
-
+ def instrumentation_params
{
+ span_name: 'llm.captain.contact_notes',
model: @model,
- response_format: { type: 'json_object' },
+ temperature: @temperature,
+ account_id: @conversation.account_id,
+ feature_name: 'contact_notes',
messages: [
- {
- role: 'system',
- content: prompt
- },
- {
- role: 'user',
- content: content
- }
- ]
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: @content }
+ ],
+ metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
}
end
- def parse_response(response)
- content = response.dig('choices', 0, 'message', 'content')
- return [] if content.nil?
+ def system_prompt
+ account_language = @conversation.account.locale_english_name
+ Captain::Llm::SystemPromptsService.notes_generator(account_language)
+ end
- JSON.parse(content.strip).fetch('notes', [])
+ def parse_response(response)
+ return [] if response.nil?
+
+ JSON.parse(response.strip).fetch('notes', [])
rescue JSON::ParserError => e
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
[]
diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb
index 47d1433bd..e9152bc99 100644
--- a/enterprise/app/services/captain/llm/conversation_faq_service.rb
+++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb
@@ -1,4 +1,5 @@
-class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
+class Captain::Llm::ConversationFaqService < Llm::BaseAiService
+ include Integrations::LlmInstrumentation
DISTANCE_THRESHOLD = 0.3
def initialize(assistant, conversation)
@@ -35,7 +36,7 @@ class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
faqs.each do |faq|
combined_text = "#{faq['question']}: #{faq['answer']}"
- embedding = Captain::Llm::EmbeddingService.new.get_embedding(combined_text)
+ embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
similar_faqs = find_similar_faqs(embedding)
if similar_faqs.any?
@@ -81,38 +82,43 @@ class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
end
def generate
- response = @client.chat(parameters: chat_parameters)
- parse_response(response)
- rescue OpenAI::Error => e
- Rails.logger.error "OpenAI API Error: #{e.message}"
+ response = instrument_llm_call(instrumentation_params) do
+ chat
+ .with_params(response_format: { type: 'json_object' })
+ .with_instructions(system_prompt)
+ .ask(@content)
+ end
+ parse_response(response.content)
+ rescue RubyLLM::Error => e
+ Rails.logger.error "LLM API Error: #{e.message}"
[]
end
- def chat_parameters
- account_language = @conversation.account.locale_english_name
- prompt = Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
-
+ def instrumentation_params
{
+ span_name: 'llm.captain.conversation_faq',
model: @model,
- response_format: { type: 'json_object' },
+ temperature: @temperature,
+ account_id: @conversation.account_id,
+ conversation_id: @conversation.id,
+ feature_name: 'conversation_faq',
messages: [
- {
- role: 'system',
- content: prompt
- },
- {
- role: 'user',
- content: content
- }
- ]
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: @content }
+ ],
+ metadata: { assistant_id: @assistant.id }
}
end
- def parse_response(response)
- content = response.dig('choices', 0, 'message', 'content')
- return [] if content.nil?
+ def system_prompt
+ account_language = @conversation.account.locale_english_name
+ Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
+ end
- JSON.parse(content.strip).fetch('faqs', [])
+ def parse_response(response)
+ return [] if response.nil?
+
+ JSON.parse(response.strip).fetch('faqs', [])
rescue JSON::ParserError => e
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
[]
diff --git a/enterprise/app/services/captain/llm/embedding_service.rb b/enterprise/app/services/captain/llm/embedding_service.rb
index 5190ed28a..2fac54594 100644
--- a/enterprise/app/services/captain/llm/embedding_service.rb
+++ b/enterprise/app/services/captain/llm/embedding_service.rb
@@ -1,22 +1,38 @@
-require 'openai'
+class Captain::Llm::EmbeddingService
+ include Integrations::LlmInstrumentation
-class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService
class EmbeddingsError < StandardError; end
- def self.embedding_model
- @embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || OpenAiConstants::DEFAULT_EMBEDDING_MODEL
+ def initialize(account_id: nil)
+ Llm::Config.initialize!
+ @account_id = account_id
+ @embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
end
- def get_embedding(content, model: self.class.embedding_model)
- response = @client.embeddings(
- parameters: {
- model: model,
- input: content
- }
- )
+ def self.embedding_model
+ InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
+ end
- response.dig('data', 0, 'embedding')
- rescue StandardError => e
+ def get_embedding(content, model: @embedding_model)
+ return [] if content.blank?
+
+ instrument_embedding_call(instrumentation_params(content, model)) do
+ RubyLLM.embed(content, model: model).vectors
+ end
+ rescue RubyLLM::Error => e
+ Rails.logger.error "Embedding API Error: #{e.message}"
raise EmbeddingsError, "Failed to create an embedding: #{e.message}"
end
+
+ private
+
+ def instrumentation_params(content, model)
+ {
+ span_name: 'llm.captain.embedding',
+ model: model,
+ input: content,
+ feature_name: 'embedding',
+ account_id: @account_id
+ }
+ end
end
diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb
index 634385b36..b22a631b3 100644
--- a/enterprise/app/services/captain/llm/faq_generator_service.rb
+++ b/enterprise/app/services/captain/llm/faq_generator_service.rb
@@ -1,15 +1,24 @@
-class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
- def initialize(content, language = 'english')
+class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
+ include Integrations::LlmInstrumentation
+
+ def initialize(content, language = 'english', account_id: nil)
super()
@language = language
@content = content
+ @account_id = account_id
end
def generate
- response = @client.chat(parameters: chat_parameters)
- parse_response(response)
- rescue OpenAI::Error => e
- Rails.logger.error "OpenAI API Error: #{e.message}"
+ response = instrument_llm_call(instrumentation_params) do
+ chat
+ .with_params(response_format: { type: 'json_object' })
+ .with_instructions(system_prompt)
+ .ask(@content)
+ end
+
+ parse_response(response.content)
+ rescue RubyLLM::Error => e
+ Rails.logger.error "LLM API Error: #{e.message}"
[]
end
@@ -17,26 +26,25 @@ class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
attr_reader :content, :language
- def chat_parameters
- prompt = Captain::Llm::SystemPromptsService.faq_generator(language)
+ def system_prompt
+ Captain::Llm::SystemPromptsService.faq_generator(language)
+ end
+
+ def instrumentation_params
{
+ span_name: 'llm.captain.faq_generator',
model: @model,
- response_format: { type: 'json_object' },
+ temperature: @temperature,
+ feature_name: 'faq_generator',
+ account_id: @account_id,
messages: [
- {
- role: 'system',
- content: prompt
- },
- {
- role: 'user',
- content: content
- }
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: @content }
]
}
end
- def parse_response(response)
- content = response.dig('choices', 0, 'message', 'content')
+ def parse_response(content)
return [] if content.nil?
JSON.parse(content.strip).fetch('faqs', [])
diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
index ebfef4b5c..149152107 100644
--- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
+++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
@@ -1,4 +1,6 @@
class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
+ include Integrations::LlmInstrumentation
+
# Default pages per chunk - easily configurable
DEFAULT_PAGES_PER_CHUNK = 10
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
@@ -13,7 +15,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
@max_pages = options[:max_pages] # Optional limit from UI
@total_pages_processed = 0
@iterations_completed = 0
- @model = OpenAiConstants::PDF_PROCESSING_MODEL
+ @model = LlmConstants::PDF_PROCESSING_MODEL
end
def generate
@@ -43,7 +45,19 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
private
def generate_standard_faqs
- response = @client.chat(parameters: standard_chat_parameters)
+ params = standard_chat_parameters
+ instrumentation_params = {
+ span_name: 'llm.faq_generation',
+ account_id: @document&.account_id,
+ feature_name: 'faq_generation',
+ model: @model,
+ messages: params[:messages]
+ }
+
+ response = instrument_llm_call(instrumentation_params) do
+ @client.chat(parameters: params)
+ end
+
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error I18n.t('captain.documents.openai_api_error', error: e.message)
@@ -84,7 +98,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
def process_page_chunk(start_page, end_page)
params = build_chunk_parameters(start_page, end_page)
- response = @client.chat(parameters: params)
+
+ instrumentation_params = build_instrumentation_params(params, start_page, end_page)
+
+ response = instrument_llm_call(instrumentation_params) do
+ @client.chat(parameters: params)
+ end
+
result = parse_chunk_response(response)
{ faqs: result['faqs'] || [], has_content: result['has_content'] != false }
rescue OpenAI::Error => e
@@ -180,21 +200,26 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
def similarity_score(str1, str2)
words1 = str1.downcase.split(/\W+/).reject(&:empty?)
words2 = str2.downcase.split(/\W+/).reject(&:empty?)
-
common_words = words1 & words2
total_words = (words1 + words2).uniq.size
-
return 0 if total_words.zero?
common_words.size.to_f / total_words
end
- def determine_stop_reason(last_chunk_result)
- return 'Maximum iterations reached' if @iterations_completed >= MAX_ITERATIONS
- return 'Maximum pages processed' if @max_pages && @total_pages_processed >= @max_pages
- return 'No content found in last chunk' if last_chunk_result[:faqs].empty?
- return 'End of document reached' if last_chunk_result[:has_content] == false
-
- 'Unknown'
+ def build_instrumentation_params(params, start_page, end_page)
+ {
+ span_name: 'llm.paginated_faq_generation',
+ account_id: @document&.account_id,
+ feature_name: 'paginated_faq_generation',
+ model: @model,
+ messages: params[:messages],
+ metadata: {
+ document_id: @document&.id,
+ start_page: start_page,
+ end_page: end_page,
+ iteration: @iterations_completed + 1
+ }
+ }
end
end
diff --git a/enterprise/app/services/captain/llm/pdf_processing_service.rb b/enterprise/app/services/captain/llm/pdf_processing_service.rb
index 55177b78d..82e3e9fbc 100644
--- a/enterprise/app/services/captain/llm/pdf_processing_service.rb
+++ b/enterprise/app/services/captain/llm/pdf_processing_service.rb
@@ -1,4 +1,6 @@
class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
+ include Integrations::LlmInstrumentation
+
def initialize(document)
super()
@document = document
@@ -19,13 +21,30 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
def upload_pdf_to_openai
with_tempfile do |temp_file|
- response = @client.files.upload(
- parameters: {
- file: temp_file,
- purpose: 'assistants'
- }
- )
- response['id']
+ instrument_file_upload do
+ response = @client.files.upload(
+ parameters: {
+ file: temp_file,
+ purpose: 'assistants'
+ }
+ )
+ response['id']
+ end
+ end
+ end
+
+ def instrument_file_upload(&)
+ return yield unless ChatwootApp.otel_enabled?
+
+ tracer.in_span('llm.file.upload') do |span|
+ span.set_attribute('gen_ai.provider', 'openai')
+ span.set_attribute('file.purpose', 'assistants')
+ span.set_attribute(ATTR_LANGFUSE_USER_ID, document.account_id.to_s)
+ span.set_attribute(ATTR_LANGFUSE_TAGS, ['pdf_upload'].to_json)
+ span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'document_id'), document.id.to_s)
+ file_id = yield
+ span.set_attribute('file.id', file_id) if file_id
+ file_id
end
end
diff --git a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
index 02ba35571..a5879ba33 100644
--- a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
+++ b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
@@ -1,4 +1,5 @@
-class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
+class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
+ include Integrations::LlmInstrumentation
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -57,19 +58,29 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
end
def extract_business_info
- prompt = build_analysis_prompt
+ response = instrument_llm_call(instrumentation_params) do
+ chat
+ .with_params(response_format: { type: 'json_object' }, max_tokens: 1000)
+ .with_temperature(0.1)
+ .with_instructions(build_analysis_prompt)
+ .ask(@website_content)
+ end
- response = client.chat(
- parameters: {
- model: model,
- messages: [{ role: 'user', content: prompt }],
- response_format: { type: 'json_object' },
- temperature: 0.1,
- max_tokens: 1000
- }
- )
+ parse_llm_response(response.content)
+ end
- parse_llm_response(response.dig('choices', 0, 'message', 'content'))
+ def instrumentation_params
+ {
+ span_name: 'llm.captain.website_analyzer',
+ model: @model,
+ temperature: 0.1,
+ feature_name: 'website_analyzer',
+ messages: [
+ { role: 'system', content: build_analysis_prompt },
+ { role: 'user', content: @website_content }
+ ],
+ metadata: { website_url: @website_url }
+ }
end
def build_analysis_prompt
@@ -95,7 +106,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
end
def parse_llm_response(response_text)
- parsed_response = JSON.parse(response_text)
+ parsed_response = JSON.parse(response_text.strip)
{
success: true,
diff --git a/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb
index 586e9fad0..7be5647a8 100644
--- a/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb
+++ b/enterprise/app/services/internal/account_analysis/content_evaluator_service.rb
@@ -1,48 +1,59 @@
-class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService
- def initialize
- super()
+class Internal::AccountAnalysis::ContentEvaluatorService
+ include Integrations::LlmInstrumentation
- @model = 'gpt-4o-mini'.freeze
+ def initialize
+ Llm::Config.initialize!
end
def evaluate(content)
return default_evaluation if content.blank?
- begin
- response = send_to_llm(content)
- evaluation = handle_response(response)
- log_evaluation_results(evaluation)
- evaluation
- rescue StandardError => e
- handle_evaluation_error(e)
+ moderation_result = instrument_moderation_call(instrumentation_params(content)) do
+ RubyLLM.moderate(content.to_s[0...10_000])
end
+
+ build_evaluation(moderation_result)
+ rescue StandardError => e
+ handle_evaluation_error(e)
end
private
- def send_to_llm(content)
- Rails.logger.info('Sending content to LLM for security evaluation')
- @client.chat(
- parameters: {
- model: @model,
- messages: llm_messages(content),
- response_format: { type: 'json_object' }
- }
- )
+ def instrumentation_params(content)
+ {
+ span_name: 'llm.internal.content_moderation',
+ model: 'text-moderation-latest',
+ input: content,
+ feature_name: 'content_evaluator'
+ }
end
- def handle_response(response)
- return default_evaluation if response.nil?
+ def build_evaluation(result)
+ flagged = result.flagged?
+ categories = result.flagged_categories
- parsed = JSON.parse(response.dig('choices', 0, 'message', 'content').strip)
-
- {
- 'threat_level' => parsed['threat_level'] || 'unknown',
- 'threat_summary' => parsed['threat_summary'] || 'No threat summary provided',
- 'detected_threats' => parsed['detected_threats'] || [],
- 'illegal_activities_detected' => parsed['illegal_activities_detected'] || false,
- 'recommendation' => parsed['recommendation'] || 'review'
+ evaluation = {
+ 'threat_level' => flagged ? determine_threat_level(result) : 'safe',
+ 'threat_summary' => flagged ? "Content flagged for: #{categories.join(', ')}" : 'No threats detected',
+ 'detected_threats' => categories,
+ 'illegal_activities_detected' => categories.any? { |c| c.include?('violence') || c.include?('self-harm') },
+ 'recommendation' => flagged ? 'review' : 'approve'
}
+
+ log_evaluation_results(evaluation)
+ evaluation
+ end
+
+ def determine_threat_level(result)
+ scores = result.category_scores
+ max_score = scores.values.max || 0
+
+ case max_score
+ when 0.8.. then 'critical'
+ when 0.5..0.8 then 'high'
+ when 0.2..0.5 then 'medium'
+ else 'low'
+ end
end
def default_evaluation(error_type = nil)
@@ -56,18 +67,11 @@ class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAi
end
def log_evaluation_results(evaluation)
- Rails.logger.info("LLM evaluation - Level: #{evaluation['threat_level']}, Illegal activities: #{evaluation['illegal_activities_detected']}")
+ Rails.logger.info("Moderation evaluation - Level: #{evaluation['threat_level']}, Threats: #{evaluation['detected_threats'].join(', ')}")
end
def handle_evaluation_error(error)
Rails.logger.error("Error evaluating content: #{error.message}")
default_evaluation('evaluation_failure')
end
-
- def llm_messages(content)
- [
- { role: 'system', content: 'You are a security analysis system that evaluates content for potential threats and scams.' },
- { role: 'user', content: Internal::AccountAnalysis::PromptsService.threat_analyser(content.to_s[0...10_000]) }
- ]
- end
end
diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb
index 504685e3a..a5a91cf24 100644
--- a/enterprise/app/services/llm/base_ai_service.rb
+++ b/enterprise/app/services/llm/base_ai_service.rb
@@ -14,8 +14,6 @@ class Llm::BaseAiService
setup_temperature
end
- # Returns a configured RubyLLM chat instance.
- # Subclasses can override model/temperature via instance variables or pass them explicitly.
def chat(model: @model, temperature: @temperature)
RubyLLM.chat(model: model).with_temperature(temperature)
end
diff --git a/enterprise/app/services/llm/legacy_base_open_ai_service.rb b/enterprise/app/services/llm/legacy_base_open_ai_service.rb
index ede9f0d8f..f431830db 100644
--- a/enterprise/app/services/llm/legacy_base_open_ai_service.rb
+++ b/enterprise/app/services/llm/legacy_base_open_ai_service.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly.
-# New features should use Llm::BaseAiService with RubyLLM instead.
-# This class will be removed once all services are migrated to RubyLLM.
+# Only used for PDF/file operations that require OpenAI's files API:
+# - Captain::Llm::PdfProcessingService (files.upload for assistants)
+# - Captain::Llm::PaginatedFaqGeneratorService (uses file_id from uploaded files)
+#
+# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
class Llm::LegacyBaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'
diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb
index d3cc91376..1676dd862 100644
--- a/enterprise/app/services/messages/audio_transcription_service.rb
+++ b/enterprise/app/services/messages/audio_transcription_service.rb
@@ -1,4 +1,8 @@
-class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
+class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
+ include Integrations::LlmInstrumentation
+
+ WHISPER_MODEL = 'whisper-1'.freeze
+
attr_reader :attachment, :message, :account
def initialize(attachment)
@@ -46,7 +50,7 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
temp_file_path = fetch_audio_file
- response_text = nil
+ transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
@@ -56,14 +60,23 @@ class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
temperature: 0.4
}
)
-
- response_text = response['text']
+ transcribed_text = response['text']
end
FileUtils.rm_f(temp_file_path)
- update_transcription(response_text)
- response_text
+ update_transcription(transcribed_text)
+ transcribed_text
+ end
+
+ def instrumentation_params(file_path)
+ {
+ span_name: 'llm.messages.audio_transcription',
+ model: WHISPER_MODEL,
+ account_id: account&.id,
+ feature_name: 'audio_transcription',
+ file_path: file_path
+ }
end
def update_transcription(transcribed_text)
diff --git a/enterprise/lib/captain/agent.rb b/enterprise/lib/captain/agent.rb
deleted file mode 100644
index f0b511115..000000000
--- a/enterprise/lib/captain/agent.rb
+++ /dev/null
@@ -1,137 +0,0 @@
-require 'openai'
-class Captain::Agent
- attr_reader :name, :tools, :prompt, :persona, :goal, :secrets
-
- def initialize(name:, config:)
- @name = name
- @prompt = construct_prompt(config)
- @tools = prepare_tools(config[:tools] || [])
- @messages = config[:messages] || []
- @max_iterations = config[:max_iterations] || 10
- @llm = Captain::LlmService.new(api_key: config[:secrets][:OPENAI_API_KEY])
- @logger = Rails.logger
-
- @logger.info(@prompt)
- end
-
- def execute(input, context)
- setup_messages(input, context)
- result = {}
- @max_iterations.times do |iteration|
- push_to_messages(role: 'system', content: 'Provide a final answer') if iteration == @max_iterations - 1
-
- result = @llm.call(@messages, functions)
- handle_llm_result(result)
-
- break if result[:stop]
- end
-
- result[:output]
- end
-
- def register_tool(tool)
- @tools << tool
- end
-
- private
-
- def setup_messages(input, context)
- if @messages.empty?
- push_to_messages({ role: 'system', content: @prompt })
- push_to_messages({ role: 'assistant', content: context }) if context.present?
- end
- push_to_messages({ role: 'user', content: input })
- end
-
- def handle_llm_result(result)
- if result[:tool_call]
- tool_result = execute_tool(result[:tool_call])
- push_to_messages({ role: 'assistant', content: tool_result })
- else
- push_to_messages({ role: 'assistant', content: result[:output] })
- end
- result[:output]
- end
-
- def execute_tool(tool_call)
- function_name = tool_call['function']['name']
- arguments = JSON.parse(tool_call['function']['arguments'])
-
- tool = @tools.find { |t| t.name == function_name }
- tool.execute(arguments, {})
- rescue StandardError => e
- "Tool execution failed: #{e.message}"
- end
-
- def construct_prompt(config)
- return config[:prompt] if config[:prompt]
-
- <<~PROMPT
- Persona: #{config[:persona]}
- Objective: #{config[:goal]}
-
- Guidelines:
- - Persistently work towards achieving the stated objective without deviation.
- - Use only the provided tools to complete the task. Avoid inventing or assuming function names.
- - Set `'stop': true` once the objective is fully achieved.
- - DO NOT return tool usage as the final result.
- - If sufficient information is available to deliver result, compile and present it to the user.
- - Always return a final result and ENSURE the final result is formatted in Markdown.
-
- Output Structure:
-
- 1. **Tool Usage:**
- - If a relevant function is identified, call it directly without unnecessary explanations.
-
- 2. **Final Answer:**
- When ready to provide a complete response, follow this JSON format:
-
- ```json
- {
- "thought_process": "Explain the reasoning and steps taken to arrive at the final result.",
- "result": "Provide the complete response in clear, structured text.",
- "stop": true
- }
- PROMPT
- end
-
- def prepare_tools(tools = [])
- tools.map do |_, tool|
- Captain::Tool.new(
- name: tool['name'],
- config: {
- description: tool['description'],
- properties: tool['properties'],
- secrets: tool['secrets'],
- implementation: tool['implementation']
- }
- )
- end
- end
-
- def functions
- @tools.map do |tool|
- properties = {}
- tool.properties.each do |property_name, property_details|
- properties[property_name] = {
- type: property_details[:type],
- description: property_details[:description]
- }
- end
- required = tool.properties.select { |_, details| details[:required] == true }.keys
- {
- type: 'function',
- function: {
- name: tool.name,
- description: tool.description,
- parameters: { type: 'object', properties: properties, required: required }
- }
- }
- end
- end
-
- def push_to_messages(message)
- @logger.info("\n\n\nMessage: #{message}\n\n\n")
- @messages << message
- end
-end
diff --git a/enterprise/lib/captain/llm_service.rb b/enterprise/lib/captain/llm_service.rb
deleted file mode 100644
index f0faa1002..000000000
--- a/enterprise/lib/captain/llm_service.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-require 'openai'
-
-class Captain::LlmService
- def initialize(config)
- @client = OpenAI::Client.new(
- access_token: config[:api_key],
- log_errors: Rails.env.development?
- )
- @logger = Rails.logger
- end
-
- def call(messages, functions = [])
- openai_params = {
- model: 'gpt-4o',
- response_format: { type: 'json_object' },
- messages: messages
- }
- openai_params[:tools] = functions if functions.any?
-
- response = @client.chat(parameters: openai_params)
- handle_response(response)
- rescue StandardError => e
- handle_error(e)
- end
-
- private
-
- def handle_response(response)
- if response['choices'][0]['message']['tool_calls']
- handle_tool_calls(response)
- else
- handle_direct_response(response)
- end
- end
-
- def handle_tool_calls(response)
- tool_call = response['choices'][0]['message']['tool_calls'][0]
- {
- tool_call: tool_call,
- output: nil,
- stop: false
- }
- end
-
- def handle_direct_response(response)
- content = response.dig('choices', 0, 'message', 'content').strip
- parsed = JSON.parse(content)
-
- {
- output: parsed['result'] || parsed['thought_process'],
- stop: parsed['stop'] || false
- }
- rescue JSON::ParserError => e
- handle_error(e, content)
- end
-
- def handle_error(error, content = nil)
- @logger.error("LLM call failed: #{error.message}")
- @logger.error(error.backtrace.join("\n"))
- @logger.error("Content: #{content}") if content
-
- { output: 'Error occurred, retrying', stop: false }
- end
-end
diff --git a/enterprise/lib/captain/tool.rb b/enterprise/lib/captain/tool.rb
deleted file mode 100644
index b3ed6f69f..000000000
--- a/enterprise/lib/captain/tool.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-class Captain::Tool
- class InvalidImplementationError < StandardError; end
- class InvalidSecretsError < StandardError; end
- class ExecutionError < StandardError; end
-
- REQUIRED_PROPERTIES = %w[name description properties secrets].freeze
-
- attr_reader :name, :description, :properties, :secrets, :implementation, :memory
-
- def initialize(name:, config:)
- @name = name
- @description = config[:description]
- @properties = config[:properties]
- @secrets = config[:secrets] || []
- @implementation = config[:implementation]
- @memory = config[:memory] || {}
- end
-
- def register_method(&block)
- @implementation = block
- end
-
- def execute(input, provided_secrets = {})
- validate_secrets!(provided_secrets)
- validate_input!(input)
-
- raise ExecutionError, 'No implementation registered' unless @implementation
-
- instance_exec(input, provided_secrets, memory, &@implementation)
- rescue StandardError => e
- raise ExecutionError, "Execution failed: #{e.message}"
- end
-
- private
-
- def validate_config!(config)
- missing_keys = REQUIRED_PROPERTIES - config.keys
- return if missing_keys.empty?
-
- raise InvalidImplementationError,
- "Missing required properties: #{missing_keys.join(', ')}"
- end
-
- def validate_secrets!(provided_secrets)
- required_secrets = secrets.map!(&:to_sym)
- missing_secrets = required_secrets - provided_secrets.keys
-
- return if missing_secrets.empty?
-
- raise InvalidSecretsError, "Missing required secrets: #{missing_secrets.join(', ')}"
- end
-
- def validate_input!(input)
- properties.each do |property, constraints|
- validate_property!(input, property, constraints)
- end
- end
-
- def validate_property!(input, property, constraints)
- value = input[property.to_sym]
-
- raise ArgumentError, "Missing required property: #{property}" if constraints['required'] && value.nil?
-
- true
- end
-end
diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb
index aff41fbce..34281c738 100644
--- a/lib/integrations/llm_instrumentation.rb
+++ b/lib/integrations/llm_instrumentation.rb
@@ -7,14 +7,6 @@ module Integrations::LlmInstrumentation
include Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationSpans
- PROVIDER_PREFIXES = {
- 'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
- 'anthropic' => %w[claude-],
- 'google' => %w[gemini-],
- 'mistral' => %w[mistral- codestral-],
- 'deepseek' => %w[deepseek-]
- }.freeze
-
def instrument_llm_call(params)
return yield unless ChatwootApp.otel_enabled?
@@ -66,16 +58,57 @@ module Integrations::LlmInstrumentation
end
end
- def determine_provider(model_name)
- return 'openai' if model_name.blank?
+ def instrument_embedding_call(params)
+ return yield unless ChatwootApp.otel_enabled?
- model = model_name.to_s.downcase
-
- PROVIDER_PREFIXES.each do |provider, prefixes|
- return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
+ instrument_with_span(params[:span_name] || 'llm.embedding', params) do |span, track_result|
+ set_embedding_span_attributes(span, params)
+ result = yield
+ track_result.call(result)
+ set_embedding_result_attributes(span, result)
+ result
end
+ end
- 'openai'
+ def instrument_audio_transcription(params)
+ return yield unless ChatwootApp.otel_enabled?
+
+ instrument_with_span(params[:span_name] || 'llm.audio.transcription', params) do |span, track_result|
+ set_audio_transcription_span_attributes(span, params)
+ result = yield
+ track_result.call(result)
+ set_transcription_result_attributes(span, result)
+ result
+ end
+ end
+
+ def instrument_moderation_call(params)
+ return yield unless ChatwootApp.otel_enabled?
+
+ instrument_with_span(params[:span_name] || 'llm.moderation', params) do |span, track_result|
+ set_moderation_span_attributes(span, params)
+ result = yield
+ track_result.call(result)
+ set_moderation_result_attributes(span, result)
+ result
+ end
+ end
+
+ def instrument_with_span(span_name, params, &)
+ result = nil
+ executed = false
+ tracer.in_span(span_name) do |span|
+ track_result = lambda do |r|
+ executed = true
+ result = r
+ end
+ yield(span, track_result)
+ end
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
+ raise unless executed
+
+ result
end
private
@@ -86,36 +119,4 @@ module Integrations::LlmInstrumentation
nil
end
-
- def setup_span_attributes(span, params)
- set_request_attributes(span, params)
- set_prompt_messages(span, params[:messages])
- set_metadata_attributes(span, params)
- end
-
- def record_completion(span, result)
- if result.respond_to?(:content)
- span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
- span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
- elsif result.is_a?(Hash)
- set_completion_attributes(span, result) if result.is_a?(Hash)
- end
- end
-
- def set_request_attributes(span, params)
- provider = determine_provider(params[:model])
- span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
- span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
- span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
- end
-
- def set_prompt_messages(span, messages)
- messages.each_with_index do |msg, idx|
- role = msg[:role] || msg['role']
- content = msg[:content] || msg['content']
-
- span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
- span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
- end
- end
end
diff --git a/lib/integrations/llm_instrumentation_completion_helpers.rb b/lib/integrations/llm_instrumentation_completion_helpers.rb
new file mode 100644
index 000000000..50e071119
--- /dev/null
+++ b/lib/integrations/llm_instrumentation_completion_helpers.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Integrations::LlmInstrumentationCompletionHelpers
+ include Integrations::LlmInstrumentationConstants
+
+ private
+
+ def set_embedding_span_attributes(span, params)
+ span.set_attribute(ATTR_GEN_AI_PROVIDER, determine_provider(params[:model]))
+ span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
+ span.set_attribute('embedding.input_length', params[:input]&.length || 0)
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
+ set_common_span_metadata(span, params)
+ end
+
+ def set_audio_transcription_span_attributes(span, params)
+ span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
+ span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
+ span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
+ set_common_span_metadata(span, params)
+ end
+
+ def set_moderation_span_attributes(span, params)
+ span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
+ span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
+ span.set_attribute('moderation.input_length', params[:input]&.length || 0)
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
+ set_common_span_metadata(span, params)
+ end
+
+ def set_common_span_metadata(span, params)
+ span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
+ span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
+ end
+
+ def set_embedding_result_attributes(span, result)
+ span.set_attribute('embedding.dimensions', result&.length || 0) if result.is_a?(Array)
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, "[#{result&.length || 0} dimensions]")
+ end
+
+ def set_transcription_result_attributes(span, result)
+ transcribed_text = result.respond_to?(:text) ? result.text : result.to_s
+ span.set_attribute('transcription.length', transcribed_text&.length || 0)
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, transcribed_text.to_s)
+ end
+
+ def set_moderation_result_attributes(span, result)
+ span.set_attribute('moderation.flagged', result.flagged?) if result.respond_to?(:flagged?)
+ span.set_attribute('moderation.categories', result.flagged_categories.to_json) if result.respond_to?(:flagged_categories)
+ output = {
+ flagged: result.respond_to?(:flagged?) ? result.flagged? : nil,
+ categories: result.respond_to?(:flagged_categories) ? result.flagged_categories : []
+ }
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output.to_json)
+ end
+
+ def set_completion_attributes(span, result)
+ set_completion_message(span, result)
+ set_usage_metrics(span, result)
+ set_error_attributes(span, result)
+ end
+
+ def set_completion_message(span, result)
+ message = result[:message] || result.dig('choices', 0, 'message', 'content')
+ return if message.blank?
+
+ span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
+ span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
+ end
+
+ def set_usage_metrics(span, result)
+ usage = result[:usage] || result['usage']
+ return if usage.blank?
+
+ span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
+ span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
+ span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
+ end
+
+ def set_error_attributes(span, result)
+ error = result[:error] || result['error']
+ return if error.blank?
+
+ span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
+ span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
+ end
+end
diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb
index c03e3e9c7..129092ed4 100644
--- a/lib/integrations/llm_instrumentation_helpers.rb
+++ b/lib/integrations/llm_instrumentation_helpers.rb
@@ -2,38 +2,52 @@
module Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationConstants
+ include Integrations::LlmInstrumentationCompletionHelpers
+
+ def determine_provider(model_name)
+ return 'openai' if model_name.blank?
+
+ model = model_name.to_s.downcase
+
+ LlmConstants::PROVIDER_PREFIXES.each do |provider, prefixes|
+ return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
+ end
+
+ 'openai'
+ end
private
- def set_completion_attributes(span, result)
- set_completion_message(span, result)
- set_usage_metrics(span, result)
- set_error_attributes(span, result)
+ def setup_span_attributes(span, params)
+ set_request_attributes(span, params)
+ set_prompt_messages(span, params[:messages])
+ set_metadata_attributes(span, params)
end
- def set_completion_message(span, result)
- message = result[:message] || result.dig('choices', 0, 'message', 'content')
- return if message.blank?
-
- span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
- span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
+ def record_completion(span, result)
+ if result.respond_to?(:content)
+ span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
+ span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
+ elsif result.is_a?(Hash)
+ set_completion_attributes(span, result)
+ end
end
- def set_usage_metrics(span, result)
- usage = result[:usage] || result['usage']
- return if usage.blank?
-
- span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
- span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
- span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
+ def set_request_attributes(span, params)
+ provider = determine_provider(params[:model])
+ span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
+ span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
+ span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
- def set_error_attributes(span, result)
- error = result[:error] || result['error']
- return if error.blank?
+ def set_prompt_messages(span, messages)
+ messages.each_with_index do |msg, idx|
+ role = msg[:role] || msg['role']
+ content = msg[:content] || msg['content']
- span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
- span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
+ span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
+ span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
+ end
end
def set_metadata_attributes(span, params)
diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb
index 9199aaa10..824b6aa4a 100644
--- a/lib/integrations/llm_instrumentation_spans.rb
+++ b/lib/integrations/llm_instrumentation_spans.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'opentelemetry_config'
-require_relative 'llm_instrumentation_constants'
module Integrations::LlmInstrumentationSpans
include Integrations::LlmInstrumentationConstants
diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb
index 0a0dfa8ae..2f0180701 100644
--- a/lib/integrations/openai/processor_service.rb
+++ b/lib/integrations/openai/processor_service.rb
@@ -77,21 +77,22 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
end
def add_message_if_within_limit(character_count, message, messages, in_array_format)
- if valid_message?(message, character_count)
- add_message_to_list(message, messages, in_array_format)
- character_count += message.content.length
+ content = message.content_for_llm
+ if valid_message?(content, character_count)
+ add_message_to_list(message, messages, in_array_format, content)
+ character_count += content.length
[character_count, true]
else
[character_count, false]
end
end
- def valid_message?(message, character_count)
- message.content.present? && character_count + message.content.length <= TOKEN_LIMIT
+ def valid_message?(content, character_count)
+ content.present? && character_count + content.length <= TOKEN_LIMIT
end
- def add_message_to_list(message, messages, in_array_format)
- formatted_message = format_message(message, in_array_format)
+ def add_message_to_list(message, messages, in_array_format, content)
+ formatted_message = format_message(message, in_array_format, content)
messages.prepend(formatted_message)
end
@@ -99,17 +100,17 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
in_array_format ? [] : ''
end
- def format_message(message, in_array_format)
- in_array_format ? format_message_in_array(message) : format_message_in_string(message)
+ def format_message(message, in_array_format, content)
+ in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
end
- def format_message_in_array(message)
- { role: (message.incoming? ? 'user' : 'assistant'), content: message.content }
+ def format_message_in_array(message, content)
+ { role: (message.incoming? ? 'user' : 'assistant'), content: content }
end
- def format_message_in_string(message)
+ def format_message_in_string(message, content)
sender_type = message.incoming? ? 'Customer' : 'Agent'
- "#{sender_type} #{message.sender&.name} : #{message.content}\n"
+ "#{sender_type} #{message.sender&.name} : #{content}\n"
end
def summarize_body
diff --git a/lib/llm_constants.rb b/lib/llm_constants.rb
new file mode 100644
index 000000000..054b775a5
--- /dev/null
+++ b/lib/llm_constants.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module LlmConstants
+ DEFAULT_MODEL = 'gpt-4.1-mini'
+ DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
+ PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
+
+ OPENAI_API_ENDPOINT = 'https://api.openai.com'
+
+ PROVIDER_PREFIXES = {
+ 'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
+ 'anthropic' => %w[claude-],
+ 'google' => %w[gemini-],
+ 'mistral' => %w[mistral- codestral-],
+ 'deepseek' => %w[deepseek-]
+ }.freeze
+end
diff --git a/lib/open_ai_constants.rb b/lib/open_ai_constants.rb
deleted file mode 100644
index 2094567a7..000000000
--- a/lib/open_ai_constants.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-
-module OpenAiConstants
- DEFAULT_MODEL = 'gpt-4.1-mini'
- DEFAULT_ENDPOINT = 'https://api.openai.com'
- DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
- PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
-end
diff --git a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
index 73b67ee27..c3e5eab1c 100644
--- a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
before do
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
- .with(document.content, document.account.locale_english_name)
+ .with(document.content, document.account.locale_english_name, account_id: document.account_id)
.and_return(faq_generator)
allow(faq_generator).to receive(:generate).and_return(faqs)
end
@@ -52,7 +52,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
before do
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
- .with(spanish_document.content, 'portuguese')
+ .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
.and_return(spanish_faq_generator)
allow(spanish_faq_generator).to receive(:generate).and_return(faqs)
end
@@ -61,7 +61,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
described_class.new.perform(spanish_document)
expect(Captain::Llm::FaqGeneratorService).to have_received(:new)
- .with(spanish_document.content, 'portuguese')
+ .with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
end
end
diff --git a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
index ee993da37..0ab7f37bf 100644
--- a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
@@ -4,49 +4,42 @@ RSpec.describe Captain::Llm::ConversationFaqService do
let(:captain_assistant) { create(:captain_assistant) }
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
let(:service) { described_class.new(captain_assistant, conversation) }
- let(:client) { instance_double(OpenAI::Client) }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:sample_faqs) do
+ [
+ { 'question' => 'What is the purpose?', 'answer' => 'To help users.' },
+ { 'question' => 'How does it work?', 'answer' => 'Through AI.' }
+ ]
+ end
+ let(:mock_response) do
+ instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
+ end
before do
- create(:installation_config) { create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') }
- allow(OpenAI::Client).to receive(:new).and_return(client)
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
+ allow(RubyLLM).to receive(:chat).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_params).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#generate_and_deduplicate' do
- let(:sample_faqs) do
- [
- { 'question' => 'What is the purpose?', 'answer' => 'To help users.' },
- { 'question' => 'How does it work?', 'answer' => 'Through AI.' }
- ]
- end
-
- let(:openai_response) do
- {
- 'choices' => [
- {
- 'message' => {
- 'content' => { faqs: sample_faqs }.to_json
- }
- }
- ]
- }
- end
-
context 'when successful' do
before do
- allow(client).to receive(:chat).and_return(openai_response)
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
end
- it 'creates new FAQs' do
+ it 'creates new FAQs for valid conversation content' do
expect do
service.generate_and_deduplicate
end.to change(captain_assistant.responses, :count).by(2)
end
- it 'saves the correct FAQ content' do
+ it 'saves FAQs with pending status linked to conversation' do
service.generate_and_deduplicate
expect(
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
@@ -63,6 +56,11 @@ RSpec.describe Captain::Llm::ConversationFaqService do
it 'returns an empty array without generating FAQs' do
expect(service.generate_and_deduplicate).to eq([])
end
+
+ it 'does not call the LLM API' do
+ expect(RubyLLM).not_to receive(:chat)
+ service.generate_and_deduplicate
+ end
end
context 'when finding duplicates' do
@@ -70,9 +68,6 @@ RSpec.describe Captain::Llm::ConversationFaqService do
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
end
let(:similar_neighbor) do
- # Using OpenStruct here to mock as the Captain:AssistantResponse does not implement
- # neighbor_distance as a method or attribute rather it is returned directly
- # from SQL query in neighbor gem
OpenStruct.new(
id: 1,
question: existing_response.question,
@@ -82,87 +77,78 @@ RSpec.describe Captain::Llm::ConversationFaqService do
end
before do
- allow(client).to receive(:chat).and_return(openai_response)
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([similar_neighbor])
end
- it 'filters out duplicate FAQs' do
+ it 'filters out duplicate FAQs based on embedding similarity' do
expect do
service.generate_and_deduplicate
end.not_to change(captain_assistant.responses, :count)
end
end
- context 'when OpenAI API fails' do
+ context 'when LLM API fails' do
before do
- allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
+ allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error'))
+ allow(Rails.logger).to receive(:error)
end
- it 'handles the error and returns empty array' do
- expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
+ it 'returns empty array and logs the error' do
+ expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
expect(service.generate_and_deduplicate).to eq([])
end
end
context 'when JSON parsing fails' do
let(:invalid_response) do
- {
- 'choices' => [
- {
- 'message' => {
- 'content' => 'invalid json'
- }
- }
- ]
- }
+ instance_double(RubyLLM::Message, content: 'invalid json')
end
before do
- allow(client).to receive(:chat).and_return(invalid_response)
+ allow(mock_chat).to receive(:ask).and_return(invalid_response)
end
- it 'handles JSON parsing errors' do
+ it 'handles JSON parsing errors gracefully' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
expect(service.generate_and_deduplicate).to eq([])
end
end
+
+ context 'when response content is nil' do
+ let(:nil_response) do
+ instance_double(RubyLLM::Message, content: nil)
+ end
+
+ before do
+ allow(mock_chat).to receive(:ask).and_return(nil_response)
+ end
+
+ it 'returns empty array' do
+ expect(service.generate_and_deduplicate).to eq([])
+ end
+ end
end
- describe '#chat_parameters' do
- it 'includes correct model and response format' do
- params = service.send(:chat_parameters)
- expect(params[:model]).to eq('gpt-4o-mini')
- expect(params[:response_format]).to eq({ type: 'json_object' })
- end
-
- it 'includes system prompt and conversation content' do
- allow(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator).and_return('system prompt')
- params = service.send(:chat_parameters)
-
- expect(params[:messages]).to include(
- { role: 'system', content: 'system prompt' },
- { role: 'user', content: conversation.to_llm_text }
- )
- end
-
+ describe 'language handling' do
context 'when conversation has different language' do
let(:account) { create(:account, locale: 'fr') }
let(:conversation) do
- create(:conversation, account: account,
- first_reply_created_at: Time.zone.now)
+ create(:conversation, account: account, first_reply_created_at: Time.zone.now)
end
- it 'includes system prompt with correct language' do
- allow(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator)
+ before do
+ allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
+ allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
+ end
+
+ it 'uses account language for system prompt' do
+ expect(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator)
.with('french')
- .and_return('system prompt in french')
+ .at_least(:once)
+ .and_call_original
- params = service.send(:chat_parameters)
-
- expect(params[:messages]).to include(
- { role: 'system', content: 'system prompt in french' }
- )
+ service.generate_and_deduplicate
end
end
end
diff --git a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
index 7d799dc18..003d5b715 100644
--- a/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
@@ -4,58 +4,40 @@ RSpec.describe Captain::Llm::FaqGeneratorService do
let(:content) { 'Sample content for FAQ generation' }
let(:language) { 'english' }
let(:service) { described_class.new(content, language) }
- let(:client) { instance_double(OpenAI::Client) }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:sample_faqs) do
+ [
+ { 'question' => 'What is this service?', 'answer' => 'It generates FAQs.' },
+ { 'question' => 'How does it work?', 'answer' => 'Using AI technology.' }
+ ]
+ end
+ let(:mock_response) do
+ instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
+ end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
- allow(OpenAI::Client).to receive(:new).and_return(client)
+ allow(RubyLLM).to receive(:chat).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_params).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#generate' do
- let(:sample_faqs) do
- [
- { 'question' => 'What is this service?', 'answer' => 'It generates FAQs.' },
- { 'question' => 'How does it work?', 'answer' => 'Using AI technology.' }
- ]
- end
-
- let(:openai_response) do
- {
- 'choices' => [
- {
- 'message' => {
- 'content' => { faqs: sample_faqs }.to_json
- }
- }
- ]
- }
- end
-
context 'when successful' do
- before do
- allow(client).to receive(:chat).and_return(openai_response)
- allow(Captain::Llm::SystemPromptsService).to receive(:faq_generator).and_return('system prompt')
- end
-
- it 'returns parsed FAQs' do
+ it 'returns parsed FAQs from the LLM response' do
result = service.generate
expect(result).to eq(sample_faqs)
end
- it 'calls OpenAI client with chat parameters' do
- expect(client).to receive(:chat).with(parameters: hash_including(
- model: 'gpt-4o-mini',
- response_format: { type: 'json_object' },
- messages: array_including(
- hash_including(role: 'system'),
- hash_including(role: 'user', content: content)
- )
- ))
+ it 'sends content to LLM with JSON response format' do
+ expect(mock_chat).to receive(:with_params).with(response_format: { type: 'json_object' }).and_return(mock_chat)
service.generate
end
- it 'calls SystemPromptsService with correct language' do
- expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language)
+ it 'uses SystemPromptsService with the specified language' do
+ expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language).at_least(:once).and_call_original
service.generate
end
end
@@ -63,23 +45,57 @@ RSpec.describe Captain::Llm::FaqGeneratorService do
context 'with different language' do
let(:language) { 'spanish' }
- before do
- allow(client).to receive(:chat).and_return(openai_response)
- end
-
it 'passes the correct language to SystemPromptsService' do
- expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish')
+ expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish').at_least(:once).and_call_original
service.generate
end
end
- context 'when OpenAI API fails' do
+ context 'when LLM API fails' do
before do
- allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
+ allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error'))
+ allow(Rails.logger).to receive(:error)
end
- it 'handles the error and returns empty array' do
- expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
+ it 'returns empty array and logs the error' do
+ expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
+ expect(service.generate).to eq([])
+ end
+ end
+
+ context 'when response content is nil' do
+ let(:nil_response) { instance_double(RubyLLM::Message, content: nil) }
+
+ before do
+ allow(mock_chat).to receive(:ask).and_return(nil_response)
+ end
+
+ it 'returns empty array' do
+ expect(service.generate).to eq([])
+ end
+ end
+
+ context 'when JSON parsing fails' do
+ let(:invalid_response) { instance_double(RubyLLM::Message, content: 'invalid json') }
+
+ before do
+ allow(mock_chat).to receive(:ask).and_return(invalid_response)
+ end
+
+ it 'logs error and returns empty array' do
+ expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
+ expect(service.generate).to eq([])
+ end
+ end
+
+ context 'when response is missing faqs key' do
+ let(:missing_key_response) { instance_double(RubyLLM::Message, content: '{"data": []}') }
+
+ before do
+ allow(mock_chat).to receive(:ask).and_return(missing_key_response)
+ end
+
+ it 'returns empty array via KeyError rescue' do
expect(service.generate).to eq([])
end
end
diff --git a/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb b/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb
index a2735bd69..4dec4c051 100644
--- a/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb
+++ b/spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb
@@ -4,40 +4,38 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
let(:website_url) { 'https://example.com' }
let(:service) { described_class.new(website_url) }
let(:mock_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
- let(:mock_client) { instance_double(OpenAI::Client) }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:business_info) do
+ {
+ 'business_name' => 'Example Corp',
+ 'suggested_assistant_name' => 'Alex from Example Corp',
+ 'description' => 'You specialize in helping customers with business solutions and support'
+ }
+ end
+ let(:mock_response) do
+ instance_double(RubyLLM::Message, content: business_info.to_json)
+ end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(Captain::Tools::SimplePageCrawlService).to receive(:new).and_return(mock_crawler)
- allow(service).to receive(:client).and_return(mock_client)
- allow(service).to receive(:model).and_return('gpt-3.5-turbo')
+ allow(RubyLLM).to receive(:chat).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_params).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#analyze' do
- context 'when website content is available and OpenAI call is successful' do
- let(:openai_response) do
- {
- 'choices' => [{
- 'message' => {
- 'content' => {
- 'business_name' => 'Example Corp',
- 'suggested_assistant_name' => 'Alex from Example Corp',
- 'description' => 'You specialize in helping customers with business solutions and support'
- }.to_json
- }
- }]
- }
- end
-
+ context 'when website content is available and LLM call is successful' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
- allow(mock_client).to receive(:chat).and_return(openai_response)
end
- it 'returns success' do
+ it 'returns successful analysis with extracted business info' do
result = service.analyze
expect(result[:success]).to be true
@@ -49,14 +47,19 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
favicon_url: 'https://example.com/favicon.ico'
)
end
+
+ it 'uses low temperature for deterministic analysis' do
+ expect(mock_chat).to receive(:with_temperature).with(0.1).and_return(mock_chat)
+ service.analyze
+ end
end
- context 'when website content is errored' do
+ context 'when website content fetch raises an error' do
before do
allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
end
- it 'returns error' do
+ it 'returns error response' do
result = service.analyze
expect(result[:success]).to be false
@@ -64,14 +67,14 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
end
end
- context 'when website content is unavailable' do
+ context 'when website content is empty' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('')
allow(mock_crawler).to receive(:page_title).and_return('')
allow(mock_crawler).to receive(:meta_description).and_return('')
end
- it 'returns error' do
+ it 'returns error for unavailable content' do
result = service.analyze
expect(result[:success]).to be false
@@ -79,21 +82,57 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
end
end
- context 'when OpenAI error' do
+ context 'when LLM call fails' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
- allow(mock_client).to receive(:chat).and_raise(StandardError, 'API error')
+ allow(mock_chat).to receive(:ask).and_raise(StandardError, 'API error')
end
- it 'returns error' do
+ it 'returns error response with message' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('API error')
end
end
+
+ context 'when LLM returns invalid JSON' do
+ let(:invalid_response) { instance_double(RubyLLM::Message, content: 'not valid json') }
+
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
+ allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
+ allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
+ allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
+ allow(mock_chat).to receive(:ask).and_return(invalid_response)
+ end
+
+ it 'returns error for parsing failure' do
+ result = service.analyze
+
+ expect(result[:success]).to be false
+ expect(result[:error]).to eq('Failed to parse business information from website')
+ end
+ end
+
+ context 'when URL normalization is needed' do
+ let(:website_url) { 'example.com' }
+
+ before do
+ allow(mock_crawler).to receive(:body_text_content).and_return('Welcome')
+ allow(mock_crawler).to receive(:page_title).and_return('Example')
+ allow(mock_crawler).to receive(:meta_description).and_return('Description')
+ allow(mock_crawler).to receive(:favicon_url).and_return(nil)
+ end
+
+ it 'normalizes URL by adding https prefix' do
+ result = service.analyze
+
+ expect(result[:data][:website_url]).to eq('https://example.com')
+ end
+ end
end
end
diff --git a/spec/enterprise/services/internal/account_analysis/content_evaluator_service_spec.rb b/spec/enterprise/services/internal/account_analysis/content_evaluator_service_spec.rb
index d7bf26c35..f959a9afa 100644
--- a/spec/enterprise/services/internal/account_analysis/content_evaluator_service_spec.rb
+++ b/spec/enterprise/services/internal/account_analysis/content_evaluator_service_spec.rb
@@ -3,60 +3,103 @@ require 'rails_helper'
RSpec.describe Internal::AccountAnalysis::ContentEvaluatorService do
let(:service) { described_class.new }
let(:content) { 'This is some test content' }
+ let(:mock_moderation_result) do
+ instance_double(
+ RubyLLM::Moderation,
+ flagged?: false,
+ flagged_categories: [],
+ category_scores: {}
+ )
+ end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(RubyLLM).to receive(:moderate).and_return(mock_moderation_result)
end
describe '#evaluate' do
- context 'when content is present' do
- let(:llm_response) do
- {
- 'choices' => [
- {
- 'message' => {
- 'content' => {
- 'threat_level' => 'low',
- 'threat_summary' => 'No significant threats detected',
- 'detected_threats' => ['minor_concern'],
- 'illegal_activities_detected' => false,
- 'recommendation' => 'approve'
- }.to_json
- }
- }
- ]
- }
- end
-
- before do
- allow(service).to receive(:send_to_llm).and_return(llm_response)
- allow(Rails.logger).to receive(:info)
- end
-
- it 'returns the evaluation results' do
+ context 'when content is safe' do
+ it 'returns safe evaluation with approval recommendation' do
result = service.evaluate(content)
expect(result).to include(
- 'threat_level' => 'low',
- 'threat_summary' => 'No significant threats detected',
- 'detected_threats' => ['minor_concern'],
+ 'threat_level' => 'safe',
+ 'threat_summary' => 'No threats detected',
+ 'detected_threats' => [],
'illegal_activities_detected' => false,
'recommendation' => 'approve'
)
end
it 'logs the evaluation results' do
+ expect(Rails.logger).to receive(:info).with('Moderation evaluation - Level: safe, Threats: ')
service.evaluate(content)
+ end
+ end
- expect(Rails.logger).to have_received(:info).with('LLM evaluation - Level: low, Illegal activities: false')
+ context 'when content is flagged' do
+ let(:mock_moderation_result) do
+ instance_double(
+ RubyLLM::Moderation,
+ flagged?: true,
+ flagged_categories: %w[harassment hate],
+ category_scores: { 'harassment' => 0.6, 'hate' => 0.3 }
+ )
+ end
+
+ it 'returns flagged evaluation with review recommendation' do
+ result = service.evaluate(content)
+
+ expect(result).to include(
+ 'threat_level' => 'high',
+ 'threat_summary' => 'Content flagged for: harassment, hate',
+ 'detected_threats' => %w[harassment hate],
+ 'illegal_activities_detected' => false,
+ 'recommendation' => 'review'
+ )
+ end
+ end
+
+ context 'when content contains violence' do
+ let(:mock_moderation_result) do
+ instance_double(
+ RubyLLM::Moderation,
+ flagged?: true,
+ flagged_categories: ['violence'],
+ category_scores: { 'violence' => 0.9 }
+ )
+ end
+
+ it 'marks illegal activities detected for violence' do
+ result = service.evaluate(content)
+
+ expect(result['illegal_activities_detected']).to be true
+ expect(result['threat_level']).to eq('critical')
+ end
+ end
+
+ context 'when content contains self-harm' do
+ let(:mock_moderation_result) do
+ instance_double(
+ RubyLLM::Moderation,
+ flagged?: true,
+ flagged_categories: ['self-harm'],
+ category_scores: { 'self-harm' => 0.85 }
+ )
+ end
+
+ it 'marks illegal activities detected for self-harm' do
+ result = service.evaluate(content)
+
+ expect(result['illegal_activities_detected']).to be true
end
end
context 'when content is blank' do
let(:blank_content) { '' }
- it 'returns the default evaluation without calling the LLM' do
- expect(service).not_to receive(:send_to_llm)
+ it 'returns default evaluation without calling moderation API' do
+ expect(RubyLLM).not_to receive(:moderate)
result = service.evaluate(blank_content)
@@ -70,34 +113,16 @@ RSpec.describe Internal::AccountAnalysis::ContentEvaluatorService do
end
end
- context 'when LLM response is nil' do
- before do
- allow(service).to receive(:send_to_llm).and_return(nil)
- end
-
- it 'returns the default evaluation' do
- result = service.evaluate(content)
-
- expect(result).to include(
- 'threat_level' => 'unknown',
- 'threat_summary' => 'Failed to complete content evaluation',
- 'detected_threats' => [],
- 'illegal_activities_detected' => false,
- 'recommendation' => 'review'
- )
- end
- end
-
context 'when error occurs during evaluation' do
before do
- allow(service).to receive(:send_to_llm).and_raise(StandardError.new('Test error'))
- allow(Rails.logger).to receive(:error)
+ allow(RubyLLM).to receive(:moderate).and_raise(StandardError.new('Test error'))
end
- it 'logs the error and returns default evaluation with error type' do
+ it 'logs error and returns default evaluation with error type' do
+ expect(Rails.logger).to receive(:error).with('Error evaluating content: Test error')
+
result = service.evaluate(content)
- expect(Rails.logger).to have_received(:error).with('Error evaluating content: Test error')
expect(result).to include(
'threat_level' => 'unknown',
'threat_summary' => 'Failed to complete content evaluation',
@@ -107,5 +132,68 @@ RSpec.describe Internal::AccountAnalysis::ContentEvaluatorService do
)
end
end
+
+ context 'with threat level determination' do
+ it 'returns critical for scores >= 0.8' do
+ mock_result = instance_double(
+ RubyLLM::Moderation,
+ flagged?: true,
+ flagged_categories: ['harassment'],
+ category_scores: { 'harassment' => 0.85 }
+ )
+ allow(RubyLLM).to receive(:moderate).and_return(mock_result)
+
+ result = service.evaluate(content)
+ expect(result['threat_level']).to eq('critical')
+ end
+
+ it 'returns high for scores between 0.5 and 0.8' do
+ mock_result = instance_double(
+ RubyLLM::Moderation,
+ flagged?: true,
+ flagged_categories: ['harassment'],
+ category_scores: { 'harassment' => 0.65 }
+ )
+ allow(RubyLLM).to receive(:moderate).and_return(mock_result)
+
+ result = service.evaluate(content)
+ expect(result['threat_level']).to eq('high')
+ end
+
+ it 'returns medium for scores between 0.2 and 0.5' do
+ mock_result = instance_double(
+ RubyLLM::Moderation,
+ flagged?: true,
+ flagged_categories: ['harassment'],
+ category_scores: { 'harassment' => 0.35 }
+ )
+ allow(RubyLLM).to receive(:moderate).and_return(mock_result)
+
+ result = service.evaluate(content)
+ expect(result['threat_level']).to eq('medium')
+ end
+
+ it 'returns low for scores below 0.2' do
+ mock_result = instance_double(
+ RubyLLM::Moderation,
+ flagged?: true,
+ flagged_categories: ['harassment'],
+ category_scores: { 'harassment' => 0.15 }
+ )
+ allow(RubyLLM).to receive(:moderate).and_return(mock_result)
+
+ result = service.evaluate(content)
+ expect(result['threat_level']).to eq('low')
+ end
+ end
+
+ context 'with content truncation' do
+ let(:long_content) { 'a' * 15_000 }
+
+ it 'truncates content to 10000 characters before sending to moderation' do
+ expect(RubyLLM).to receive(:moderate).with('a' * 10_000).and_return(mock_moderation_result)
+ service.evaluate(long_content)
+ end
+ end
end
end
From 2bd8e76886e23f90ee67e36be8ce8ba746c7c37c Mon Sep 17 00:00:00 2001
From: Muhsin Keloth strong), italic (em), links (link), images (image).
## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/d325ab86ca514c6d8f90dfe72a8928dd ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin KelothMessage signatures only support bold, italic, links, and images.
Other formatting options available in the editor (lists, code blocks, strike-through, etc.) do not apply to signatures and are ignored.
+ {{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }} +
++ {{ t('YEAR_IN_REVIEW.LOADING') }} +
++ {{ t('YEAR_IN_REVIEW.ERROR') }} +
+{{ error }}
+ ++ {{ + t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', { + count: busiestDay.count, + }) + }} + {{ performanceHelperText }} +
++ {{ performanceHelperText }} +
++ {{ personalityMessage }} +
+;3tCgBE+-Onx`7wd--kTA~S#{meS~t5heOj*g4b6ZavGNW@D?sD3g}AKk~8 zzGkd@aru0O|0maD?=Hl)p|Jp|nlc4{MB5s4D#@xG z3;yP;rHqv)j^m^Zy9&PhRJIu-YxYMzJX@=<=4^=shTp6e$y+eMM37C_Trv0U+Yb?f zpv?}VxPH_+_?@|}ljSNB6U9UgX>Zay8B}?lLClERLHaIpIEtAA3Hv2U)dx)z)+sHR zNxIy8&@%2fPmBnsK_-xu8%$a~4=cYV)06iFN>&XEe|c>cbfzxJ)z@7H+HCh|A6tXE zLT5T@V(IsD(2SCwd*V&(pa{E4Z$jQXnxv4Ctl53%@c#s}tbqeCeldG kfmU;X7;w04zV)Xovd+E3oP`EiTZZf@rbw8zOIK*}D)gnuU z`t9#;_rK5&=X+P&I_cyjt@XySdS}HJrOw{x()(?GQP4^wz2bNqpG$;4OYVUjXWprh z)?t>`0(`!!;hB^M$}6-OBD!b1l++gC7czUDCVk+xZ>AFHRYcSotJ}>4MjoU5jaez$ z_5W0MG~~b~EBbk$<^8be9iDO~aL^V{a8aIF#J#L&@5ePwK@KK6nE>Q?2vpijB}-ja zQEJwz0VT>^9A@UINL2MI)}4225L9?|%%2aska{o@%beX;N1Fk$ !F zfuS#4d&)LFfzmu_2piI3>P!UAq>rIBxYc+M-3B=6Lr4yPsyGxs>~8S)$ny(Aw+6;Z z _V1BJ*&dCT-BVm8ULyV2Yu{q$Yek>YV(?=u-tY^$ zRpUPQPEetmSAP-b%I|l@kvyiB8fvV_MEvq! H+~rMlGf*y6I~OnylhFYY2J|HIm9)UOX=n`-nx zSl|jX)%{&Oi<9(Sjk;PWpRwY~zm3&U(7+0@KT HePpVw-o19S`d2`yL7Es5ms&XNW3Q* z`>(0{SkQ$leZ>RSFUKA}_XKrjv0}e09cXH JicrkWsbh&PBm~JuBDEa z@pN_UuliUFy(cKZ6s n*2ZR#bz;53LxlOc#_uU_CNQ`{uQEY|*{e?9Uc-5ShO`ru24-hDnGDa+$y6 zK`JJFBdal8A&$K#jNq*tg-lmx-mNH}oovDnx>@yve2gq`jh=7)nU~Is?Y|YpE?mlb zmYg~NbS8pmBDo8umsMAZHB{KVp#f;wDMkYa%Z_bGUaw+p9dARL!r9?d&EPFH&}Zp0 z (|9bc77n z3kfLEbUz_$;WCuCSLA!q q(9Aai=EX8GV z9^>4$&hOT^0T+osTu|qubWO)3-C14>!+?S7c!WFh0|Z!^^ jnQr>`0mSZmWfJBB #?O z{aGg(S6K|E8F;yh2uEbk_N|rLOIv-zPc89;n{V_z{nPqC$&G<@xL;*Ft?&2-+@tP^ z-zIX`<0ChLC0H0_Owy+r!YnfW@;^Ibm@~`EteQHgD8%k&c(;fATn|;F&7nCt&gbY^ ziD75R-J&g?& _0O?lzkdrFCEd!M7UT2oWUaXiBq7*bkVY#-BXH(%d}6r& zGh{f^_47OJ>*jZ`hqD~pmt*?gE!Soemb%?|UW2@ y5zMhI)0xnif1?-I H5tm;3}j^i-X(t-Bbo>zbCdM}Q8bSqugD3TB@;xPC28brNOr1% zn_CUNPHQB=joX$VKEJvUWW@Z!P_QE9!-OEMppMw5H?>7R#0Ja;Y4skz6oXAK+5{}T z+wl+bYtwkCZx3g!k}rHQF?QqIw#@zpJ^7IRZ@%c&c$Ab+?V%; a5#9>xgi@Cs-k}(m)avauixKY! `R+Q(|8n;#1Q5UjK=~oAaF)_lV!mi+N6$*{}~bo zGSN WAcq_MOG4cTcpzWv|B#K)jY0iGY3oK^7~ VaRAz1Az zC>#I7d)VIRb(U7Mr26Bv?nTQaf+J9ptSm8hg>6pI@U5;EI|8QJ+!rPdoTaLx4L*dQ zo9rhM4B^M-gYqs*c=L8d?oxbU*Gkh^7jI#OxoR8;XnwEKf42m&1)#jCqAlY(;VcXv z*twlRN5g+Mw*DVSeH;Zvv Q|GOgE=>RNce8Y&IkmUUGRKg{t G6MP{h2wao+L1d8#?A zrZ7&s*4$5WjlE|pJ%qb3?IqPW5d_8vVrNB-*|aaM=CrhXC2R(YpH9|o><-fH5MJvj zPORKI{|A)7$1e#7<(+lw%4MWFs+O0egVNzQFbj{vDRtA*bdV7k{ss?r)Io^5U%yPb zqsaZQ3*$DS2RIeHu}t863bbUE3QRYIeYyR>9l0TK-6_J*V<>MHvfka8wMl=EuIi;G zGpczg&WMSiEAYJ}wCq5*u%m^7X4dqAciNxQqr8{pz{>3d5zWMfU^Z3mI $qtV2}^YA3whRa<3`?JO3rn;Wi$1&sKXl)sv$@ekhHPuWhM6mKHvhzq!HOaQmm0 zj6T?OTn>1IN%j%}xP6I@Bu4Ius;9f>S|z!Z08#)hyWMiWvVfZoO(r=wTEz5 zrost9ZE6Q84*p|f7ra)+0-rG4Fc2Csza3*iFfX PVyxJ1%(Aef%RY*Rp%n~MMgessiUYQ2aWiO9Xef7 ra?hNh^i>SIbKo9E5XY@D*ebQ-oQ1cp4FX`T}O{Mkvic#*Y~z zgy>>znRqvjmC %)tw10<%HlL)gbH*(mTF$ 63Nhqz4y5&N2@b2!^*_OK&soNAyXSQwcONta{~6}Lpf zL=)aE@=>3z-3`KwVArX?uz@Y*UgbzXDuTI~JJ1nK`U^k0EMLT>a3Mw&N>XFMxfn=) zoP13OnX9^;_e7+USAC33iY8>*;Y0r7$pih;tNs4Jro*sQ@* ph;8|IrSK4B@L bBti~TKt}dL z(PxJ_@;Q2bUVe96f2gYR=>L=ckKe*BRyj=x#`u$dOciWU@}cT%W<%;%xdMSBI|4Ot zcl{$NQl;=sLYPSkXL>hR&7sk#0k1y}c!iVWx3IZ$G~qAw6>7Ty1&Dv->}?$Eu)IEH z>*W0$7&7ey>b#&?via;J2h%r!77X6BDuRigBe`B7b{U{{w OfMCl4LZP&U%0MEJYgfP8oT zKBJ|;T{#z%vz-kG)+Ycvo$_f+_x^VK`$?b)<6t{cqc`#fLa8L<;WZmlYo8x>1Vcd$ zJ}K+9GQ4Ws4czKh6M7|@9QNH9qBEF-fqddY)u9ofFD$7c93ucn=F*-DUH3#n5RP -QYj<%r|1+9gD3}D{U$l++>2}4f8eRTUXZ1z-JW2Rn6OMe3$SUK5f@gnw}XvJhS z$W_2!DnRqYk3MGSC7 5HZL%jbUt*7ZxOrBy2IN! z{dUgbGQ~ #0fUWA4nT5dn=T;-Mf7b4%65%6CCw0>QLn0UxSjzrCHU zyTl59&0J8@%G>t`dN`^y71L>3JY1?oU(fmcvo!MHzRmsj6**OAR1TWs@%Hm%WoGfa z4&Q%c&GKn0;B?c{?@X}L_KRDMx#L*SNLc}icvoZgwcRMV)13m_=Nxpb2oD7otSDK} z_65PGfWRxS$iHnW|4wVC_?M5fGzc%YWqA>YwRyot#}PdjhqW~zNE_dSKhqUs=0%Nv zyIEuk-HzLkQ4w(6R1^c~)HI$LpOmpdZbg)sF6ilMtG||2>iu9ufOh?*cb4~7344WO z1myF6yA|#y&H|+93}CmthA00TQ;t7Wnq4$CY19DF%YwJ%Of#B%hxjm5>sDw`ZD12i zp!2&LVD X Mn-xBi>=Rh|M(W0Lg8^aeG^F>cS%VD=*bt=X zp>%^P_*eLyX 7dA~O;57ruOAr~bY)5V+_uPrXg|sJ~1TnS-EO5q&SfSSD zW(RJ-%#Da~S)kuhJt&2%6qke<;eU7B*3L =%COixEm z{+&+mCAIW1TL5fTpC5?5vcC8U+;|A|QdD^zTJrG0Q$X>D>(uk>Y17@(u|%3+`ql(s zXE1#F 1~;ygZ#WscAF5T9P*1;fov=z)ZE-TRUnpz&G2GPmIgj ziKNK-y=26BB(OANjna-cDxu^-bB{a+H0&$i`YK9^F(;qYv!~(n{5bZHWE=Or9-{Y_ z!0Mqo`9LF$x!}@U8f>$i@R-0Z53h`Lx>}MdNCs(AhDkP0wQOHj)x?QDeNiR70dI&= zG&(~+_?N8 t7TE(WeE$nLt_c8DGA-k^|6mOF*OD34C zTeVW&-@_CPM6#U99+XN2F!6_)gAORiJ#LRw19L_;2g+ulXAgnHCS1Y1kKK9!A*^$R zXN6nGBazSjD{YRZy5p9Q`ag49FWf btAneE@&5$AXs(s^GZzonGT$sFUIM1STVw)eVK~4> 1T}+kdk_X%-wf=;OB^<_x1{*$X@f(0h(d*I&kB zAdVfugV#$MDuEMY9G0#zeiTqutV7FJ!@G>4ogCLB1xr{ozD-#!?nLY4csV2GTcnls zr9b|hnl!?$DcCh~RCc=2QaPO}sV>m?lU&K0uC-<;U8bA$4EFqKmKpGzn_0~ti%eyM zB$}~2&N@s1u9Tp~(C~>dz$ep^hB}~ub;3Q~kdB>Y!@@UE9O*>vzb0S)X=fw7Gysa! zc4f^uy6DfJtNRJpEm;=jRjC52+^So6K53`+lT>-CsRJ|TULwUNc0u}&?eV27->)tL zE`!7?((k@g$&Q-5VWWCwKIL<<+im<$alM#s3)>5*$2rX&DuvTcQ(19r0m^rwq?p9wTy! z)wvE$r2`fXa|^O%g$4NH4uC$R1XK9WT~2ZM7S4PQX@h<;uYYe`Z`Q<~`nO(QG1Jpi zH$X*#F2Cnvh*sy=htHaKGso>BukypBlpOP`q%~r4n;Wu0bQA!{Oz9u`w9FhFJ)j1) z%b`cx5=sRXyiqU)lj`q7!^^eGNKbu5 B_bn{~h72W-W`0%W$k&>|+gJ_c38@^Sl8P!F_dKsQcp0vL zG#gbxju#?;_$<`trM=w|g`I2-=yR-i7Et5RUyg4AjN)s!e2%F%6czI<_UACC_#e23 zJkw$WMoFBi0vi&xkEwg fAMeaRdDR`IU0R-2pwQY&wwpwCP(xxg23Pd*R%zyyh$AaBFM))dEd9s1?tD;4^m zd)Vj&1}D@tKBXjE0~pQT>2T3+mKQ7d4nIX=>{X;(N`)IyT*6OG0-rcYTM$w``k$sw zWhRx~b|ovhDzYkREx>8*$%9z#l-~qrbZ!B3z4RfSN2mk(b?K#9?->NlvSw i+s?^%Grwkg2&Ry3}UNspbU`~FZ<=-<658)L4f z$u*;^0t<8mkz$18Os-TM10*9E*H^o=x6Q#V!lI}})k!WBUaQd_jQ3=@5siXVn>8bc zJNO-wl%(-k1Y-;K_HmwfJAwhJ_|t0O=5KoUYqIUG^^h<7HsEw25-YVsFLva67@}Y6 z^vk)%+ms0jxlP|+3cOvFHYYC16lxu^;^@!d?SJL4F5_yXDyD9iE&|t9Fk`C ;kE@G>ebdO5E)Q;b)*Sb=mI!U zEf%*`T;8p99g_PoSGprnaLWFfr99Ub -2A%ln zH-Gw-;<#I7$>*36;7HMStXuL0W28#2!nl`a=a1_ZQ@LYBAh!&6_Z1%g`5GPczT#J$ z8F0-ylkXLq4{D$=kZAw40Ge-BN5-)i-~RkjJ>D6QoWoTHEiR{>rzJATMkWR&Esfq$ zgjUS6#8I{LCm!-bYby61@EhL}e)fe{)60aItGHrd46;gl$IR^jo)=Atj61tUi-G6N zZVlG==So @JJ4gf5DIa{Z}eI9 z(_65PS ;BV7gDMk$f9o5TJd3=4MWZEx-5!@c*9`Jmv#F3c9D#q|W#npb`Pb zNTNHB+%8b_p+C080`{H_onwbRxM~J`yD6eeWMY5&%@IIoDGj&)HBIxC?IvJjMV+JM zTfPAP|La_X`9@YOIU~1gD^0kf44rS<38a7@hPb0<=XS+w^L5dWo<|(^lJ6mm I#96EZ+8j ziy^e$8V>S82?;;gIbOuZ!m_>O`wg5OkOfYDIZX*$?xy$Pe~y_ZT#??78ftvF(5W!) zui3dh=fwbC_Po`Su$SvI>l1#>8r8}T(?F*%sM%&`GpLC_AmTPJTqPJhP2RT>=6fNc zu0ihVTVZ~KFofnxKDKC?LaR_g$_RtG3A0eJ3L?;cxm<6=(;l&X{G_Bt8-7m)LM6^f z R zwA%hupM#~{+EL+R$|-xvcLHMH#sTzmzgxC{`vWdU`mK#IjydbF8LrkyS%GoHB0h5l zm#86gJ0ek;uhN98GaB;+8oKOh;Ox%qv`*LI>xzrvlkW!Kl>ALYPVWXod`7bElTWo2 z?jyfiBZ1#96LSe$cMg4Jdu`2WmiJ;U`5wW5weM)PjQz~_PRYj>t-5( J+wS6fDob2mz#m`5z!2(ykzW_<)6ZZ2{-69-gSeeC@9dUF zslK3VsEZdyn1utqXtER^&&kCiA{a)^%a&DKR_?~bY%EKil-p~y&oNBBOq4RtXg%L@ z04Mj@0YC5jMZ-qN$Cj;Gvk|QmZZqUyS;de )FTI%*R)_8+Dq1y?9Eip zVV}QA=X&^$e800>6s54Rz|VVf;rPe`N_3+erf1X#j!&sD(%b~_OlLpN89y$CrAfx^ zwH|q=itJ2eSW`>xQ~qhYI&>2!4%S@zWBvW;_6J-HFW{MuH&!4mh7ltwE3OP}{3$Vx zYlpZYGGFQNYEOx9W%+!_a&Em_gD{a4ZDlxd&z321&KYe!5VKYmrOp^SI&&T$?|1eY z0rYnjB^C4cG#I%> -p3chWi}7_R;#ya(?E^pnTWpqexEsPWCw^ zC~qkBp|omBcx?qEAW(nO#DYR4ZWBRR3Yo9R9IdwXFFvzA_o?GH`tRG&OSG|rsW4ND z4R&1W7>lyC*G{ x{!gag}5z> zy%P)2*&BMB4>bP&WrbdHi??FCD>wi8F%LAjgDF~8L>7Sg_L+D62JyLSu#D_+7vA&r z93hz?puU#w+zPOK;ie>L7s%j1gk`~O@?8SX82}Kx3|e=%%$GY$)|3QSaMSZYuKzI( z6iU@@N1rI~PUvmE+T%qEhsQx13{Rgw+YLC<$^z_0l(nB(-`#XMUt7WZogO=mJaEBF zpLmr3(Pj=bj}iDX;&0n&vE882tEthW^yIWk*)8{*f4-7lQlHCwsmDMnC~#H#8!<&o zaW>lR=o3{_Psp$UGSkEk`&1n}6nLE<)**HeguO%(PaKqj0oP`x{-D5oOV(ZgxW53r zRnE}b&_l5asj2H43jc$O_}g}Ztt?2m?ugvwXi!F1%q!M4| zd|V{v%RMcOh>)rgq5|%=G;`ULJ(qj60A1gQk^h{i*5|IGWiE?(qR9M^uHU@;9{m@& z6N;v+8=Xl^)2`4hVKI0YF;(PpAB_Vn7$xTU)FlCy&I2q_-u`V5xcGz?Pqim_)0EnY zNJO27ON9Q%%kRk7{Al3E82V z6lTVcLSZ2KZe$m=Cz(j)Ec5Ys2hV)O7h{!(iYIYVRpNhAX$WIcx|~9bx$8N?_bMvl z2`gFWfxT+yB~h4f b^(Z1d~{^{|zzsf3Q+W_{P){Vd(WnV-ataVv#8~ zSEWuxB%$s-q2Zb{Fg5lz`ONrPxQ{R)hN3w);bgv^pJ}rI#$s5Z#T+-S@x6)~@9o&k z&c|)sCilobh52%CQs?Nv7^0@Xi 2n|DYn#5A{8x}w68#2Re<@w zS`v~`2 S|cz6yi%0oI=Tk_gN< z@`GE64crvB;diFeyyf@@o6c@wqUj sWNVI4sqeexKf+UO>5_Z8(m2%g`1Mf1-H#sF1jrx>VyB* zD~XawPS5n~nIR~ziHq$jnE+ WN}X8M@ko0S zU&r1m(elg=3IRCDkWZDnN%^>WSV4d>-{3t1?;k~>;+RjAr-QB1{s1!+Q;sdjHxVU{ zXN)ObhIsMT2Q-uT{ePpNZi+e(_-W*GMl2%Ho}IIJ<-{rHw$;${xseYzBu~G#Id+C< zd7^PDC}GNwFWXVd$9;mQf!!+y46R3ht;E`W(xC=N)BRina^ h_mz;IQAd@>)5x;Oi|27pGDbTCw+Ac+b8oj3BS;k(a^_+`E>D9rYOWdbd3>8ODA z7GkBQ?jSRkD1K)^{1YQ>{rbv$&LKljbJK!%6Cq!=t5msWB2tMWUye{LG+dQnC8iiG zf_3}31}_=$!Hkti7{i4B&O2*6gp!XfO9sIVstrn8+wCBq<99Un=7l>7YB)%iSOjq+ zpFw!X5@qpdSAJN;H@6FcMz1BvR}U`0*bH*wiF_3ju4)YVa lEkncD&(JtHssYm%!0dlP@6lv4w<0@M _)qyCO@MqvxPz>QG!$n{joptm9Z3{FisF= zvMTI%bl?hTX-aVj{wc#{YxJ!v&|jH)3Ev51uc__uSo<<|nQtUfBqhjaGL*YvmWVy} zb^*>I6p*EX$fr> #bx3xlr6pW8j3QqG#Wswn z7jT2{fXLUyV(Xvnm0P`ENvhZ_mK}H_Uq$oalap;S8EfzReGx=?+&Z&`w3CyaBHuBZ zg**~RW7U;Aie#X8rz}QfEOp^VTK;5j<(wZ-5v@w_TRL``FSWHY5$2{OjXpiioiHm7 zxq4eRyj*65B3}!!3oKkEIDd)`M%E=y_j0G08$6kl5;S}(Fq(QJpQfJWpWl4?=eOfQ z*Jc^I)eoOg;s4UqFQG%x{fEl#-inb=+ +D jrE)c36wrA=)1@Nc*FkhWngVZs@_)ArY>LojqAy7{ql*9x}SPy-cN|M zmp}-8W)sWz`$H7qTbjvvpt(h2ZV&6QT+hWH-7H6T#N4M4{%`xxOSEf559Iv*(fsCP z82K_x#)ptk(g_$B`IZ6jdk~NJa;IBGd^UO|Sh^NO$=(zBGJ>0bwe=ZVYw!C> xT zRv6t*BA5A$B=RA;|45fmQ&cCkFrp`KoSkhkTG1cc9X;928%_+cr`HfspM$Z ` zqOY9Kj&_UfQ$C}sK;a&!(B^Po a< z?CAC&FNcV)HQAQ(@;K)x@|D;cL&(<)CSzhE-%H?lgC*vBxpU?E+EMgTc%l6eEeW=s zKCR7cpbDR^<1X8&8aJh9^Fc{?$d51o`9UW6iW?tV*%Vtw;A27OiOjr33(2$z5sq^D zeiv?QWGN4| eILYBmyCl*QRaM2egW(W?-! z5-*}PUN^V7Yw;%nLO2^29!?$Mg&wwO<;iFN!jP{9 q0tBI%o<~K#%uFfs0mzJ{_kGrQT2oFW#u*;tMxlo6Kt3wW6AXK8rzddStf4qV z#I?c_P=b-xqAcFUw5nF9Oxw&YmigXoBL10UvmF(L#1uQLK}0ojj(yD}zr-|K4PpX< ze3fAME9N|3#w~fempf(h*R|CV$2fq_u^bbXDQh$RtcM^o7kqe>SW`-@Qjf1ku|?z~ zoRCtxQ_KBpH`h)OODYIUOf0DzgR~us9TsJ=iD*^pd=N?8=ur#r9BRaw+p*)`Q~U1{ zxw#%hR3qouU7xtHJc^m79r+h$v|#*FMQYkLc430$BOea-mqqSgbx{Qe+8)mLFZ_^C zo9U+>g3QoR@he~6M$K21c+W}^8WLT|cg=&t@y1TCta{=W#t#uvoDkutw?tzGY55$} zqAbR=s!Uw0Yv`t%SnlmIH$#Y*0Q6W5BdT?j3RTOIFF||vTHfK%N#^LtSF(Y$6=3o{ z YAE>alIXDely3#!V1kn2rBt|Q#_emjoA=z3z(VrLtHyV>pf&w6 z6}(5bRph(j{(5F?#4#E;p)YclCP6K1=5pvJvnfx+NM-Rik+LlU$&m!ITR2*+ yzNgMNKLz5^{o|B2#>!0G z0Nk2V7H?wCKc(^x_AKU11NE^c`>}2%{Itr B(T{UE^iiYix7v3oXJi7n zXiy>9F7j;zfZkl<+};4#xRqGIK(y(jg|++2h+fCie2WsY{~WUwu9mYrs^K(1aqSHJ z&ur#a;F)G&dV3;yX7a>yKja&8Wj^vX3cT-wM?NiT#m(^15g|#s0Udrn(q~Eejy;eM z7ds83!$6}j>(+@RMm?PmCO_GlI@a{Ng)j2uX510IM-tlZ&LH2qS2SigtL(_vx?cp4 z!%Fk*DtrE;os @<*XYAoorl?O~=EBu5Ty`%vBv@Y_GI#A=V znH>2ly!h2{RFl}T=^)5gLrY%mdtAVjZ!PwL$S3()dhR6@5^mWGVpJX=E)b;YcizZH z@U&i%^L^o`6~oBq =2<0Wp;Aip2!V^cm9`2=Do z*4@tfnpx&^A%@ZcBDX1K+}7U6mzwKTN|3Miy|F#8ZUnKZqvkX o_n znfbA`Rd}o2RGZH+2_gG0l9G+MW_RjkZkG{V+%$k&8z-9M&uEtUTo3*7fdP5;LHG|- z=A*c-5~S6v8TGQ9>d?v@LwVH=1l@rm!snavts4r6d|J@l1Whyp{H72TZqpET!R&*W zXm3+8d+kOG0b4d6COY!17GDyx#^#cVj6nb1wS|{Ftj~$xympZ7i7UrELIU8d672?a zRX%3YM~zeMCQq?KloGtTsY<7UQdbpFaw4LKD#gf`3exJ5bGQ<(84^zApU@kw@ez$* zzxW=PmKkY)k*|<6HvtpIkgp{ImZ?J+w2@lOPDH}K^T*CaF~c <>{C9HU}DHuNt&CybnX#fQg=8}fz`6X z2VPue{=v!YI1t2)1JQ?M=!*ru6~{FjG)$Yhv4k{pse3l1Xv`fg9mICz=3BhFYU23! zrMHd^unfpdlBoR%&E}5uHixfqJ0#8!XmF6vLd0Zc+G@qv^Aw @0}b+JI#PaK4tExiL?_JvWy)|{Sn|H^Gv2(iSiRrWXd8w0r7p_(q?WAZii+! zvJ!B?KB&XKF+ipjBVR5k&Kz(NPz%-RE)w#o5`77R8vMlGx%) 9 zu?u*a$qU0avw fyVDR~8fo~P$OQ3vTmF+Jbbzf?I4So^8{VmxJQ+Z_dDUr`t`^brsZ~k?E z_YJvh0?&0-R3wQ8(wYX2e5LzDYm!7gDc$+%sypF7Opp$FiR`V6cn2=|%8;v;fE^Bw z`DXy+w XL0w(Av?ht9tG8YG3UvY1J!Q}H!M9sB;h RQuse?UQ|AbrM%^Stb!a-bKCaE@Q4Sr@oZ%v1Hi0uy zeWcmpS}wm^Zk*$yuGbU$N`sVqBUB<%X`bHkwNY-pi7}YX4d*Y0#9%u&Kp zaraIh0dm{yY%!1DHB7z&iB}0zWEoKz+(#O-?B(BEv-2DmbR}D#kvLU%3HcICC=VL( z(K@CIwE0ToG!@rKKv?Po@(z>uZEW%pb52Wk@Nwq21sMfc@(s?^Au~FalOb&28CJ05 zyN%f96Pif}Ng<4Uk5!-+=sNM87zR72&6gT=Xg@PA);swc)p4OGU&Y2y`tfStnn?g% z !if@I9YOJP$ms_un9W#f9J 4o zjI5`!4FNae<>5QrJ&QAL-rq4_fTXvs$8NX`f=ki2 z{K#>1UG++m &$KUlH|J%-Rxej1g~8MvKCiR z@YVfn!hiLe+_7iA0DBvkAzu&YQcg)flCHW16P?Ta#fGvH2zVj+O0f8+$@bmT`VifA zWgM2{<{*yxF*Z|}001BWNkl )QW!c(}%#azN&cgow=Pl%gI+bWuuN0 zswwp!B;;#kV2DzVI%q$*dO`ASLd8xwyQX!5Xu;9U^&iiR|C^rx`z~B|@IUiMgnaj8 zeB^^c0st$Xt{Q(&`Y5UUxX_TV28(~KBHN!E>uruGn88TCB;NerJi>ehDzU_x4|poS z<)puEIe&bTlW%?>=aQXBE2zLDA>UF42F=xg)9&F#-Mt+7cEQLfXV ^!#O841n|tSV&9~iJqSz!MUluK!=l$YZ zYi_rOyO33LvGMHrrn`{e1D{?+4rMEbd?nVbpvec$I4pc4W^r^CQ nkwy%7!h=COIzBfi#KjjfB-=5xT)f%#)GF{K>t@|_)*tHF2VtA}SX`R03b z+w!J05Sm*;z9L#S&-=wjYi^cz3T4&YCA9MG94$zj&xye{@-?nWwzQ2dv0==TyxkTa ziOS ?wiruGU&$7@{Qx)-lmi**p6Ay7D=y zI_bONbEsb`037+|d-JVecijCvK;&a);th31!>Q&YI45r)-zL=kSj#>51hx4Vai{A& z(vcw_G8?yGUWqF4k*GY%@?l-&m3Wty^aZ^AB-!CL>!#eZEHvbs-SIJld~Itb-!fWa zaAh15&8_5Xe4o#> @@^0UQjUB_@J`^MICwwbMJjgs{?bWGzIkR{NyJ$jF0YEn*9*E}s;kwH ziKY>UJ4Fw$hE_gj!8O$8D _Z zq{qs>qToHQ^0Jb?4ryty -6*lP-* B(M|_#@H sKC&AG zP2LT7Y$vOwR`NZH>baBLW<2@BPcfe(U#lpt`b5dWzt?-D0%9(9juwGb#T)Ld1k$C& z)Z9F4dVoHC6)~~)5Mb>r`Apjjr${oQ$Wkj3`>Pl|+*+ZlyfZErl=Ov$+3s}YgWqXz zVyvXt@rvsJ9gt5?9~VYm=skC2CJsUY z`rbn+A4@)bxG7bTkDZ7-L%jf8LN}4xd>r{6(a&4Sw_oU!z9(t<@JB()_1!8(vC~_` zIePc%BbpRhik-O$qeHwI)0S`Z^@5pQ!UlI0wA4OgDc7G_liog}1t8)_mE0`i3_n{H z`4UByHBjETn1ftG#hp7P`S9T;&GFG(jgU6q!A`z3Qk##P;|DSc;vsNgyrbP5yUm(w?I>&05L|Lh*-~1*| tx8zs~y z@hJ5ZSqT!zn=u0UMn#o%5jer*LXy4*G~zPk%W)7~DagmE%me1BxR@uR@?E?@BrxEu zk$l8C0`H@weE$6S3qcN6C{u|#bZ?!gs )c8{D!fVd-*sSGBQ3c}0FV0vLQdnE7 zhZV3A{>COF`G~hE`5(8Ifx-KC?)V&xBi~85q#thX-hF0kY2Ki&3{do6n?jqu@H%pc z>fi!CXD4gK0Nvc*npr0lS$p`ogvqDtw)J)^%eCsE))ksQgDVhsclTQIb+V}kEBUzk z$}Ofc6%~d)QZuNvwfPDw`KWhFUPnp!{CjnD|NT}#FwsNq0+?(?$$1ecndf-3GCArY z;+;Hq<~OhzvQ_C7rU~?~Z1`WND~Q!Ns*F$UuY&%bsTdN0e6;l z?ywZ*$>M31F}e&ka8iG1RvO&GWnL~fKO!lVDYssF)k;B3&}8^?36rl;)qgts>N|De zX^gJ%eiK*VhuBF=r^%-{u+^~_RUonXnu2_(dL{)R-vI^}yp)t;Z3)yn-+}Ff-G nrIL(}^_sM#WTshI}SpYZQbLnhgG@TKziycfGQb?-jZG@_OeH z&NGgyiq;Ga`R>3{wDa3J_gGeB>3%d;Ssmfnp+Ixtxw#b;o&s<~z-zGKq9$MBAM_{* z@^$!AZbFbru%=5q@@eu7TIPM`ud|10o#X*FB>5EYG^ogzn0#%tPNh({fEsS4UxzoO z9&CZ|NtXZkGB`Lk{*fV3rXJr-Mc;>g+SL5$tQJ>9D+w-1bL$mlUUgME(6~_Vbmnh8 z5JOyr!hMF&gGhC~+}1qb(;J$|S@bdHPyY(db<**cB%f-=Xu!!=n0&2`LB3G3wg&%9 zrEXXHDUOnFpg%61@BH47*@jk|DF qri(fo*penyBmu&TFI$Vz zQ_zW}O5LvXKg5GAS{%jt2)fgy;BV}(h)QmBE%?6BPW>ys3a+hlP#$yB-cLp#%HNRX zzA2py^T~Nc@>Tx7M2s(w3gJ40SlR08KdrTlotc`*Z{rU#f8y7JZg`s{U#BtR0nfim zi?5Zc rc-Zk8aA;)Q%< 6&SQ(4J3nyf3?Uf7UJ&;gh{mm;6;pb7=~^2olX zIR-I{@lFFhtHTGk#9X*-+4hlO%H v>Ny2dJY?)+Q#AMUQEId0>KF0jCYyfIQF2e*Yqec(n(aT45EmSq)g zD_J? OH)!MS;JVc}>{i2{!qp8S=I zgKs8!=o6ju*XfZcV}#&k^nQ*IlRn40Y*p{BzuYK3!5iy+!t;3xcJ}NCr};?#G?>tq z`y9xBSVM;pS0D0~p#D>mom4$*UDX!I-jy9*1?z0C43) ^al>6Yr_nH1&I5QN2bQYendE zZ?(;$z0`~wm?KD4kj$iH;CZik1o<+N04_wn1Ql(`WzBi(s!)2lbHjUhi(t|M$OoRA zYK?s7fUg~x1T>^VLG5X9!O02r6228XEJ@(=o}1u7qI+MJL@%i%>HGDo-hWamP?8h} z5Odpu!Zf5iE2CdcAZKkh+l4onO?}+3hHl)rN%H^+UOe*Eymk3l;`P%zUl9={fP5WR z^|D63Bf!^? sH4Yg& z_aoof!`~sExB}v}X5j~MpP%0500D~1GYVR8t;Lg{emj2Ovloh6#ge|8PyhPkc=^S{ z7-mUQzIf92_^10liQbQ6zf+s$#j$&xV9!$ P2y0OU*X!h+k^&P_rQ(v(I8oheP22SpyS z>13;Cs`rg1ed2MOa%W^PC6CUAXN^Pq5%xTZ;=OMG4y zH@M74B42N!PgGlAd{=Jxi+rM>=|{dA3>U`8cLVV? R0$%X}&2 zRYp>}mz7?p{p{W%pX`)UKk~I8Vdy=S3`D+D YM+JD`5g=jPZ@nl1o^P33zjcq79(tJcZH~szFMP-c?P(Z!?80MfQt#lr%7%Ui zSm|PNUB(@fN_&wnMPqPnihLK4n jysZj<;YzJB3LkZ7ZM)z(UZ$pdDx@8+ik2Dqsc8t}zQEvpqcM{9`+*iMYV z(fg0Mg_@#iw=MF;B%lW3rb3|xsJxWKo`!-hk`yX1qZ<)A3lNfjHPb4|WD0$$S^kKp z6;iUbIX(wvSn;cRKp4 OkV?LB0f=&eR#@D)=2J K@JV234w28zkPlXUl1r=DG`2W!M_(hI zY2o#Hxb%==4WS}8I*K9RfU#dU^0hDlrI2rGBzA^a>9yF};6Im8d_bvpp-Ob^YrO#` zFgMSL=AJlR1rE7d5|Os2b-6@!*Cm0m{~ND9G6Z#I$OpEQI8-F`K<05?b7malz?t?V z- 5XT)hv`CVm0%Ac z0$4l$CkNNXiF_GeK4FkAwG$LP8=Wbo|LTl81{#tN`4V{T6HDYf27OH(j#{G@z!u-r zFyp17PBr%}4+d kgnl84WcVZ~hSTg9wqWxdG&rx39@gm;}Ghlu1)HFy`dZ&-J;JP@GuO*l? zVP~zK*cr5o+moFhvw+ehe>ETSmGBw>$TvmB&Z!lxF+1M8!{Mu@n{`qhTNw-rR-FF$ ziQek1^iHUqBquwNA`pY2sAxtM4pF01R$CQSyI}6qyA~it*Tsc=M4694zT8sm43;83 zpX~4mv~_Cw (o#z*R(ov*wH3wRP!1%MNDYvP17O@(j&0a|cHy%6!aTAyct4=qkJXf%ZUK zr>c9$gM1ZUZUT@mL!(n^6xTAd%wT}!o*Cb)vw8CO54!Ap0a+q`s2J8|_Kgwr0XKIg zxVYHqSaGCVW{|w>Tg3%y>(YZZfLGz%XK=Y_jb=+O w_a z^^ r&=DlhPMndV34q&T2n95LKqe z-2#olz6O!ptb(3>Fmv;Nab-nL%del`iF_@3OBeDr3?qS&FS8XpC&5k-%{JOAHNL*Q zw0S8v^1(-!O~juCmndejP#j1%j*X&+HZ!Q O+fRE60*!csa1Dy6Vkv*`rRTwD`PN|o*soWC^-6P0C& z{Ys2OBT3(fd>O$$a=q2$U`iff{|80}qRk8v_#!3G*GpX-%`KWf2q;3e9HXOL)F@M! zZI1BfkY?Lxz`o8v?iLk0OCx1@h~z#l `CcPHhA7G&CJ}5 zd^JIk3%w13dkR8l^}aKT9@@-c4{guPd8ufo{N>C_xdXdSK77pkt$38FE{$3FjK7|Q z1t^bdoMPHTW|ZAK5MUk3ynk>8EHPF&kZ+(Q?FI})m1zxGuqZp&;F*^;?aO}TYl$E= z(|2ghFn>vmrXe@X3^FuptNAC$l=t0S`wQ42G3di#Lw{=8`YwHYS5iZ^AT)Fi+1q!} zW|-Jn8}ZO8Hp{IeF>2wpb$sEyq*l2FU%j=hA^QdT-G#p3mL}~7=bgw$?Pmc#ev@Ki zXJ=H!84M-49KA%tmn>^7U0IrO%pO6$748GtE|>M(fm=#fnS$B8YTDi?-8U{Yt2=0O z<34L;3~yqy>_EO0_uA>mCJRoiZLypoH>-n-4P@8$Hq4q!LmYP^AGMz)1^8Zx$SYy5 zpBqIFWoEDkRyhiTl4Yy?Vas?pg+y$TOj%j{{oLTwXPvd!=>k(F&lmK!sL$pvAh9zy z>L$vgv)qok!W74 +A&` zeqj|oPFM)IARpob3J1qpZ#zJZGKGK-C$)v{KDXu*{tGa0Hm{TUb>`MY-~AJdha$yJ z4G-<2v)qi0sK-HLZ-jCK(Oo@uD&dbE^!wZfFginp@ah7eewT(5`KbLYn*sPeh8LXJ zsfgmSru-Xhczj2Our&$?QM0a5jNKgI!PPKuB&yqdHn%{T-d^l <;F)tWqNyd |%}^4!J0R6b zjT#51u?7K_U@gcHCMB?mTBO+{8(9NVjcC9n{7zF%{L%zvT$(t&-- r(| ez=}o>1^1eveH^vH0}SdInpw3`ml*P)&aF8n`|O23X VQr4d{yiAF?gqg?v57Z9BE$ z cSe_!Vco$?-CGUgWT zsC{7-DOZ#M%Xw%ekUYxK8V&?j?QcFPS?1fk;0m4cQ#05If8S&mpl=oq_K;@lLcS$c z<{O#L39|0ddq$(}G3@4Wx!mdhlIS`$Aj}2OKT_K8O!(01BkX=+arNel@ao7L!uI&K zU!nQQcU4AH-$&RpwR-kf*>D_J&Heo(ihL!3RU_mh%Y2(TqX_x(?HJep$KmB}LtnVL z1#D4uAYZ|i`9@510jxVMuSlbKVHuxY{(phRKRsct0Mgjn6yAh;8dlFrla?B(*tz<0 z9+_o1scE+&b~-uFF=KAO9DlOQ;1{d~9HFx@o{o{3GiAO_ aASrnI~4{IhOufbTOCd@4m)roF!C40EM{KK>P z=ozt=8kyL6^VvL}x4mU |2b>-7=2TN)(rob1-GT&1kh|%X}kK zIfeY+_A^3}GTHiMa)n0>_`m~59mqG3iFcx8F9njZjrr~hu^m~^7M6o5R4W|}2y=?8 zTE%7eo~x|BdpLr vnS4#_AS64Y2002ZY5)NoHbUGs!pb?*bP8`ii7Xo zLZWytLOz-Cdo~8fEq_)T5at9a&SqRi_ouNOPuIP_zkKrecmEy#`s34o{LS}}h+vpH zT6yk$|MKx)e!kzQVw5?C(ZHzru0ly{B<1@z`aqrD8y%G5TST`IwRn$_k16wQBHw$q z%m aM1(iDe0g zfk||ygW#QA0?5Y>LC-na1;lfm-Jl+MW(SW7C%bt3da_e-!|sL xnw#?+0WTOfXT=)SsWfs+ z?S7^y9OvekPgLe}G9SJDI3^lc=BasV*l%1ll|RRh_ktHLBNu#GKF@r0F&G|Epg-e; ztj4y08hPTGF~+N~VlX%ccYwR0%9jpb%zZQ>;ngNr oD_i-rzuBKKd#q zw0%gJ&D`|iw3&O=(ijcbX$E0S>M-;DXHt5eC13clM6dz)bi^M;rVg`RUP+_Ih@fpT z_%0EQkMsW6C;Kms56a>S!xRZ|L-GA;BKxc}MBlPvG7-x*u2pfi1M_M{;AI=M-!u$% zamH| oSstGIaAu({ZO#mg#X~ny#LU z8{b;HOHCB0;O9HWxt@3CJ5xXSvWNAYVdiVgcE-VcjAK446ai %fHoz~(ftfgj%7S%Lh!Uc?duWm;vNE5G`5N@? zoS>M>9wW-;kBXa4ejkgBr@O?*e^mp(e8&pg5Te1&Cck@>A4O)2vdw)PPHs$)(_0x6 zf=u*#@hmYVfHwq=cbIM}{4j?zxx?pZK=KilO+u|=nFA!%d{m8WZV8I{&WMu?>h!!t z0OpU|lr=M}ky8XccZ4;vOuNjN3;cUj!teQG6hz204jE5(iBH~-Gt7J^@-iP@u*`GX zvgbhqB^8m(olakX>X!pY7MKuZboiFhAtM@eV$HWAt6+sx;*@=m&LH{v3M> h!84}>MPZBw00;9?3$U4XgLNt) z000uQNkl {K6C78Ca929W#<20WG^Hy1ssk*15Jl zzz#}AC~#z$QZxs*pruP`w{CEl?^KFaa`_cwL&?y437Kg d#9PQBufV0J@LF z&@XU|lTuds8;kjNHJOjsYtlwK#r7y9AC~$?Bm2r5sJUqr8(hrCD& >&$yVm3Z6sBn<^l!8%FLx*YA z!6}rc`_H$$CYdV69fzh y^m_$9xf}+&$Lw!Y`?JFapRE7AQ%-3RFweAvqIK_Q4-~0eF(l1sN zPto9!+RI-?*A4LP2G>*P;Il;5tlTCtV5WF%$i2WGFHoAqDlSZNsKGGK3|TY>@nKfq z>6fMEb{<+Le45@SLm?QRr?!R8Z!|=p;Jk0EmiesA*I^P1=3yShnt dA>nT zO6fH-frm1myMeD FFipt!h{G^Ehm_yKKLHa-x+!7p+^bcZXli|bKf2M zeid!kj(B*vz=T=j$|CZeYA-m18j^-S&I!La&_g~7G`ByI=5`)2-NJUJpn@1P6#o0t z8#fT8v0I4Ql${|Ujrn#pnXj+5nKM}8>lMQ?50ES~nEo_Y6~2M^)h$@&WA1&;Zu01L zzl5aqM^C=ulJT*V`e4#c5#=DPbhA9l*25T;tT9hu`X5AkfOZP0$?`zs5iFFsWcY3l zm#?zwF!1TiYmz2-uk8~|?zwJma2ezx1OVvMeYD{<#f1O0zV+lT{ND{*(c>Hkc2Nin z$27N9%Y3C+yVPUOU9TZmJS~W57SQ`$NVzZg5Eo2*F6LwIy-9m@uU2qrW1u;1l!Tj( zHl+ZT)IE1q>2f^& #)!A1^Wm`# 8{rbvT?6$@ {F(O2WG5s@kTYMS29>_FSbR+dxz0?E5cE7aK;)__h zrN%e)dFDQXf>0zrYs|yn`}&};k>Wt@?%^MI(WC=Qp#qFBrMl$CZSwtq zq}VFcI`<@d ymyKapxY?id5yE(YhgNlHke!BDd z5E>Wi8+`Xx?^}E}7zy#&nU5XcHVY$*9$u;8Nyiz5y8s|jc$QG_QFx#WHg*B^CTzb6 zBuzl-do8 Yp_g9>V`Q+!#C(1T zR3-cag>cFK%9D`Aw`~EU_M-Wqbe#Vg<^o>BEM3EQ7ZGDo )&t6n*DcW58HXM+I>FXU_qb zbJfz^+`-4KuSyZCHQf5&{cPvPWF*8DeX~Ach$;GR93*g?Z0GKefiYsb3r9NQ$A0#H zMOUTyQDB?`X8~{Q)S$lePMv+R+ ^ZzhLNckpGu;NT_iH6}CFg1s#& z(u-%E1t;tT$#gNJniT8tpf%EgLT*EvR4Smpws6e}j->aKIs->fH++K-V;AmwkndMU zoGGjhSy8x9XF^q_!OYhZ?PQY+8>1NSWv+O*wX;8Gzr%Ua{wwzekNpIkmOeY#-X|O+ zBMCMj1gFx4Ey$$f 5oKgfK|6?JRo6Pk3)?c4L?ay83z+i6i;PYShH}5&eZ@!*7)|L+} z&2#m_;yneFC(^~5YEmrX{j{OuK>R(ENd>wDtBdZ}RR{x Fxvi z*FsiGM(@57JoEE$GO4iUJt)@CJ5ryx2yhCBfO(?Uykj)tkWQ9DGI!?=D!P)~mxa~1 zurZoZ*8lE|%J9 t+3d|lWYPS+T}k ztudnBH61;%rizcVJaBI)<>XSqb_t{c%W(xFQz`FAYP0F2#Q+egr|O%EaQtt3*V3HE z5roM)z?lMUaT6*6*T@h>+$;e?x;Y@M+MD$N%12~m*~d7l hd&DnXq6zom8_<{Hm}vY)ck4qsCDP!X8;E_%Mg(>b z_=iIcLm}-Wt(tj2t@}Oja-C{oe`Ae*(g^Eq@&Q6$+;w11-DO+__=KA8IG`!@q!c=h zZa*eN`BF}zGM)wUX>l=KD2<@cpW=WdYh-B=S8JC?09>zotMwd`A}(AYzFXs59KqEb z5A!_vfgPuUMvkLHUz2>N?_VK#3z`4eL{)(B1AW)19OWUbBe;C VRo8VC|)-C0kPUg?>Qt#vbP) rfPD4#k!OywYBj}yw$#fwK)lj1m4L?SCcpe zHuDb-iK}t8l#AZ#P%HFm9yt?Y9r;w0BICP-6ia;+M}AKeNKVoP-$k$)un~)-4_Kv= zUEiq^`eYD8zVL(b?~s;Jnzv5E5%#d>j TNk zail!G50>BZ7_)W*-hUGH39GcKc?74Z8La3pI7$O_=X1$4s&@oV814tGCN_mnMf&K7 zB3fqRtHVUki??gM_0;9GwrOa0f)Ai7J8mvwbJr`X_P#~rU0SXhpP00WLG&d)(MvX^ zWo~?rTCQTX{pY{Pt9k?#+!!gM;TLktPNx6-x885cX8+k%Vtis(h@jF(=^;lIHVSc) zQ-rxG1X`zP!Z*Xvr+tNUBm-7I(3VDQz0Hud_bo`QWFb*QgG}FUqVqh4cYK T6r4b3&ny69{Y6?Jh%v?NvpmD9E!>FMzLqX0U!bH4P?<=UQlQ!)b z7SmWQwuhO_ei&hX#9m3T{EYh#-_`aS8Z;H^18bdGt{^H%U|0Q$yox)qH*)Cjiv88> zYzdP-iU$aZn+qr4ACE2ugTc|4b)=y^qma7VD(T0e&gk;)RW@|zaT>_IUy=ApfNR_H zn1Xwjgvz%+ruor`KO0qR`KMh`Co0JCbag!6jh82SdnnTi4BTBev>4;i$$v-!^LD(& z!v?mkM#^Wkwa>Gk==AxQd76|`rvG&DwH!vIp*yA~_cV6XibIWjh~ZP^&38|T#>vV~ zgCQGgO(M14mW_>3>pzO$TIVDY%#)dJ8||A~3BWt3Zj40ApFU5%tG%p?7#+b;&!U}> z5V)+*yecmtwv`<1E5te>)r-T<7dnC&AJ+ezS$*!W_K{_IcV y%#1}{8t$9kBSxj>{o7^N((>&`jH@EY3{F$}iSl|q2 zyK%<;YmM%?F<+rjiAx;vEuOZuJ%8<7y%P^@m^^!*w$~+=0w04B;p#<(eO{Mh>kgyG z+gs=5Fu+vITlwA#pPw{dqv)z4Go?G@Q0VdeG7^R1W~dHzh;+!AhHBq!cb%8QkhfZ* z#sZ$+W3tB1)qykI=6H|sQZV-S^LSUJsp?^Hybnyw7y*2Q_OV-W$La^ma>g!JRQuS0 zpVfvB21A7u=H=OYD@7}l%ErsxkGIxXzKTeK ik0fcB@|B&fTM4Z?p$T iy4RaC7z_r3k@_EghXP+1RG%~e0000 V3cW`+fiGausIpz1Dix{oMDxp0&0!dmU^<1Qi5PD3plpt{wYO zC_IEh;ZQg%_|5R}&^7QM&YxsYLZM32gnk^vgP+mi`)szOirbXFfFD%$?%8i`6SC)I zU+$^C{G|T;lkf9S_T_``!M~@G@AEn-Cdicn)O`+4sKkC%$onyI)0-FPTTrOgDBB&R z{l|O9*>=H-HZu4>>ks9aHTya~$=xZ0ty8~_mYm#3^i4E7tcL4fcS? t!fK`I*Wqx$a@l3S2&w~bB=J5>o4A~qWJAO^X%}~#f7f)lKt@( z_oe;zd)jdaENeLX1x-eS_P);#Hec9%Yizer^@wSgKZ@${MB`Gbjb8c)l%qSNFyz}_ z=YU~-5Kc@LQevfQFjA>)1sJlpn}HT*_aCGHi|Db$oyBV`I+=8t5qTx ~%s$h>4bjI)PpP}Rnd@F_oID-ZY&mc0&%Q-=q$tG||g zorC *Lx~mlC@^c)t-%&5(mE%;!Xr5swgm3cItIq`Mzx?Q-TtmQX_yP zXZlm%Kwo&`AlJtlc4lNUgYf3M+lXQUWFe|HhI1?%M 6s zwZh=fp#R|O3$yD=r9LIPbz!YI#Vzj|$=j=s(U@T2ai4$j?$^S%nNSRJVNqVezeXa6 zTBK NN>*|j|7YadWr?zq0f)Xd?n3I3$25HsbuqoK0 z_b_BI`%ma(G=bo0KmgCIIxK3cPSNLt!b8vas7X);F-<+Op6Y6wD6=Ee8t P$EKHe^rj>g4sPawPeFdea;f0K!9nYR-WBPu)yr zJ{ bo3`HZ2+#{L)pR`%2Cm3d)Nnl z4mF(= |-9j2Oj6a8|zQ7M!2Dju8(~=1a$n4CUo_a&yS-c%C z 1)#Pn3`wleruQ6?*OyBj2=-9-Y-d~{UJ}9qMtj*wo#=}@1Jg6oW5+GkYtS15g z8~-Sd8VN}Z5yO6Q7e1D&75P4b+G`yy!fqy^DdMaTiuw31ma|L`UrJ#P{nSuR)M@ znq(Ov1)|E(RpPk&!WFNRBQz}1el!bHHCMq8w^=b8&6LI}zm7gQx9WFKu2zm*sd2z@ zodM{uTpdyXy 8d1;w;1V4^TlP zRKRP4k8O<6DHh-(XBK=Aru>Cxt5Y6n^hgk0FQVCE*~NUE4Wz!hMigp-zxpaP?QXYB z(O;>rRQwrk8MoZ@#pg$PzG78E!Ue_r@T?7Jju#n9YJ`&RHEh+giMEOk>-iQHs^=gx z)Y>yYJsG0ESvK%f{Agjrp|<|d8VhkF?d;IWs73Sn!uZ$+oihBHrk+nVm05+seG49Gmr`?@N*+$wZch00J-qc{z4lb(UBeE| ztg0nH6Sug}EeXY@p0vHk9K)jO`5oO3l7s?owr5>gx}4CpWYo27%rD)(owl i1iE+IK*c;Um1*NYP~&z8rYIz8{{ z%5VIfrW7z)C|hMU`QjCx)P#vsk*1!0A@lQ#jAOzIG@c!oC1M!13P*kX0J@R5hQe?V zH|+m-06;Iv?>F=`#oSDy?Kc4k0o+9#rCm2LM-us82ssIXPomKOgW3BZe(BBFTkZ~b z;#X^u+i$XuY^`r0C{&$QqUIpCCiC^mGb4ZDHTF*&dBrECoFc?eY?%=jsXijyTlhSz zLxI{LOs%Vg_(*9&VsI+-h$yxuF<$!~KY=Xi<^kI?jAB#JfVw7&PC`$&VR>LroI}8F zyoxl&eF;?ALrR#n$?>UK6KK+yuCf+Q4)I$HRM2yN5#fS0gg9G7dhob7KjB5K$kMjI zK3^EfrmG~A(w^_Jz3l%de7q${S=LMEd8v;*Y?`8a;iOb|tOQ*~>GM9g0`yB2b_E)^ zXoTSet$_ejd>Mqsy_)tSyo-X3fhr#q?j5@&Dk9F!P~fPL>}G{UtAmBBRck >P z6X|Y+Z#(hq)kqTQ6OGx1B5()vWifG5ip35P1_bULtb-F7k(c|gc+XoE6v-i~a$E5f zhOJ3qq>5H&BAL7k$>hTWw?HO?d7^cb9Qms+K@l23qO{9`l!JM65+Vg2#}O$21c*ec zSXr9&Nzn^oI|)NliK^pSJ|1ZTNuvUyK|v)TfSUZQhzVg{hGHSTIAIU4R-aUF$&5 z651+dEQqD0A=FJ(M;PurQ20ppm>r)&l^{uqTg;5CLH*C2CGP-ZdZmI9t~$C8t@DqJ zB5j@~x#oUoxeulXm&?zg>vKrsKDlSw)uUXX$LF>)GHa)|BNMdB7mioXY$X}D5~G-m z%&XP=VBPW1k7%G2yCF{;9OLkn{WuZK&6XRCVsX|mh$k)gOexUaai3f6E!$>9NeJjn zd73P*&2w!!#Z_3g*ld>229Co$s0mkuua;8bb|8wx1d8N`3;fPgGq$@OnpA Nl27?I0CiKa+==#d!~q+|0bVf94kkk-*AwGC zH^IEL>ej!6WF^DPfYu1rd5tEn0#4-c4H6s`-Mt9D Dgw3T=YZ4#vLyUpkefN_N?`BCvo<;c)yd7?^EF{sn4cW-MT=(| zdZK!OX}G|&&{N`=BeK+Q4x378QZ}~3Jl~>Qi})503!e2T`BIg$sSg2Y66sf5dm>8o zkhXDkZGXLx ;%Z5pCgh{e}blW*a`2=F3!(V99Wp{5eoA;TJpI z(IqL?H=@wyYdK*I@Tmr+rv=AgMm9p8+`(cE4qbxdk2seaTY8WJbMB~oKa1e0kM-mu zI_^UO^qB&aYsTopn4d;127V;E4l;rySs!Dz^3#gWr0R1V000rh=#zwIg@vmRBQ&+y z<^6tKt{XkGyEdZlXS6*RuXiwyVSw7I8%vDhCkt>*Fq{?yI4##1#S==L8x7yT4aQgI zBquu-B$wtZ>dF3GoKJ4}t{rtnv_1sIkpLm5n)?rDUA@+!`r;AWI83uDv|KK!>f`m4 zKPC%CG-fS~n}*&;hITYeU0QP1%dD_F4(9?ZrJSmEwyH91WhMJ(0^3pS#d4 0`wK4 GW3lhvjZoVBH$*3Mo^X{Q YOLiD zFSP#bSY$e0qZ9x3l0I gTgZXt+ok;Njoa)(3V3SqK=mgAaIH24VJxWz78S&|eoC(C;k|3)Ek6 z4*RO(v+icSTg&bp`CX#NB)s(sQHVvHT{=RMe* x|cgLl;sER2lUGTtl*@h)&Kryty=`y-ALrMRW?*!HCQmY^H4cco}QD z5x8R&Nm4jN72MFh0}C#rTO5Q>Ws+?BP428di%~X7mgf%i^3m497#AAI|yIaP{Pr zyM=i)+!)_T@HAnHHb2p;C5gQ!<^+VH11~F9d*BlL`3;4*h8CO>aOZg$sv4Y{rY}yR zkMxtt{4@yxu7)Oj{Fy!vyE*@Wr;bAl9LHh@YeGYa9adn^*q9yW&F^dj2Q3WA; z (Md2(&9g6nD#Z92t_|vXJPtV*;$4^y=yZpbKu} zNkj-whTQpS6T-CI+RJ5M<64>=p!OMb;F$k0SjrzJ3A9KB!*MonaydiArVjMoO9=Gc z1kG6^2IBCogOSJ28uAqePp_(wp*JqdD;lcsx1g1!cO|cKJPvIw^^xO*ZiCB%3mm{T zOygqJRb0Q?cm*;c0+a@1=h$1|7aJpCJ@6su$t+*1TP4wV`J9MC+W1H%|5>nBIs b4@2h^u#~ FVqT(!$BOC{e@B$w=$@s9))}9;|CeHpUv8Y>lp>r_Q~Vrpr*st+^xl#n$*hirZ1x zQpiw%t4Kc#pz|$*7-NxT1j12@of#j6KOBbi2XPGBzgg%>O&N&G #9gBW8hFk%oV^VZfmGMuqMr zN#jjJWI&hbsW@7O6lEKr9@F*bWsokLnUwm7b2h6`hO+qXgL%|}KW>qMT_q68dJ8Da z`(1??Cn;%qc_IFXSKeb 4r;H{m!aO+{CP3O=I>&I6E=lHj?*+XGRp%STm_gPCH^J|pX z7h7n|`}%d)7mtsKMSKd9yQI*XYB#;|YQMrl?MkI|iMHZCbKQjfb7CjO6}g)7N1Vel z@;^C?R7J^FmyGF!wQFVNT-RPd+3jKFeM#(IHymT*FeLD3