Captain V2 now adds FAQ citations from a structured model response. The model returns ordered response parts with citation indexes, and Chatwoot turns only trusted indexes into customer links. ## Before Captain V2 asked the model to copy text markers such as `[[faq:1]]`. Chatwoot used one regular expression to replace those markers with links in the outgoing message and another regular expression to remove the rendered links before the next model turn. Long conversations depended on parsing the customer message to recover plain model context. ## After The FAQ lookup tool now gives each eligible source document a numeric index and never gives the model a URL. FAQ results from the same document reuse the same index. The model returns `response_parts`, where each part contains customer text and the supporting citation indexes. Chatwoot checks every index against the document IDs registered during the current run. Only stored HTTP or HTTPS web-document links without embedded credentials can appear in the customer reply. Blank links, PDF sources, attachments, non-HTTP storage links, and unknown indexes do not create links. Sources receive display numbers in the order they first appear, and repeated sources keep the same display number. Chatwoot saves the structured response parts with each newly generated Captain message. Later Captain V2 turns use the saved plain text for those messages, so they never need to parse rendered links. Existing messages remain unchanged and continue to use their stored content. When citations are disabled, Chatwoot clears citation indexes before it returns or saves the response. Captain V1, Copilot, legacy prompts, legacy tools, and the playground response contract are unchanged. The playground continues to show the plain `response` field. ## Closes [AI-138](https://linear.app/chatwoot/issue/AI-138/faq-citation-fix) ## How to test 1. Open a conversation handled by a Captain V2 assistant and turn citations off. Ask a greeting, an FAQ question, a code question, and a follow up question. Confirm that the assistant answers normally and shows no source links. 2. Turn citations on and ask a question that matches one public web document. Confirm that the reply shows the stored public link after the supported text. 3. Ask a question that needs two public web documents. Confirm that the response order stays correct, each link appears after the supported text, and repeated sources keep the same display number. 4. Ask a question that retrieves several FAQ results from one document. Confirm that the reply shows the document once at each supported response part rather than exposing separate FAQ sources. 5. Ask a question supported by a PDF, attachment, blank link, or non-HTTP storage link. Confirm that Captain can use the information but does not show a customer link. 6. Ask for a fenced code example with a citation. Confirm that the code block stays complete and the citation appears after the closing fence. 7. Continue the conversation with a follow up question. Confirm that Captain uses the earlier plain response text and does not receive or repeat rendered citation links. 8. Test a scenario handoff in a conversation. Confirm that the handoff and final response still work.
240 lines
7.3 KiB
Ruby
240 lines
7.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe Concerns::Agentable do
|
|
let(:dummy_class) do
|
|
Class.new do
|
|
include Concerns::Agentable
|
|
|
|
attr_reader :account
|
|
attr_accessor :temperature
|
|
|
|
def initialize(name: 'Test Agent', temperature: 0.8, account: nil)
|
|
@name = name
|
|
@temperature = temperature
|
|
@account = account
|
|
end
|
|
|
|
def self.name
|
|
'DummyClass'
|
|
end
|
|
|
|
private
|
|
|
|
def agent_name
|
|
@name
|
|
end
|
|
|
|
def prompt_context
|
|
{ base_key: 'base_value' }
|
|
end
|
|
end
|
|
end
|
|
|
|
let(:account) { create(:account) }
|
|
let(:dummy_instance) { dummy_class.new(account: account) }
|
|
let(:mock_agents_agent) { instance_double(Agents::Agent) }
|
|
|
|
before do
|
|
InstallationConfig.where(name: 'CAPTAIN_OPEN_AI_MODEL').destroy_all
|
|
allow(Agents::Agent).to receive(:new).and_return(mock_agents_agent)
|
|
allow(Captain::PromptRenderer).to receive(:render).and_return('rendered_template')
|
|
end
|
|
|
|
describe '#agent' do
|
|
it 'creates an Agents::Agent with correct parameters' do
|
|
expect(Agents::Agent).to receive(:new).with(
|
|
name: 'Test Agent',
|
|
instructions: instance_of(Proc),
|
|
tools: [],
|
|
model: Llm::Models.default_model_for('assistant'),
|
|
temperature: 0.8,
|
|
response_schema: Captain::ResponseSchema
|
|
)
|
|
|
|
dummy_instance.agent
|
|
end
|
|
|
|
it 'uses default temperature when temperature is nil' do
|
|
dummy_instance.temperature = nil
|
|
|
|
expect(Agents::Agent).to receive(:new).with(
|
|
hash_including(temperature: 0.5)
|
|
)
|
|
|
|
dummy_instance.agent
|
|
end
|
|
|
|
it 'converts temperature to float' do
|
|
dummy_instance.temperature = '0.5'
|
|
|
|
expect(Agents::Agent).to receive(:new).with(
|
|
hash_including(temperature: 0.5)
|
|
)
|
|
|
|
dummy_instance.agent
|
|
end
|
|
end
|
|
|
|
describe '#agent_instructions' do
|
|
it 'calls Captain::PromptRenderer with base context' do
|
|
expect(Captain::PromptRenderer).to receive(:render).with(
|
|
'dummy_class',
|
|
hash_including(base_key: 'base_value')
|
|
)
|
|
|
|
dummy_instance.agent_instructions
|
|
end
|
|
|
|
it 'merges context state when provided' do
|
|
context_double = instance_double(Agents::RunContext,
|
|
context: {
|
|
state: {
|
|
assistant_config: { 'feature_contact_attributes' => true },
|
|
conversation: { id: 123 },
|
|
contact: { name: 'John' }
|
|
}
|
|
})
|
|
|
|
expected_context = {
|
|
base_key: 'base_value',
|
|
conversation: { id: 123 },
|
|
contact: { name: 'John' },
|
|
campaign: {}
|
|
}
|
|
|
|
expect(Captain::PromptRenderer).to receive(:render).with(
|
|
'dummy_class',
|
|
hash_including(expected_context)
|
|
)
|
|
|
|
dummy_instance.agent_instructions(context_double)
|
|
end
|
|
|
|
it 'merges campaign data from context state' do
|
|
context_double = instance_double(Agents::RunContext,
|
|
context: {
|
|
state: {
|
|
conversation: { id: 123 },
|
|
contact: { name: 'John' },
|
|
campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' }
|
|
}
|
|
})
|
|
|
|
expect(Captain::PromptRenderer).to receive(:render).with(
|
|
'dummy_class',
|
|
hash_including(
|
|
campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' }
|
|
)
|
|
)
|
|
|
|
dummy_instance.agent_instructions(context_double)
|
|
end
|
|
|
|
it 'handles context without state' do
|
|
context_double = instance_double(Agents::RunContext, context: {})
|
|
|
|
expect(Captain::PromptRenderer).to receive(:render).with(
|
|
'dummy_class',
|
|
hash_including(
|
|
base_key: 'base_value',
|
|
conversation: {},
|
|
contact: nil,
|
|
campaign: {}
|
|
)
|
|
)
|
|
|
|
dummy_instance.agent_instructions(context_double)
|
|
end
|
|
end
|
|
|
|
describe '#template_name' do
|
|
it 'returns underscored class name' do
|
|
expect(dummy_instance.send(:template_name)).to eq('dummy_class')
|
|
end
|
|
end
|
|
|
|
describe '#agent_tools' do
|
|
it 'returns empty array by default' do
|
|
expect(dummy_instance.send(:agent_tools)).to eq([])
|
|
end
|
|
end
|
|
|
|
describe '#agent_model' do
|
|
it 'returns the assistant feature default model' do
|
|
expect(dummy_instance.send(:agent_model)).to eq(Llm::Models.default_model_for('assistant'))
|
|
end
|
|
|
|
it 'returns account override model when present' do
|
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
|
account.update!(captain_models: { 'assistant' => 'gpt-5.2' })
|
|
|
|
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
|
|
end
|
|
|
|
it 'returns the installation model when account override is absent' do
|
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
|
|
|
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano')
|
|
end
|
|
|
|
it 'returns the Captain V2 default when Captain V2 is enabled' do
|
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
|
account.enable_features!('captain_integration_v2')
|
|
|
|
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
|
|
expect(account.reload.captain_models).to be_nil
|
|
end
|
|
|
|
it 'returns the assistant feature default model when account is nil' do
|
|
agent = dummy_class.new(account: nil)
|
|
|
|
expect(agent.send(:agent_model)).to eq(Llm::Models.default_model_for('assistant'))
|
|
end
|
|
end
|
|
|
|
describe '#agent_response_schema' do
|
|
it 'returns Captain::ResponseSchema' do
|
|
expect(dummy_instance.send(:agent_response_schema)).to eq(Captain::ResponseSchema)
|
|
end
|
|
|
|
it 'defines complete structured response parts with nested citation indexes' do
|
|
schema = Captain::ResponseSchema.new.to_json_schema[:schema]
|
|
response_parts = schema.dig(:properties, :response_parts)
|
|
response_part = response_parts.dig(:items, :properties)
|
|
|
|
expect(schema[:required]).to contain_exactly(:response_parts, :reasoning)
|
|
expect(response_parts).to include(type: 'array', minItems: 1)
|
|
expect(response_part.dig(:text, :type)).to eq('string')
|
|
expect(response_part.dig(:citation_indexes, :items, :type)).to eq('integer')
|
|
expect(response_part.dig(:citation_indexes, :items, :minimum)).to eq(1)
|
|
expect(schema[:properties]).not_to have_key(:response)
|
|
end
|
|
end
|
|
|
|
describe 'required methods' do
|
|
let(:incomplete_class) do
|
|
Class.new do
|
|
include Concerns::Agentable
|
|
end
|
|
end
|
|
|
|
let(:incomplete_instance) { incomplete_class.new }
|
|
|
|
describe '#agent_name' do
|
|
it 'raises NotImplementedError when not implemented' do
|
|
expect { incomplete_instance.send(:agent_name) }
|
|
.to raise_error(NotImplementedError, /must implement agent_name/)
|
|
end
|
|
end
|
|
|
|
describe '#prompt_context' do
|
|
it 'raises NotImplementedError when not implemented' do
|
|
expect { incomplete_instance.send(:prompt_context) }
|
|
.to raise_error(NotImplementedError, /must implement prompt_context/)
|
|
end
|
|
end
|
|
end
|
|
end
|