Merge branch 'develop' into feat/voice-call-model
This commit is contained in:
@@ -62,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def set_article
|
||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||
@parsed_content = render_article_content(@article.content)
|
||||
@parsed_content = render_article_content(@article.content.to_s)
|
||||
end
|
||||
|
||||
def set_category
|
||||
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
|
||||
created() {
|
||||
state = createState(
|
||||
this.modelValue,
|
||||
this.modelValue || '',
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
@@ -170,7 +170,7 @@ export default {
|
||||
},
|
||||
reloadState() {
|
||||
state = createState(
|
||||
this.modelValue,
|
||||
this.modelValue || '',
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ const createNewArticle = async ({ title, content }) => {
|
||||
if (title) article.value.title = title;
|
||||
if (content) article.value.content = content;
|
||||
|
||||
if (!article.value.title || !article.value.content) return;
|
||||
if (!article.value.title) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
try {
|
||||
|
||||
@@ -58,7 +58,7 @@ class Article < ApplicationRecord
|
||||
validates :account_id, presence: true
|
||||
validates :author_id, presence: true
|
||||
validates :title, presence: true
|
||||
validates :content, presence: true
|
||||
validates :content, presence: true, if: :published?
|
||||
|
||||
# ensuring that the position is always set correctly
|
||||
before_create :add_position_to_article
|
||||
|
||||
+1
-1
@@ -190,7 +190,7 @@
|
||||
chatwoot_internal: true
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
enabled: true
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class EnableAssignmentV2ForNewAccounts < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
features = config.value
|
||||
feature = features.find { |f| f['name'] == 'assignment_v2' }
|
||||
return if feature.blank?
|
||||
|
||||
feature['enabled'] = true
|
||||
config.update!(value: features)
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_08_170902) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -27,9 +27,9 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
|
||||
end
|
||||
|
||||
# Translation is an internal operation, not customer-initiated.
|
||||
# Prefer the system key; fall back to the account's hook key for self-hosted setups without one.
|
||||
def api_key
|
||||
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
|
||||
# It should always use the installation key.
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def query_in_target_language?(query)
|
||||
|
||||
@@ -56,12 +56,10 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
{ complete: false, reason: reason }
|
||||
end
|
||||
|
||||
# Prefer the system API key over the account's OpenAI hook key.
|
||||
# This is an internal operational evaluation, not a customer-triggered feature,
|
||||
# so it should not consume the customer's OpenAI credits on hosted platforms.
|
||||
# Falls back to the account hook for self-hosted deployments without a system key.
|
||||
def api_key
|
||||
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
|
||||
# so it should always use the installation key.
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def event_name
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Captain::BaseTaskService
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::ToolInstrumentation
|
||||
include Llm::ExceptionTrackable
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
@@ -55,7 +56,9 @@ class Captain::BaseTaskService
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
credential = llm_credential
|
||||
|
||||
Llm::Config.with_api_key(credential[:api_key], api_base: api_base) do |context|
|
||||
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
@@ -65,7 +68,7 @@ class Captain::BaseTaskService
|
||||
build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
capture_llm_exception(e, credential: credential)
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
@@ -147,11 +150,24 @@ class Captain::BaseTaskService
|
||||
end
|
||||
|
||||
def api_key_configured?
|
||||
api_key.present?
|
||||
llm_credential.present?
|
||||
end
|
||||
|
||||
def api_key
|
||||
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
|
||||
llm_credential&.dig(:api_key)
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= hook_llm_credential || system_llm_credential
|
||||
end
|
||||
|
||||
def hook_llm_credential
|
||||
key = openai_hook&.settings&.dig('api_key').presence
|
||||
{ api_key: key, source: :hook } if key
|
||||
end
|
||||
|
||||
def system_llm_credential
|
||||
{ api_key: system_api_key, source: :system } if system_api_key.present?
|
||||
end
|
||||
|
||||
def openai_hook
|
||||
@@ -162,6 +178,10 @@ class Captain::BaseTaskService
|
||||
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
end
|
||||
|
||||
def exception_tracking_account
|
||||
account
|
||||
end
|
||||
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Integrations::LlmBaseService
|
||||
include Integrations::LlmInstrumentation
|
||||
include Llm::ExceptionTrackable
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
@@ -100,13 +101,14 @@ class Integrations::LlmBaseService
|
||||
def execute_ruby_llm_request(parsed_body)
|
||||
messages = parsed_body['messages']
|
||||
model = parsed_body['model']
|
||||
credential = llm_credential
|
||||
|
||||
Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context|
|
||||
Llm::Config.with_api_key(credential[:api_key], api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
setup_chat_with_messages(chat, messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
|
||||
capture_llm_exception(e, credential: credential)
|
||||
build_error_response_from_exception(e, messages)
|
||||
end
|
||||
|
||||
@@ -164,6 +166,14 @@ class Integrations::LlmBaseService
|
||||
}
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= { api_key: hook.settings['api_key'], source: :hook }
|
||||
end
|
||||
|
||||
def exception_tracking_account
|
||||
hook.account
|
||||
end
|
||||
|
||||
def build_error_response_from_exception(error, messages)
|
||||
{ error: error.message, request_messages: messages }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
module Llm::ExceptionTrackable
|
||||
private
|
||||
|
||||
def capture_llm_exception(error, credential:)
|
||||
if credential && credential[:source] == :system
|
||||
ChatwootExceptionTracker.new(error, account: exception_tracking_account).capture_exception
|
||||
else
|
||||
Rails.logger.error("[LLM] account=#{exception_tracking_account&.id} #{error.class}: #{error.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -141,15 +141,15 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'falls back to the account hook key when no system key exists' do
|
||||
it 'does not fall back to the account hook key when no system key exists' do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil)
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('customer-own-key', api_base: anything).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
expect(Llm::Config).not_to receive(:with_api_key)
|
||||
|
||||
service.perform
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq(I18n.t('captain.api_key_missing'))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -258,6 +258,18 @@ RSpec.describe Captain::BaseTaskService do
|
||||
expect(result[:error]).to eq('API Error')
|
||||
expect(result[:request_messages]).to eq(messages)
|
||||
end
|
||||
|
||||
it 'does not track exceptions for account hook failures' do
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('hook-key', api_base: anything).and_raise(error)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
expect(result[:error]).to eq('API Error')
|
||||
expect(result[:request_messages]).to eq(messages)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#api_key' do
|
||||
@@ -276,6 +288,16 @@ RSpec.describe Captain::BaseTaskService do
|
||||
expect(service.send(:api_key)).to eq('test-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no API key is configured' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.destroy
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.send(:api_key)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#prompt_from_file' do
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::LlmBaseService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:hook) { create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' }) }
|
||||
let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
let(:service) { described_class.new(hook: hook, event: event) }
|
||||
let(:error) { StandardError.new('API Error') }
|
||||
let(:body) { { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }] }.to_json }
|
||||
|
||||
describe '#make_api_call' do
|
||||
before do
|
||||
allow(service).to receive(:instrument_llm_call).and_yield
|
||||
allow(Llm::Config).to receive(:with_api_key).and_raise(error)
|
||||
end
|
||||
|
||||
it 'does not track exceptions for hook key failures' do
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
result = service.send(:make_api_call, body)
|
||||
|
||||
expect(result[:error]).to eq('API Error')
|
||||
expect(result[:request_messages]).to eq([{ 'role' => 'user', 'content' => 'Hello' }])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,16 @@ RSpec.describe Article do
|
||||
it { is_expected.to validate_presence_of(:account_id) }
|
||||
it { is_expected.to validate_presence_of(:author_id) }
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
it { is_expected.to validate_presence_of(:content) }
|
||||
|
||||
it 'validates content presence only for published articles' do
|
||||
article = build(:article, portal_id: portal_1.id, author_id: user.id, category_id: category_1.id,
|
||||
title: 'test', content: nil, status: :draft)
|
||||
expect(article).to be_valid
|
||||
|
||||
article.status = :published
|
||||
expect(article).not_to be_valid
|
||||
expect(article.errors[:content]).to include("can't be blank")
|
||||
end
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
|
||||
Reference in New Issue
Block a user