fix: add harness on false promises (#14672)
# Pull Request Template ## Description https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Against negative cases identified by evals ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::V1FalsePromiseHandler
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -38,6 +39,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
message_history: message_history
|
||||
)
|
||||
classify_v1_response_action(message_history) if conversation_pending?
|
||||
repair_v1_false_promise_response(message_history) if conversation_pending?
|
||||
process_response
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
module Captain::Conversation::V1FalsePromiseHandler
|
||||
FUTURE_PROMISE_REPAIR_INSTRUCTION = <<~PROMPT.squish.freeze
|
||||
Internal instruction for the assistant, not a customer message: your previous draft promised future work after this
|
||||
message. Regenerate a replacement response now using the same conversation context and available tools. You may use
|
||||
tools now if needed. Do not promise delayed follow-up, later checking, monitoring, notifications, email, callbacks,
|
||||
or background escalation by yourself. Answer with what you can verify now, ask one concrete clarifying question, or
|
||||
offer a human handoff without claiming that it already happened.
|
||||
PROMPT
|
||||
|
||||
private
|
||||
|
||||
def repair_v1_false_promise_response(message_history)
|
||||
false_promise_detected = false
|
||||
return unless v1_false_promise_harness_enabled?
|
||||
return if v1_handoff_requested?
|
||||
|
||||
detection = detect_v1_false_promise(message_history)
|
||||
return unless future_work_promise?(detection)
|
||||
|
||||
false_promise_detected = true
|
||||
mark_v1_false_promise_handoff_fallback
|
||||
regenerate_v1_false_promise_response(message_history)
|
||||
inspect_v1_response_after_false_promise_repair(message_history)
|
||||
rescue StandardError => e
|
||||
mark_v1_false_promise_handoff_fallback if false_promise_detected
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][ResponseBuilderJob] V1 false promise harness failed for account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def mark_v1_false_promise_handoff_fallback
|
||||
@response.merge!(
|
||||
'action' => 'handoff',
|
||||
'action_reason' => 'false_promise_detected',
|
||||
'action_source' => 'false_promise_harness'
|
||||
)
|
||||
end
|
||||
|
||||
def regenerate_v1_false_promise_response(message_history)
|
||||
repair_message_history = message_history + [{ role: 'assistant', content: @response['response'] }]
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: repair_message_history,
|
||||
additional_message: FUTURE_PROMISE_REPAIR_INSTRUCTION
|
||||
)
|
||||
end
|
||||
|
||||
def inspect_v1_response_after_false_promise_repair(message_history)
|
||||
classify_v1_response_action(message_history) if conversation_pending?
|
||||
return unless conversation_pending?
|
||||
return if v1_handoff_requested?
|
||||
|
||||
verify_v1_false_promise_repair(message_history)
|
||||
end
|
||||
|
||||
def detect_v1_false_promise(message_history)
|
||||
detection = Captain::Llm::AssistantFalsePromiseService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation
|
||||
).detect(message_history: message_history, assistant_response: @response['response'])
|
||||
|
||||
log_v1_false_promise_detection(detection)
|
||||
detection
|
||||
end
|
||||
|
||||
def verify_v1_false_promise_repair(message_history)
|
||||
detection = detect_v1_false_promise(message_history)
|
||||
return if safe_response?(detection)
|
||||
|
||||
mark_v1_false_promise_handoff_fallback
|
||||
end
|
||||
|
||||
def future_work_promise?(detection)
|
||||
detection['decision'] == 'future_work_promise'
|
||||
end
|
||||
|
||||
def safe_response?(detection)
|
||||
detection['decision'] == 'safe'
|
||||
end
|
||||
|
||||
def v1_false_promise_harness_enabled?
|
||||
ActiveModel::Type::Boolean.new.cast(@assistant.config['false_promise_harness_enabled'])
|
||||
end
|
||||
|
||||
def log_v1_false_promise_detection(detection)
|
||||
Rails.logger.info(
|
||||
"[CAPTAIN][ResponseBuilderJob] V1 false promise harness account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id} decision=#{detection['decision']} " \
|
||||
"reason=#{detection['reason']} model=#{detection['model']}"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,6 @@
|
||||
class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
MAX_CONTEXT_MESSAGES = 10
|
||||
include Captain::Llm::AssistantResponseInspectionHelpers
|
||||
|
||||
def initialize(assistant:, conversation:)
|
||||
super()
|
||||
@@ -11,9 +10,10 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
end
|
||||
|
||||
def classify(message_history:, assistant_response:)
|
||||
user_prompt = classification_user_prompt(
|
||||
user_prompt = assistant_response_inspection_prompt(
|
||||
message_history: message_history,
|
||||
assistant_response: assistant_response
|
||||
assistant_response: assistant_response,
|
||||
response_tag: 'assistant_response_to_classify'
|
||||
)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params(user_prompt)) do
|
||||
@@ -35,68 +35,6 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
|
||||
private
|
||||
|
||||
def classification_user_prompt(message_history:, assistant_response:)
|
||||
<<~PROMPT
|
||||
<account_custom_instructions>
|
||||
#{@assistant.config['instructions']}
|
||||
</account_custom_instructions>
|
||||
|
||||
<conversation_context>
|
||||
#{format_conversation_context(message_history)}
|
||||
</conversation_context>
|
||||
|
||||
<assistant_response_to_classify>
|
||||
#{assistant_response}
|
||||
</assistant_response_to_classify>
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def normalize_messages(message_history)
|
||||
message_history.filter_map do |message|
|
||||
role = message[:role] || message['role']
|
||||
next if role.blank?
|
||||
|
||||
{ role: role.to_s, content: normalize_content(message[:content] || message['content']) }
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_content(content)
|
||||
return content if content.is_a?(String)
|
||||
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
|
||||
|
||||
content.to_s
|
||||
end
|
||||
|
||||
def text_part?(part)
|
||||
return false unless part.is_a?(Hash)
|
||||
|
||||
(part[:type] || part['type']).to_s == 'text'
|
||||
end
|
||||
|
||||
def format_conversation_context(messages)
|
||||
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
|
||||
content = message[:content].to_s.strip
|
||||
next if content.blank?
|
||||
|
||||
"#{role_label(message[:role])}: #{content}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def role_label(role)
|
||||
return 'User' if role == 'user'
|
||||
return 'Assistant' if role == 'assistant'
|
||||
|
||||
role.to_s.titleize
|
||||
end
|
||||
|
||||
def parse_response(content)
|
||||
return content if content.is_a?(Hash)
|
||||
|
||||
JSON.parse(sanitize_json_response(content))
|
||||
rescue JSON::ParserError, TypeError
|
||||
{}
|
||||
end
|
||||
|
||||
def normalize_response(parsed, raw_content)
|
||||
action = parsed['action'].to_s
|
||||
reason = parsed['action_reason'].to_s
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class Captain::Llm::AssistantFalsePromiseService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::Llm::AssistantResponseInspectionHelpers
|
||||
|
||||
def initialize(assistant:, conversation:)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@temperature = 0.0
|
||||
end
|
||||
|
||||
def detect(message_history:, assistant_response:)
|
||||
user_prompt = assistant_response_inspection_prompt(
|
||||
message_history: message_history,
|
||||
assistant_response: assistant_response,
|
||||
response_tag: 'assistant_response_to_check'
|
||||
)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params(user_prompt)) do
|
||||
chat(model: @model, temperature: @temperature)
|
||||
.with_schema(Captain::AssistantFalsePromiseSchema)
|
||||
.with_instructions(system_prompt)
|
||||
.ask(user_prompt)
|
||||
end
|
||||
|
||||
parsed = parse_response(response.content)
|
||||
normalize_response(parsed, response.content)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][AssistantFalsePromise] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
{ 'decision' => nil, 'reason' => nil, 'error' => e.message, 'model' => @model }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_response(parsed, raw_content)
|
||||
decision = parsed['decision'].to_s
|
||||
reason = parsed['reason'].to_s
|
||||
return invalid_response(raw_content) unless Captain::AssistantFalsePromiseSchema::DECISIONS.include?(decision)
|
||||
|
||||
{
|
||||
'decision' => decision,
|
||||
'reason' => reason.presence,
|
||||
'raw_response' => raw_content,
|
||||
'model' => @model
|
||||
}
|
||||
end
|
||||
|
||||
def invalid_response(raw_content)
|
||||
{
|
||||
'decision' => nil,
|
||||
'reason' => nil,
|
||||
'raw_response' => raw_content,
|
||||
'error' => 'invalid_false_promise_response',
|
||||
'model' => @model
|
||||
}
|
||||
end
|
||||
|
||||
def instrumentation_params(user_prompt)
|
||||
{
|
||||
span_name: 'llm.captain.assistant_false_promise_detector',
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
conversation_id: @conversation.display_id,
|
||||
feature_name: 'assistant_false_promise_detector',
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
],
|
||||
metadata: {
|
||||
assistant_id: @assistant.id,
|
||||
channel_type: @conversation.inbox&.channel_type,
|
||||
source: 'v1_response_builder'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
Captain::Llm::SystemPromptsService.assistant_false_promise_detector
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
module Captain::Llm::AssistantResponseInspectionHelpers
|
||||
MAX_CONTEXT_MESSAGES = 10
|
||||
|
||||
private
|
||||
|
||||
def assistant_response_inspection_prompt(message_history:, assistant_response:, response_tag:)
|
||||
<<~PROMPT
|
||||
<account_custom_instructions>
|
||||
#{@assistant.config['instructions']}
|
||||
</account_custom_instructions>
|
||||
|
||||
<conversation_context>
|
||||
#{format_conversation_context(message_history)}
|
||||
</conversation_context>
|
||||
|
||||
<#{response_tag}>
|
||||
#{assistant_response}
|
||||
</#{response_tag}>
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def format_conversation_context(messages)
|
||||
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
|
||||
content = message[:content].to_s.strip
|
||||
next if content.blank?
|
||||
|
||||
"#{role_label(message[:role])}: #{content}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def normalize_messages(message_history)
|
||||
message_history.filter_map do |message|
|
||||
role = message[:role] || message['role']
|
||||
next if role.blank?
|
||||
|
||||
{ role: role.to_s, content: normalize_content(message[:content] || message['content']) }
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_content(content)
|
||||
return content if content.is_a?(String)
|
||||
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
|
||||
|
||||
content.to_s
|
||||
end
|
||||
|
||||
def text_part?(part)
|
||||
return false unless part.is_a?(Hash)
|
||||
|
||||
(part[:type] || part['type']).to_s == 'text'
|
||||
end
|
||||
|
||||
def role_label(role)
|
||||
return 'User' if role == 'user'
|
||||
return 'Assistant' if role == 'assistant'
|
||||
|
||||
role.to_s.titleize
|
||||
end
|
||||
|
||||
def parse_response(content)
|
||||
return content if content.is_a?(Hash)
|
||||
|
||||
JSON.parse(sanitize_json_response(content))
|
||||
rescue JSON::ParserError, TypeError
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -137,6 +137,65 @@ class Captain::Llm::SystemPromptsService
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def assistant_false_promise_detector
|
||||
<<~PROMPT
|
||||
You are checking one failure mode in a customer-support assistant response: unsupported promises of future work.
|
||||
|
||||
Return decision "future_work_promise" when the assistant response says or clearly implies that work has already
|
||||
started, is happening now, or will definitely happen later outside the current reply because of this assistant
|
||||
message. This includes promises that the assistant, bot, Captain, or system will check, verify, investigate,
|
||||
review, monitor, notify, update, email, call back, follow up, get back later, process, refund, cancel, book,
|
||||
order, reserve, file, escalate/forward something in the background, or claim that the current conversation has
|
||||
been or will be transferred, connected, or handed off to a human.
|
||||
|
||||
Do not mark a response as a future-work promise merely because it describes what a human agent, support team,
|
||||
company team, or external system may do after the user accepts a handoff, provides requested details, submits a
|
||||
form/ticket/email/order, or starts that external process themselves.
|
||||
|
||||
Do not mark ordinary in-chat help as a future-work promise. Asking the user for missing information, confirmation,
|
||||
or completion of a step before continuing is safe when the response does not also claim that work has started,
|
||||
is happening now, or will happen in the background.
|
||||
|
||||
Treat transfer claims as future-work promises unless the response is exactly the internal action token
|
||||
`conversation_handoff`. Examples that are future-work promises: "I'm transferring you now", "You've been
|
||||
transferred", "Connecting you now", "Handing off to the team now", "I'll connect you with support",
|
||||
"I'll escalate this", and equivalent phrases in any language.
|
||||
|
||||
Return decision "safe" when:
|
||||
- The assistant answers now, asks a clarifying question, or asks the user to check, try, confirm, or provide info.
|
||||
- The assistant says it can help, check, look up, or guide the user after the user first provides requested
|
||||
information, confirms something, or completes a step.
|
||||
- The assistant asks the user to report back after completing a step and offers to continue helping in chat.
|
||||
- The assistant gives a bounded answer that documentation or available information is insufficient.
|
||||
- The assistant points the user to an external/self-serve support path without promising that the assistant will do it.
|
||||
- The assistant describes what an external support, sales, delivery, finance, or operations team will do after the
|
||||
user submits a form, request, email, application, order, ticket, or in-app chat themselves.
|
||||
- The assistant recommends waiting for an existing external process or support response that was already started
|
||||
outside this assistant message.
|
||||
- The assistant offers future help, monitoring, escalation, or handoff conditionally and waits for the user to
|
||||
accept, without saying the work or transfer has already started.
|
||||
- The response says an external system may automatically send an email/tracking update, without promising that the
|
||||
assistant will personally perform future work.
|
||||
- The response is exactly `conversation_handoff`, which is an internal action token and not a customer-visible promise.
|
||||
|
||||
Be language-independent. The customer and assistant may write in any language.
|
||||
Be conservative: only mark "future_work_promise" when the response promises background/asynchronous work,
|
||||
says work is happening now, or claims a handoff/escalation/notification/action has started or will definitely happen.
|
||||
|
||||
The reason field MUST be one of:
|
||||
- "safe_response"
|
||||
- "asks_user_to_check_or_provide_info"
|
||||
- "external_support_direction"
|
||||
- "unaccepted_handoff_offer"
|
||||
- "future_check_or_investigation"
|
||||
- "future_notification_or_update"
|
||||
- "future_callback_or_email"
|
||||
- "background_escalation_promise"
|
||||
|
||||
Return only the structured fields requested by the response schema.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def copilot_response_generator(product_name, available_tools, config = {})
|
||||
citation_guidelines = if config['feature_citation']
|
||||
@@ -235,6 +294,7 @@ class Captain::Llm::SystemPromptsService
|
||||
- Do not generate a response more than three sentences.
|
||||
- Keep the conversation flowing.
|
||||
- Do not use use your own understanding and training data to provide an answer.
|
||||
- Do not promise work that will happen after this reply. Do not say you will check, investigate, monitor, follow up, notify, email, call, refund, cancel, book, escalate, transfer, or submit anything unless you complete that action now using an available tool or, for human transfer, return `conversation_handoff` as the response. If you lack enough information, ask the user for the missing detail without promising future work.
|
||||
- Clarify: when there is ambiguity, ask clarifying questions, rather than make assumptions.
|
||||
- Don't implicitly or explicitly try to end the chat (i.e. do not end a response with "Talk soon!" or "Enjoy!").
|
||||
- Sometimes the user might just want to chat. Ask them relevant follow-up questions.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class Captain::AssistantFalsePromiseSchema < RubyLLM::Schema
|
||||
DECISIONS = %w[safe future_work_promise].freeze
|
||||
REASONS = %w[
|
||||
safe_response
|
||||
asks_user_to_check_or_provide_info
|
||||
external_support_direction
|
||||
unaccepted_handoff_offer
|
||||
future_check_or_investigation
|
||||
future_notification_or_update
|
||||
future_callback_or_email
|
||||
background_escalation_promise
|
||||
].freeze
|
||||
|
||||
string :decision, enum: DECISIONS, description: 'Whether the response contains an unsupported promise of future work'
|
||||
string :reason, enum: REASONS, description: 'The reason for the selected decision'
|
||||
end
|
||||
@@ -11,6 +11,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
||||
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
|
||||
let(:mock_false_promise_service) { instance_double(Captain::Llm::AssistantFalsePromiseService) }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||
@@ -22,6 +23,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
|
||||
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
|
||||
allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
|
||||
allow(mock_false_promise_service).to receive(:detect).and_return({ 'decision' => 'safe', 'reason' => 'safe_response' })
|
||||
end
|
||||
|
||||
context 'when captain_v2 is disabled' do
|
||||
@@ -59,6 +62,165 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
|
||||
end
|
||||
|
||||
it 'does not run the false promise harness when the assistant setting is disabled' do
|
||||
expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
|
||||
end
|
||||
|
||||
context 'when false promise harness is enabled in assistant config' do
|
||||
before do
|
||||
assistant.update!(config: assistant.config.merge('false_promise_harness_enabled' => true))
|
||||
end
|
||||
|
||||
it 'sends the original response when the detector marks it safe' do
|
||||
expect(mock_false_promise_service).to receive(:detect).with(
|
||||
message_history: [{ content: 'Hello', role: 'user' }],
|
||||
assistant_response: 'Hey, welcome to Captain Specs'
|
||||
).and_return({
|
||||
'decision' => 'safe',
|
||||
'reason' => 'safe_response',
|
||||
'model' => 'gpt-4.1'
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('pending')
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
it 'regenerates future-work promises through the V1 assistant chat service' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_return(
|
||||
{ 'response' => 'Let me check the documentation and get back to you.' },
|
||||
{ 'response' => 'Could you share the exact error message you see?' }
|
||||
)
|
||||
allow(mock_false_promise_service).to receive(:detect)
|
||||
.and_return(
|
||||
{
|
||||
'decision' => 'future_work_promise',
|
||||
'reason' => 'future_check_or_investigation',
|
||||
'model' => 'gpt-4.1'
|
||||
},
|
||||
{
|
||||
'decision' => 'safe',
|
||||
'reason' => 'asks_user_to_check_or_provide_info',
|
||||
'model' => 'gpt-4.1'
|
||||
}
|
||||
)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('pending')
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Could you share the exact error message you see?')
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
expect(mock_llm_chat_service).to have_received(:generate_response).with(
|
||||
message_history: [{ content: 'Hello', role: 'user' }]
|
||||
)
|
||||
expect(mock_llm_chat_service).to have_received(:generate_response).with(
|
||||
message_history: [
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{ role: 'assistant', content: 'Let me check the documentation and get back to you.' }
|
||||
],
|
||||
additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
|
||||
)
|
||||
end
|
||||
|
||||
it 'hands off instead of sending the unsafe draft when repair generation fails' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_return({ 'response' => 'Let me check and get back to you.' })
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.with(
|
||||
message_history: [
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{ role: 'assistant', content: 'Let me check and get back to you.' }
|
||||
],
|
||||
additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
|
||||
).and_raise(StandardError, 'repair timeout')
|
||||
allow(mock_false_promise_service).to receive(:detect).and_return({
|
||||
'decision' => 'future_work_promise',
|
||||
'reason' => 'future_check_or_investigation',
|
||||
'model' => 'gpt-4.1'
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Let me check and get back to you.')
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||
end
|
||||
|
||||
it 'hands off instead of sending an unverified repair when repair verification is inconclusive' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_return(
|
||||
{ 'response' => 'Let me check and get back to you.' },
|
||||
{ 'response' => 'Could you share the exact error message you see?' }
|
||||
)
|
||||
allow(mock_false_promise_service).to receive(:detect)
|
||||
.and_return(
|
||||
{
|
||||
'decision' => 'future_work_promise',
|
||||
'reason' => 'future_check_or_investigation',
|
||||
'model' => 'gpt-4.1'
|
||||
},
|
||||
{
|
||||
'decision' => nil,
|
||||
'reason' => nil,
|
||||
'error' => 'verification timeout',
|
||||
'model' => 'gpt-4.1'
|
||||
}
|
||||
)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Could you share the exact error message you see?')
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||
end
|
||||
|
||||
it 'hands off when the regenerated response still contains a future-work promise' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_return(
|
||||
{ 'response' => 'Let me check and get back to you.' },
|
||||
{ 'response' => 'I will monitor this and update you later.' }
|
||||
)
|
||||
allow(mock_false_promise_service).to receive(:detect).and_return({
|
||||
'decision' => 'future_work_promise',
|
||||
'reason' => 'future_check_or_investigation',
|
||||
'model' => 'gpt-4.1'
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||
end
|
||||
|
||||
it 'skips the false promise harness when the action classifier already requested handoff' do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_v1_action_classifier').and_return(true)
|
||||
allow(mock_action_classifier_service).to receive(:classify).and_return({
|
||||
'action' => 'handoff',
|
||||
'action_reason' => 'explicit_human_request',
|
||||
'model' => 'gpt-4.1'
|
||||
})
|
||||
|
||||
expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when V1 action classifier is enabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
|
||||
Reference in New Issue
Block a user