Tightens the resolved-conversation FAQ generator so it only proposes durable, reusable FAQ candidates supported by human support-agent messages. The implementation now sends a conversation-FAQ-specific transcript to the LLM: customer messages plus real human support-agent messages only, excluding bot, private, activity, and template messages. ## Closes - https://linear.app/chatwoot/issue/CW-7494/tighten-conversation-faq-generation-prompt ## What changed - Added a human-only transcript builder in `ConversationFaqService` instead of using the generic `conversation.to_llm_text` output. - Excluded bot/agent-bot messages before the LLM call, which removes the main bot-line leakage class deterministically. - Preserved native-channel human replies where outgoing messages are stored as `external_echo` without a `User` sender. - Kept a prompt decision gate requiring each FAQ to be backed by a complete public human-agent answer. - Added generic no-FAQ classes for spam, wrong-service conversations, private account/payment/order/certificate/troubleshooting cases, support workflow mechanics, and direct-link/file/quote outputs. - Added a separate `conversation_faq_generation` model route defaulting to `gpt-5.2`, while keeping `document_faq_generation` on its existing `gpt-4.1-mini` default. Conversation FAQ generation passes that feature default ahead of the legacy global `CAPTAIN_OPEN_AI_MODEL` setting unless an account-level override is configured. - Kept the prompt domain-neutral so it can still generate reusable product, service, policy, setup, and process FAQs outside SaaS contexts. ## Sampling notes - Production Langfuse traces showed `llm.captain.conversation_faq` calls using `gpt-4.1` in the sampled account set. - Locally, `Llm::FeatureRouter.resolve(feature: 'conversation_faq_generation')` now resolves to `gpt-5.2`. - Reviewed recent production `llm.captain.conversation_faq` traces across 13+ accounts in compact form. - Replayed 20 full traces across 10 accounts/domains, including education, hosting, retail/auto, APIs, logistics, tax/fiscal workflows, and Chatwoot account 1. - Explicit `gpt-5.2` replay with human-only conversation history returned no FAQ for 15/20 traces. - A comparison replay with `gpt-4.1-mini` returned no FAQ for only 7/20 traces, bringing back several private/order/payment/support-workflow cases. - Remaining non-empty `gpt-5.2` outputs are now mostly borderline/possibly useful human-agent-derived FAQs rather than obvious bot-sourced answers. ## How to test - Resolve conversations where the answer came only from the bot; no pending FAQ should be generated. - Resolve spam, unrelated, wrong-service, or private payment/order/account conversations; no pending FAQ should be generated. - Resolve conversations that require account/order/payment/login/private verification or a human handoff; no pending FAQ should be generated. - Resolve a conversation where a human agent gives a stable, reusable help-center answer; the generated pending FAQ should be general and self-contained.
259 lines
11 KiB
Ruby
259 lines
11 KiB
Ruby
require 'rails_helper'
|
|
|
|
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(: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, 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
|
|
context 'when successful' do
|
|
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 the conversation FAQ generation feature model' do
|
|
expect(RubyLLM).to receive(:chat).with(
|
|
model: Llm::Models.default_model_for('conversation_faq_generation')
|
|
).and_return(mock_chat)
|
|
|
|
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
|
end
|
|
|
|
it 'uses the conversation FAQ default ahead of the legacy global installation model' do
|
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini')
|
|
|
|
expect(RubyLLM).to receive(:chat).with(
|
|
model: Llm::Models.default_model_for('conversation_faq_generation')
|
|
).and_return(mock_chat)
|
|
|
|
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
|
end
|
|
|
|
it 'keeps account conversation FAQ model overrides ahead of the feature default' do
|
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1')
|
|
conversation.account.update!(captain_models: { 'conversation_faq_generation' => 'gpt-4.1-mini' })
|
|
|
|
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
|
|
|
|
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
|
end
|
|
|
|
it 'resolves the feature model from the conversation account' do
|
|
expect(Llm::FeatureRouter).to receive(:resolve).with(
|
|
feature: 'conversation_faq_generation',
|
|
account: conversation.account
|
|
).and_call_original
|
|
|
|
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
|
end
|
|
|
|
it 'sends only customer and human support agent messages to the LLM' do
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
|
content: 'Customer question')
|
|
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
content: 'Bot answer that should not become knowledge')
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
|
content: 'Human answer')
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
|
private: true, content: 'Private note')
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
message_type: :activity, content: 'Activity message')
|
|
|
|
service.generate_and_deduplicate
|
|
|
|
expected_content = satisfy do |content|
|
|
content.include?('User: Customer question') &&
|
|
content.include?('Support Agent: Human answer') &&
|
|
content.exclude?('Bot answer that should not become knowledge') &&
|
|
content.exclude?('Private note') &&
|
|
content.exclude?('Activity message')
|
|
end
|
|
expect(mock_chat).to have_received(:ask).with(expected_content)
|
|
end
|
|
|
|
it 'keeps external echo outgoing replies from native channels in the LLM transcript' do
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
|
content: 'Customer asks in a native channel')
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
|
|
content_attributes: { external_echo: true })
|
|
|
|
service.generate_and_deduplicate
|
|
|
|
expected_content = satisfy do |content|
|
|
content.include?('User: Customer asks in a native channel') &&
|
|
content.include?('Support Agent: Human replied from the native app')
|
|
end
|
|
expect(mock_chat).to have_received(:ask).with(expected_content)
|
|
end
|
|
|
|
it 'uses the human-only conversation transcript for instrumentation' do
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
sender: create(:contact, account: conversation.account), message_type: :incoming,
|
|
content: 'Customer asks something')
|
|
create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
content: 'Bot-only answer')
|
|
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
|
sender: create(:user, account: conversation.account), message_type: :outgoing,
|
|
content: 'Agent gives a public answer')
|
|
|
|
expect(service).to receive(:instrument_llm_call) do |params, &block|
|
|
user_message = params[:messages].find { |message| message[:role] == 'user' }[:content]
|
|
|
|
expect(user_message).to include('User: Customer asks something')
|
|
expect(user_message).to include('Support Agent: Agent gives a public answer')
|
|
expect(user_message).not_to include('Bot-only answer')
|
|
|
|
block.call
|
|
end
|
|
|
|
service.generate_and_deduplicate
|
|
end
|
|
|
|
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 FAQs with pending status linked to conversation' do
|
|
service.generate_and_deduplicate
|
|
expect(
|
|
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
|
|
).to contain_exactly(
|
|
['What is the purpose?', 'To help users.', 'pending', conversation.id],
|
|
['How does it work?', 'Through AI.', 'pending', conversation.id]
|
|
)
|
|
end
|
|
end
|
|
|
|
context 'without human interaction' do
|
|
let(:conversation) { create(:conversation) }
|
|
|
|
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
|
|
let(:existing_response) do
|
|
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
|
|
end
|
|
let(:similar_neighbor) do
|
|
OpenStruct.new(
|
|
id: 1,
|
|
question: existing_response.question,
|
|
answer: existing_response.answer,
|
|
neighbor_distance: 0.1
|
|
)
|
|
end
|
|
|
|
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([similar_neighbor])
|
|
end
|
|
|
|
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 LLM API fails' do
|
|
before do
|
|
allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error'))
|
|
allow(Rails.logger).to receive(:error)
|
|
end
|
|
|
|
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
|
|
instance_double(RubyLLM::Message, content: 'invalid json')
|
|
end
|
|
|
|
before do
|
|
allow(mock_chat).to receive(:ask).and_return(invalid_response)
|
|
end
|
|
|
|
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 '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)
|
|
end
|
|
|
|
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')
|
|
.at_least(:once)
|
|
.and_call_original
|
|
|
|
service.generate_and_deduplicate
|
|
end
|
|
end
|
|
end
|
|
end
|