cleanup
This commit is contained in:
@@ -1,110 +0,0 @@
|
||||
module Captain::Conversation::DocumentationSufficiencyHandler
|
||||
HIGH_RISK_TERMS = %w[
|
||||
price pricing cost billing bill paid free plan subscription legal compliance policy limit limits maximum minimum
|
||||
available availability current currently roadmap beta early access supported support self-hosted cloud region version
|
||||
provider integration account status
|
||||
].freeze
|
||||
|
||||
private
|
||||
|
||||
def reset_documentation_searches
|
||||
Current.captain_documentation_searches = []
|
||||
end
|
||||
|
||||
def clear_documentation_searches
|
||||
Current.captain_documentation_searches = nil
|
||||
end
|
||||
|
||||
def inspect_documentation_sufficiency(message_history)
|
||||
searches = documentation_searches_for_inspection(message_history)
|
||||
return unless should_inspect_documentation_sufficiency?(message_history, searches)
|
||||
|
||||
apply_documentation_sufficiency_inspection(documentation_sufficiency_inspection(message_history, searches))
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][ResponseBuilderJob] Documentation sufficiency check failed for account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def should_inspect_documentation_sufficiency?(message_history, searches)
|
||||
return false unless documentation_sufficiency_gate_enabled?
|
||||
return false unless response_has_user_facing_answer?
|
||||
return false if searches.empty?
|
||||
|
||||
documentation_sufficiency_check_needed?(message_history, searches)
|
||||
end
|
||||
|
||||
def response_has_user_facing_answer?
|
||||
@response.present? &&
|
||||
@response['response'].present? &&
|
||||
@response['response'] != 'conversation_handoff' &&
|
||||
!@response['handoff_tool_called']
|
||||
end
|
||||
|
||||
def documentation_searches_for_inspection(message_history)
|
||||
searches = Current.captain_documentation_searches.to_a
|
||||
return searches if searches.present?
|
||||
return [] unless high_risk_conversation?(message_history)
|
||||
|
||||
[synthetic_no_results_search(message_history)]
|
||||
end
|
||||
|
||||
def synthetic_no_results_search(message_history)
|
||||
{
|
||||
query: last_user_message_content(message_history),
|
||||
queries: [last_user_message_content(message_history)],
|
||||
status: 'weak',
|
||||
reason: 'no_documentation_search',
|
||||
matches: []
|
||||
}
|
||||
end
|
||||
|
||||
def documentation_sufficiency_inspection(message_history, searches)
|
||||
Captain::Llm::DocumentationSufficiencyService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation
|
||||
).evaluate(
|
||||
message_history: message_history,
|
||||
assistant_response: @response['response'],
|
||||
documentation_searches: searches
|
||||
)
|
||||
end
|
||||
|
||||
def documentation_sufficiency_gate_enabled?
|
||||
ActiveModel::Type::Boolean.new.cast(@assistant.config['documentation_sufficiency_gate_enabled'])
|
||||
end
|
||||
|
||||
def documentation_sufficiency_check_needed?(message_history, searches)
|
||||
searches.any? { |search| search[:status] == 'weak' || search['status'] == 'weak' } || high_risk_conversation?(message_history)
|
||||
end
|
||||
|
||||
def high_risk_conversation?(message_history)
|
||||
text = "#{message_history.last(3).map { |message| message[:content] || message['content'] }.join(' ')} #{@response&.dig('response')}".downcase
|
||||
HIGH_RISK_TERMS.any? { |term| text.include?(term) }
|
||||
end
|
||||
|
||||
def last_user_message_content(message_history)
|
||||
user_message = message_history.reverse.find { |message| (message[:role] || message['role']).to_s == 'user' }
|
||||
user_message && (user_message[:content] || user_message['content']).to_s
|
||||
end
|
||||
|
||||
def apply_documentation_sufficiency_inspection(inspection)
|
||||
return unless inspection['decision'] == 'insufficient'
|
||||
|
||||
fallback_response = inspection['fallback_response'].presence || default_documentation_sufficiency_fallback
|
||||
@response.merge!(
|
||||
'response' => fallback_response,
|
||||
'action' => 'continue',
|
||||
'action_reason' => 'missing_docs_bounded_answer',
|
||||
'action_source' => 'documentation_sufficiency',
|
||||
'documentation_sufficiency_reason' => inspection['reason'],
|
||||
'documentation_sufficiency_model' => inspection['model']
|
||||
)
|
||||
end
|
||||
|
||||
def default_documentation_sufficiency_fallback
|
||||
"I couldn't find enough information to answer that confidently. Would you like me to connect you with a support person?"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
module Captain::Conversation::DocumentationSupportGate
|
||||
private
|
||||
|
||||
def check_documentation_support(message_history)
|
||||
return unless documentation_gate_enabled?
|
||||
return unless customer_reply?
|
||||
|
||||
review = review_documentation_support(message_history, documentation_evidence(message_history))
|
||||
apply_documentation_fallback(review)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][ResponseBuilderJob] Documentation support check failed for account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def documentation_gate_enabled?
|
||||
ActiveModel::Type::Boolean.new.cast(@assistant.config['documentation_sufficiency_gate_enabled'])
|
||||
end
|
||||
|
||||
def customer_reply?
|
||||
@response.present? &&
|
||||
@response['response'].present? &&
|
||||
@response['response'] != 'conversation_handoff' &&
|
||||
!@response['handoff_tool_called']
|
||||
end
|
||||
|
||||
def documentation_evidence(message_history)
|
||||
searches = @response['documentation_searches'].to_a
|
||||
return searches if searches.present?
|
||||
|
||||
[no_documentation_search(last_user_message(message_history))]
|
||||
end
|
||||
|
||||
def no_documentation_search(query)
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
status: 'weak',
|
||||
reason: 'no_documentation_search',
|
||||
matches: []
|
||||
}
|
||||
end
|
||||
|
||||
def review_documentation_support(message_history, evidence)
|
||||
Captain::Llm::DocumentationSufficiencyService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation
|
||||
).evaluate(
|
||||
message_history: message_history,
|
||||
assistant_response: @response['response'],
|
||||
documentation_searches: evidence
|
||||
)
|
||||
end
|
||||
|
||||
def last_user_message(message_history)
|
||||
message = message_history.reverse.find { |item| (item[:role] || item['role']).to_s == 'user' }
|
||||
message && (message[:content] || message['content']).to_s
|
||||
end
|
||||
|
||||
def apply_documentation_fallback(review)
|
||||
return unless review['decision'] == 'insufficient'
|
||||
|
||||
@response.merge!(
|
||||
'response' => review['fallback_response'].presence || default_documentation_fallback,
|
||||
'action' => 'continue',
|
||||
'action_reason' => 'missing_docs_bounded_answer',
|
||||
'action_source' => 'documentation_support',
|
||||
'documentation_sufficiency_reason' => review['reason'],
|
||||
'documentation_sufficiency_model' => review['model']
|
||||
)
|
||||
end
|
||||
|
||||
def default_documentation_fallback
|
||||
"I couldn't find enough information to answer that confidently. Would you like me to connect you with a support person?"
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::DocumentationSufficiencyHandler
|
||||
include Captain::Conversation::DocumentationSupportGate
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -26,7 +26,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
ensure
|
||||
clear_documentation_searches
|
||||
Current.executed_by = nil
|
||||
end
|
||||
|
||||
@@ -36,22 +35,22 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def generate_and_process_response
|
||||
message_history = collect_previous_messages
|
||||
reset_documentation_searches
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
chat_service = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation)
|
||||
@response = chat_service.generate_response(
|
||||
message_history: message_history
|
||||
)
|
||||
inspect_documentation_sufficiency(message_history) if conversation_pending?
|
||||
@response['documentation_searches'] = chat_service.documentation_searches
|
||||
check_documentation_support(message_history) if conversation_pending?
|
||||
classify_v1_response_action(message_history) if conversation_pending?
|
||||
process_response
|
||||
end
|
||||
|
||||
def generate_response_with_v2
|
||||
message_history = collect_previous_messages
|
||||
reset_documentation_searches
|
||||
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: message_history
|
||||
)
|
||||
inspect_documentation_sufficiency(message_history) if conversation_pending?
|
||||
check_documentation_support(message_history) if conversation_pending?
|
||||
process_response
|
||||
end
|
||||
|
||||
|
||||
@@ -26,18 +26,9 @@
|
||||
class Captain::AssistantResponse < ApplicationRecord
|
||||
self.table_name = 'captain_assistant_responses'
|
||||
SEARCH_LIMIT = 5
|
||||
KEYWORD_SEARCH_LIMIT_MULTIPLIER = 2
|
||||
SEARCH_STOP_WORDS = %w[
|
||||
about after all also and any are but can for from has have how into its may more not now off our out
|
||||
the their them then there these they this was what when where which who why with you your
|
||||
].freeze
|
||||
SearchMatch = Struct.new(
|
||||
:response,
|
||||
:semantic_distance,
|
||||
:keyword_score,
|
||||
:keyword_coverage,
|
||||
:matched_terms,
|
||||
:retrieval_methods,
|
||||
keyword_init: true
|
||||
) do
|
||||
def to_h
|
||||
@@ -46,11 +37,7 @@ class Captain::AssistantResponse < ApplicationRecord
|
||||
question: response.question,
|
||||
answer: response.answer,
|
||||
source: response.documentable&.try(:external_link),
|
||||
semantic_distance: semantic_distance,
|
||||
keyword_score: keyword_score,
|
||||
keyword_coverage: keyword_coverage,
|
||||
matched_terms: matched_terms,
|
||||
retrieval_methods: retrieval_methods
|
||||
semantic_distance: semantic_distance
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -80,10 +67,7 @@ class Captain::AssistantResponse < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.search_with_metadata(query, account_id: nil, limit: SEARCH_LIMIT)
|
||||
semantic_matches = semantic_search_matches(query, account_id: account_id, limit: limit)
|
||||
keyword_matches = keyword_search_matches(query, limit: limit * KEYWORD_SEARCH_LIMIT_MULTIPLIER)
|
||||
|
||||
merge_search_matches(semantic_matches, keyword_matches).first(limit)
|
||||
semantic_search_matches(query, account_id: account_id, limit: limit)
|
||||
end
|
||||
|
||||
def self.semantic_search_matches(query, account_id:, limit:)
|
||||
@@ -91,92 +75,11 @@ class Captain::AssistantResponse < ApplicationRecord
|
||||
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(limit).map do |response|
|
||||
SearchMatch.new(
|
||||
response: response,
|
||||
semantic_distance: response.neighbor_distance&.to_f,
|
||||
keyword_score: 0,
|
||||
keyword_coverage: 0.0,
|
||||
matched_terms: [],
|
||||
retrieval_methods: ['semantic']
|
||||
semantic_distance: response.neighbor_distance&.to_f
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def self.keyword_search_matches(query, limit:)
|
||||
terms = search_terms(query)
|
||||
return [] if terms.empty?
|
||||
|
||||
matches = keyword_search_scope(terms).limit(limit).map do |response|
|
||||
matched_terms = matched_terms_for(response, terms)
|
||||
SearchMatch.new(
|
||||
response: response,
|
||||
semantic_distance: nil,
|
||||
keyword_score: matched_terms.size,
|
||||
keyword_coverage: matched_terms.size.to_f / terms.size,
|
||||
matched_terms: matched_terms,
|
||||
retrieval_methods: ['keyword']
|
||||
)
|
||||
end
|
||||
matches.sort_by { |match| [-match.keyword_score, match.response.id] }
|
||||
end
|
||||
|
||||
def self.keyword_search_scope(terms)
|
||||
conditions = []
|
||||
values = []
|
||||
|
||||
terms.each do |term|
|
||||
pattern = "%#{sanitize_sql_like(term)}%"
|
||||
conditions << '(question ILIKE ? OR answer ILIKE ?)'
|
||||
values.push(pattern, pattern)
|
||||
end
|
||||
|
||||
where(conditions.join(' OR '), *values)
|
||||
end
|
||||
|
||||
def self.search_terms(query)
|
||||
query.to_s.downcase.scan(/[[:alnum:]]+/).filter_map do |term|
|
||||
next if term.length < 3
|
||||
next if SEARCH_STOP_WORDS.include?(term)
|
||||
|
||||
term
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def self.matched_terms_for(response, terms)
|
||||
text = "#{response.question} #{response.answer}".downcase
|
||||
terms.select { |term| text.include?(term) }
|
||||
end
|
||||
|
||||
def self.merge_search_matches(semantic_matches, keyword_matches)
|
||||
matches_by_response_id = {}
|
||||
|
||||
semantic_matches.each do |match|
|
||||
matches_by_response_id[match.response.id] = match
|
||||
end
|
||||
|
||||
keyword_matches.each do |keyword_match|
|
||||
existing = matches_by_response_id[keyword_match.response.id]
|
||||
if existing
|
||||
merge_keyword_match!(existing, keyword_match)
|
||||
else
|
||||
matches_by_response_id[keyword_match.response.id] = keyword_match
|
||||
end
|
||||
end
|
||||
|
||||
sort_search_matches(matches_by_response_id.values)
|
||||
end
|
||||
|
||||
def self.merge_keyword_match!(existing, keyword_match)
|
||||
existing.keyword_score = keyword_match.keyword_score
|
||||
existing.keyword_coverage = keyword_match.keyword_coverage
|
||||
existing.matched_terms = keyword_match.matched_terms
|
||||
existing.retrieval_methods |= keyword_match.retrieval_methods
|
||||
end
|
||||
|
||||
def self.sort_search_matches(matches)
|
||||
matches.sort_by do |match|
|
||||
[match.semantic_distance || 1.0, -match.keyword_score, match.response.id]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_status
|
||||
|
||||
@@ -6,10 +6,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[id display_id inbox_id contact_id status priority label_list custom_attributes additional_attributes].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
@@ -19,6 +16,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@@ -100,6 +98,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
response = output.is_a?(Hash) ? output.with_indifferent_access : { 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
|
||||
response['agent_name'] = result.context&.dig(:current_agent)
|
||||
response['handoff_tool_called'] = result.context&.dig(:captain_v2_handoff_tool_called) || false
|
||||
response['documentation_searches'] = result.context&.dig(:state, :documentation_searches).to_a
|
||||
response
|
||||
end
|
||||
|
||||
@@ -115,7 +114,8 @@ class Captain::Assistant::AgentRunnerService
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
assistant_config: @assistant.config,
|
||||
documentation_searches: []
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
class Captain::DocumentationSearchService
|
||||
MAX_ACCEPTABLE_COSINE_DISTANCE = 0.45
|
||||
MIN_ACCEPTABLE_KEYWORD_COVERAGE = 0.4
|
||||
# pgvector cosine distance: lower is closer. This only marks retrieval confidence;
|
||||
# final answer support is checked by Captain::Llm::DocumentationSufficiencyService.
|
||||
CLOSE_MATCH_DISTANCE = 0.45
|
||||
TOP_MATCHES_TO_FORMAT = 5
|
||||
SEARCH_ATTEMPT_LIMIT = 3
|
||||
|
||||
Result = Struct.new(:query, :queries, :matches, :status, :reason, keyword_init: true) do
|
||||
def weak?
|
||||
status == 'weak'
|
||||
end
|
||||
|
||||
def empty?
|
||||
matches.empty?
|
||||
end
|
||||
|
||||
def to_h
|
||||
{
|
||||
query: query,
|
||||
queries: queries,
|
||||
status: status,
|
||||
reason: reason,
|
||||
matches: matches.map(&:to_h)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(scope:, account_id: nil)
|
||||
@scope = scope
|
||||
@@ -30,28 +10,25 @@ class Captain::DocumentationSearchService
|
||||
end
|
||||
|
||||
def search(query)
|
||||
matches = []
|
||||
queries = []
|
||||
|
||||
search_queries(query).each do |search_query|
|
||||
queries << search_query
|
||||
matches = merge_matches(matches, @scope.search_with_metadata(search_query, account_id: @account_id))
|
||||
break if matches.first && sufficient_match?(matches.first)
|
||||
end
|
||||
|
||||
Result.new(query: query, queries: queries, matches: matches, status: status_for(matches), reason: reason_for(matches))
|
||||
matches = @scope.search_with_metadata(query, account_id: @account_id)
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
matches: matches,
|
||||
status: status_for(matches),
|
||||
reason: reason_for(matches)
|
||||
}
|
||||
end
|
||||
|
||||
def self.record(result)
|
||||
Current.captain_documentation_searches ||= []
|
||||
Current.captain_documentation_searches << result.to_h
|
||||
def self.serialize(result)
|
||||
result.merge(matches: result[:matches].map(&:to_h))
|
||||
end
|
||||
|
||||
def self.format_for_tool(result, no_results_message:)
|
||||
return "#{no_results_message}\n\n#{weak_instruction(result)}" if result.empty?
|
||||
return "#{no_results_message}\n\n#{weak_instruction}" if result[:matches].empty?
|
||||
|
||||
sections = [quality_section(result)]
|
||||
sections.concat(result.matches.first(TOP_MATCHES_TO_FORMAT).map { |match| format_match(match) })
|
||||
sections.concat(result[:matches].first(TOP_MATCHES_TO_FORMAT).map { |match| format_match(match) })
|
||||
sections.join("\n")
|
||||
end
|
||||
|
||||
@@ -61,20 +38,19 @@ class Captain::DocumentationSearchService
|
||||
if response.documentable.present? && response.documentable.try(:external_link)
|
||||
formatted_response += "Source: #{response.documentable.external_link}\n"
|
||||
end
|
||||
formatted_response += "Retrieval: #{match.retrieval_methods.join(', ')}"
|
||||
formatted_response += 'Retrieval: semantic'
|
||||
formatted_response += ", semantic_distance=#{format('%.4f', match.semantic_distance)}" if match.semantic_distance.present?
|
||||
formatted_response += ", keyword_coverage=#{format('%.2f', match.keyword_coverage)}" if match.keyword_score.positive?
|
||||
"#{formatted_response}\n"
|
||||
end
|
||||
|
||||
def self.quality_section(result)
|
||||
lines = ["Search quality: #{result.status}", "Search reason: #{result.reason}"]
|
||||
lines << "Search queries: #{result.queries.join(' | ')}" if result.queries.to_a.size > 1
|
||||
lines << weak_instruction(result) if result.weak?
|
||||
lines = ["Search quality: #{result[:status]}", "Search reason: #{result[:reason]}"]
|
||||
lines << "Search queries: #{result[:queries].join(' | ')}" if result[:queries].to_a.size > 1
|
||||
lines << weak_instruction if result[:status] == 'weak'
|
||||
lines.join("\n")
|
||||
end
|
||||
|
||||
def self.weak_instruction(_result)
|
||||
def self.weak_instruction
|
||||
[
|
||||
'Instruction: The retrieved documentation is missing or weak. Do not use it to make factual claims.',
|
||||
"Say you couldn't find enough information to answer confidently, ask one clarifying question if useful, or ask whether",
|
||||
@@ -84,72 +60,22 @@ class Captain::DocumentationSearchService
|
||||
|
||||
private
|
||||
|
||||
def search_queries(query)
|
||||
query = query.to_s.strip
|
||||
terms = Captain::AssistantResponse.search_terms(query)
|
||||
keyword_query = terms.join(' ')
|
||||
trailing_query = terms.last(8).join(' ')
|
||||
|
||||
[query, keyword_query, trailing_query].filter_map(&:presence).uniq.first(SEARCH_ATTEMPT_LIMIT)
|
||||
end
|
||||
|
||||
def merge_matches(existing_matches, new_matches)
|
||||
matches_by_response_id = existing_matches.index_by { |match| match.response.id }
|
||||
|
||||
new_matches.each do |new_match|
|
||||
existing_match = matches_by_response_id[new_match.response.id]
|
||||
if existing_match
|
||||
merge_match!(existing_match, new_match)
|
||||
else
|
||||
matches_by_response_id[new_match.response.id] = new_match
|
||||
end
|
||||
end
|
||||
|
||||
matches_by_response_id.values.sort_by do |match|
|
||||
[match.semantic_distance || 1.0, -match.keyword_score, match.response.id]
|
||||
end
|
||||
end
|
||||
|
||||
def merge_match!(existing_match, new_match)
|
||||
existing_match.semantic_distance = best_semantic_distance(existing_match.semantic_distance, new_match.semantic_distance)
|
||||
existing_match.keyword_score = [existing_match.keyword_score, new_match.keyword_score].max
|
||||
existing_match.keyword_coverage = [existing_match.keyword_coverage, new_match.keyword_coverage].max
|
||||
existing_match.matched_terms |= new_match.matched_terms
|
||||
existing_match.retrieval_methods |= new_match.retrieval_methods
|
||||
end
|
||||
|
||||
def best_semantic_distance(existing_distance, new_distance)
|
||||
return new_distance if existing_distance.blank?
|
||||
return existing_distance if new_distance.blank?
|
||||
|
||||
[existing_distance, new_distance].min
|
||||
end
|
||||
|
||||
def status_for(matches)
|
||||
return 'weak' if matches.empty?
|
||||
|
||||
sufficient_match?(matches.first) ? 'sufficient' : 'weak'
|
||||
close_match?(matches.first) ? 'found' : 'weak'
|
||||
end
|
||||
|
||||
def reason_for(matches)
|
||||
return 'no_results' if matches.empty?
|
||||
|
||||
top_match = matches.first
|
||||
return 'semantic_match' if top_match.semantic_distance.present? && top_match.semantic_distance <= MAX_ACCEPTABLE_COSINE_DISTANCE
|
||||
return 'keyword_match' if top_match.keyword_coverage >= MIN_ACCEPTABLE_KEYWORD_COVERAGE
|
||||
return 'semantic_match' if close_match?(top_match)
|
||||
|
||||
'low_retrieval_confidence'
|
||||
end
|
||||
|
||||
def sufficient_match?(match)
|
||||
semantic_match?(match) || keyword_match?(match)
|
||||
end
|
||||
|
||||
def semantic_match?(match)
|
||||
match.semantic_distance.present? && match.semantic_distance <= MAX_ACCEPTABLE_COSINE_DISTANCE
|
||||
end
|
||||
|
||||
def keyword_match?(match)
|
||||
match.keyword_coverage >= MIN_ACCEPTABLE_KEYWORD_COVERAGE
|
||||
def close_match?(match)
|
||||
match.semantic_distance.present? && match.semantic_distance <= CLOSE_MATCH_DISTANCE
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
attr_reader :documentation_searches
|
||||
|
||||
def initialize(assistant: nil, conversation: nil, source: nil)
|
||||
super()
|
||||
@@ -11,6 +12,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
@documentation_searches = []
|
||||
@tools = build_tools
|
||||
end
|
||||
|
||||
@@ -30,7 +32,13 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
private
|
||||
|
||||
def build_tools
|
||||
tools = [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
tools = [
|
||||
Captain::Tools::SearchDocumentationService.new(
|
||||
@assistant,
|
||||
user: nil,
|
||||
on_search: ->(search) { @documentation_searches << search }
|
||||
)
|
||||
]
|
||||
return tools unless custom_tools_enabled?
|
||||
|
||||
tools + @assistant.account.captain_custom_tools.enabled.map do |ct|
|
||||
|
||||
@@ -49,17 +49,19 @@ class Captain::Llm::DocumentationSufficiencyService < Llm::BaseAiService
|
||||
Return "insufficient" when the assistant makes factual claims that are not supported by the retrieved documentation.
|
||||
Treat prior assistant messages as claims, not evidence. They do not support the new answer by themselves.
|
||||
Conversation context can support the answer only when the user explicitly provided the relevant fact, constraint, or artifact.
|
||||
Check generic sufficiency dimensions:
|
||||
Check generic support dimensions:
|
||||
- same entity, product, platform, integration, or account object
|
||||
- same user intent, not just a nearby topic
|
||||
- requested constraints such as plan, edition, region, channel, version, provider, billing period, availability, or current status
|
||||
- high-risk claims such as pricing, billing, legal/compliance, limits, availability, platform support, roadmap, or account status
|
||||
- evidence specificity; generic broad docs are not enough for specific claims
|
||||
- requested constraints from the user
|
||||
- specific claims in the response
|
||||
- evidence specificity; broad docs are not enough for specific claims
|
||||
|
||||
Return "sufficient" when the documentation directly supports the response, or when the response only asks a clarifying
|
||||
Return "sufficient" only when the documentation directly supports the response, or when the response only asks a clarifying
|
||||
question, gives a safe bounded no-answer, offers handoff, or restates user-provided context without adding external claims.
|
||||
If documentation is missing or weak and the response gives factual claims, advice, instructions, examples, links, prices,
|
||||
limits, availability, troubleshooting steps, product behavior, platform behavior, or account-specific statements, return "insufficient".
|
||||
A response is not a safe bounded no-answer if it includes unsupported facts, steps, recommendations, examples, or links.
|
||||
A response does not answer the exact question unless the retrieved documentation supports the specific claims it makes.
|
||||
If documentation is missing or weak and the response gives factual claims, advice, instructions, examples, links,
|
||||
product behavior, platform behavior, or account-specific statements, return "insufficient".
|
||||
|
||||
If decision is "insufficient", write fallback_response in the user's language. It should be brief, say you could not find
|
||||
enough information to answer confidently, and ask whether the user wants to talk to a support person when appropriate.
|
||||
@@ -104,8 +106,6 @@ class Captain::Llm::DocumentationSufficiencyService < Llm::BaseAiService
|
||||
answer: #{truncate_text(match_value(match, :answer))}
|
||||
source: #{match_value(match, :source)}
|
||||
semantic_distance: #{match_value(match, :semantic_distance)}
|
||||
keyword_coverage: #{match_value(match, :keyword_coverage)}
|
||||
retrieval_methods: #{Array(match_value(match, :retrieval_methods)).join(', ')}
|
||||
MATCH
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
||||
def initialize(assistant, user: nil, on_search: nil)
|
||||
super(assistant, user: user)
|
||||
@on_search = on_search
|
||||
end
|
||||
|
||||
def self.name
|
||||
'search_documentation'
|
||||
end
|
||||
@@ -17,7 +22,7 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
||||
scope: assistant.responses.approved,
|
||||
account_id: assistant.account_id
|
||||
).search(translated_query)
|
||||
Captain::DocumentationSearchService.record(result)
|
||||
@on_search&.call(Captain::DocumentationSearchService.serialize(result))
|
||||
|
||||
Captain::DocumentationSearchService.format_for_tool(result, no_results_message: 'No FAQs found for the given query')
|
||||
end
|
||||
|
||||
@@ -5,9 +5,10 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
||||
|
||||
param :query, desc: 'Search Query', required: true
|
||||
|
||||
def initialize(account:, assistant: nil)
|
||||
def initialize(account:, assistant: nil, on_search: nil)
|
||||
@account = account
|
||||
@assistant = assistant
|
||||
@on_search = on_search
|
||||
super()
|
||||
end
|
||||
|
||||
@@ -26,7 +27,7 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool
|
||||
scope: search_scope,
|
||||
account_id: @account.id
|
||||
).search(translated_query)
|
||||
Captain::DocumentationSearchService.record(result)
|
||||
@on_search&.call(Captain::DocumentationSearchService.serialize(result))
|
||||
|
||||
Captain::DocumentationSearchService.format_for_tool(result, no_results_message: 'No FAQs found for the given query')
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ class Captain::DocumentationSufficiencySchema < RubyLLM::Schema
|
||||
wrong_intent
|
||||
missing_constraint
|
||||
generic_evidence
|
||||
unsupported_high_risk_claim
|
||||
unsupported_specific_claim
|
||||
].freeze
|
||||
|
||||
string :decision,
|
||||
|
||||
@@ -2,21 +2,30 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
description 'Search FAQ responses using semantic similarity to find relevant answers'
|
||||
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
def perform(tool_context, query:)
|
||||
log_tool_usage('searching', { query: query })
|
||||
|
||||
result = Captain::DocumentationSearchService.new(
|
||||
scope: @assistant.responses.approved,
|
||||
account_id: @assistant.account_id
|
||||
).search(query)
|
||||
Captain::DocumentationSearchService.record(result)
|
||||
record_documentation_search(tool_context, result)
|
||||
|
||||
if result.empty?
|
||||
if result[:matches].empty?
|
||||
log_tool_usage('no_results', { query: query })
|
||||
else
|
||||
log_tool_usage('found_results', { query: query, count: result.matches.size, status: result.status, reason: result.reason })
|
||||
log_tool_usage('found_results', { query: query, count: result[:matches].size, status: result[:status], reason: result[:reason] })
|
||||
end
|
||||
|
||||
Captain::DocumentationSearchService.format_for_tool(result, no_results_message: "No relevant FAQs found for: #{query}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def record_documentation_search(tool_context, result)
|
||||
searches = tool_context&.state&.dig(:documentation_searches)
|
||||
return unless searches
|
||||
|
||||
searches << Captain::DocumentationSearchService.serialize(result)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,6 @@ module Current
|
||||
thread_mattr_accessor :account_user
|
||||
thread_mattr_accessor :executed_by
|
||||
thread_mattr_accessor :contact
|
||||
thread_mattr_accessor :captain_documentation_searches
|
||||
|
||||
def self.reset
|
||||
Current.user = nil
|
||||
@@ -12,6 +11,5 @@ module Current
|
||||
Current.account_user = nil
|
||||
Current.executed_by = nil
|
||||
Current.contact = nil
|
||||
Current.captain_documentation_searches = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(inbox).to receive(:captain_active?).and_return(true)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([])
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||
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)
|
||||
@@ -155,13 +156,13 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
|
||||
context 'when documentation sufficiency gate is enabled' do
|
||||
let(:weak_search_result) do
|
||||
Captain::DocumentationSearchService::Result.new(
|
||||
{
|
||||
query: 'current plan limits',
|
||||
queries: ['current plan limits'],
|
||||
matches: [],
|
||||
status: 'weak',
|
||||
reason: 'no_results'
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
@@ -173,10 +174,10 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
end
|
||||
|
||||
it 'replaces an unsupported answer with the bounded fallback from the gate' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response) do
|
||||
Captain::DocumentationSearchService.record(weak_search_result)
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([weak_search_result])
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return(
|
||||
{ 'response' => 'Your current plan has unlimited usage.' }
|
||||
end
|
||||
)
|
||||
allow(mock_documentation_sufficiency_service).to receive(:evaluate).and_return(
|
||||
{
|
||||
'decision' => 'insufficient',
|
||||
@@ -195,10 +196,10 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
end
|
||||
|
||||
it 'keeps the assistant response when the gate finds enough support' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response) do
|
||||
Captain::DocumentationSearchService.record(weak_search_result)
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([weak_search_result])
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return(
|
||||
{ 'response' => 'Billing settings show current plan usage.' }
|
||||
end
|
||||
)
|
||||
allow(mock_documentation_sufficiency_service).to receive(:evaluate).and_return(
|
||||
{ 'decision' => 'sufficient', 'reason' => 'answers_exact_question', 'fallback_response' => '', 'model' => 'gpt-4.1' }
|
||||
)
|
||||
@@ -208,7 +209,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Billing settings show current plan usage.')
|
||||
end
|
||||
|
||||
it 'checks high-risk answers even when no documentation search was recorded' do
|
||||
it 'checks answers even when no documentation search was recorded' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return(
|
||||
{ 'response' => 'Your current plan costs $99 per agent per month.' }
|
||||
)
|
||||
@@ -227,7 +228,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
).and_return(
|
||||
{
|
||||
'decision' => 'insufficient',
|
||||
'reason' => 'unsupported_high_risk_claim',
|
||||
'reason' => 'unsupported_specific_claim',
|
||||
'fallback_response' => "I couldn't find enough information to answer that confidently. Would you like support?",
|
||||
'model' => 'gpt-4.1'
|
||||
}
|
||||
@@ -455,6 +456,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(Captain::OpenAiMessageBuilderService).to receive(:new).with(message: anything).and_return(mock_message_builder)
|
||||
allow(mock_message_builder).to receive(:generate_content).and_return('Hello with image')
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Test response' })
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([])
|
||||
end
|
||||
|
||||
context 'when ActiveStorage::FileNotFoundError occurs' do
|
||||
@@ -567,6 +569,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
allow(mock_llm_chat_service).to receive(:documentation_searches).and_return([])
|
||||
end
|
||||
|
||||
context 'when handoff occurs outside business hours' do
|
||||
|
||||
@@ -4,7 +4,8 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
let(:documentation_searches) { [] }
|
||||
let(:tool_context) { Struct.new(:state).new({ documentation_searches: documentation_searches }) }
|
||||
let(:documentation_search_service) { instance_double(Captain::DocumentationSearchService) }
|
||||
|
||||
before do
|
||||
@@ -29,6 +30,20 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
def search_match(response)
|
||||
Captain::AssistantResponse::SearchMatch.new(response: response, semantic_distance: 0.2)
|
||||
end
|
||||
|
||||
def search_result(query:, matches:, status:, reason:)
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
matches: matches,
|
||||
status: status,
|
||||
reason: reason
|
||||
}
|
||||
end
|
||||
|
||||
context 'when FAQs exist' do
|
||||
let(:document) { create(:captain_document, assistant: assistant) }
|
||||
let!(:response1) do
|
||||
@@ -50,24 +65,10 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
end
|
||||
|
||||
before do
|
||||
matches = [response1, response2].map do |response|
|
||||
Captain::AssistantResponse::SearchMatch.new(
|
||||
response: response,
|
||||
semantic_distance: 0.2,
|
||||
keyword_score: 0,
|
||||
keyword_coverage: 0.0,
|
||||
matched_terms: [],
|
||||
retrieval_methods: ['semantic']
|
||||
)
|
||||
end
|
||||
search_result = Captain::DocumentationSearchService::Result.new(
|
||||
query: 'password reset',
|
||||
queries: ['password reset'],
|
||||
matches: matches,
|
||||
status: 'sufficient',
|
||||
reason: 'semantic_match'
|
||||
matches = [response1, response2].map { |response| search_match(response) }
|
||||
allow(documentation_search_service).to receive(:search).and_return(
|
||||
search_result(query: 'password reset', matches: matches, status: 'found', reason: 'semantic_match')
|
||||
)
|
||||
allow(documentation_search_service).to receive(:search).and_return(search_result)
|
||||
end
|
||||
|
||||
it 'searches FAQs and returns formatted responses' do
|
||||
@@ -77,6 +78,7 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
expect(result).to include('Answer: Click on forgot password link')
|
||||
expect(result).to include('Question: How to change email?')
|
||||
expect(result).to include('Answer: Go to settings and update email')
|
||||
expect(documentation_searches.first[:status]).to eq('found')
|
||||
end
|
||||
|
||||
it 'includes source link when document has external_link' do
|
||||
@@ -91,7 +93,7 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
expect(tool).to receive(:log_tool_usage).with('searching', { query: 'password reset' })
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'found_results',
|
||||
{ query: 'password reset', count: 2, status: 'sufficient', reason: 'semantic_match' }
|
||||
{ query: 'password reset', count: 2, status: 'found', reason: 'semantic_match' }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
@@ -100,14 +102,9 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
context 'when no FAQs found' do
|
||||
before do
|
||||
search_result = Captain::DocumentationSearchService::Result.new(
|
||||
query: 'nonexistent topic',
|
||||
queries: ['nonexistent topic'],
|
||||
matches: [],
|
||||
status: 'weak',
|
||||
reason: 'no_results'
|
||||
allow(documentation_search_service).to receive(:search).and_return(
|
||||
search_result(query: 'nonexistent topic', matches: [], status: 'weak', reason: 'no_results')
|
||||
)
|
||||
allow(documentation_search_service).to receive(:search).and_return(search_result)
|
||||
end
|
||||
|
||||
it 'returns no results message' do
|
||||
@@ -126,14 +123,9 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
context 'with blank query' do
|
||||
it 'handles empty query' do
|
||||
search_result = Captain::DocumentationSearchService::Result.new(
|
||||
query: '',
|
||||
queries: [],
|
||||
matches: [],
|
||||
status: 'weak',
|
||||
reason: 'no_results'
|
||||
allow(documentation_search_service).to receive(:search).and_return(
|
||||
search_result(query: '', matches: [], status: 'weak', reason: 'no_results')
|
||||
)
|
||||
allow(documentation_search_service).to receive(:search).and_return(search_result)
|
||||
|
||||
result = tool.perform(tool_context, query: '')
|
||||
expect(result).to include('No relevant FAQs found for: ')
|
||||
|
||||
@@ -167,7 +167,17 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'processes and formats agent result' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false })
|
||||
expect(result).to include('response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false)
|
||||
end
|
||||
|
||||
it 'includes documentation searches from runner state' do
|
||||
searches = [{ query: 'billing', matches: [], status: 'weak', reason: 'no_results' }]
|
||||
runner_context = { state: { documentation_searches: searches } }
|
||||
allow(mock_result).to receive(:context).and_return(runner_context)
|
||||
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result['documentation_searches']).to eq(searches)
|
||||
end
|
||||
|
||||
context 'when handoff tool was called during agent execution' do
|
||||
@@ -179,11 +189,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'includes handoff_tool_called flag in response' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'Let me connect you',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => true
|
||||
})
|
||||
expect(result).to include('response' => 'Let me connect you', 'agent_name' => nil, 'handoff_tool_called' => true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -208,12 +214,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
it 'formats string response correctly' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'Simple string response',
|
||||
'reasoning' => 'Processed by agent',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false
|
||||
})
|
||||
expect(result).to include(
|
||||
'response' => 'Simple string response',
|
||||
'reasoning' => 'Processed by agent',
|
||||
'agent_name' => nil,
|
||||
'handoff_tool_called' => false
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -20,59 +20,54 @@ RSpec.describe Captain::DocumentationSearchService do
|
||||
)
|
||||
end
|
||||
|
||||
def search_match(semantic_distance:, keyword_coverage:, keyword_score: 0, response_record: response)
|
||||
def search_match(semantic_distance:, response_record: response)
|
||||
Captain::AssistantResponse::SearchMatch.new(
|
||||
response: response_record,
|
||||
semantic_distance: semantic_distance,
|
||||
keyword_score: keyword_score,
|
||||
keyword_coverage: keyword_coverage,
|
||||
matched_terms: [],
|
||||
retrieval_methods: ['semantic']
|
||||
semantic_distance: semantic_distance
|
||||
)
|
||||
end
|
||||
|
||||
def search_result(matches:, status:, reason:, query: 'billing')
|
||||
{
|
||||
query: query,
|
||||
queries: [query],
|
||||
matches: matches,
|
||||
status: status,
|
||||
reason: reason
|
||||
}
|
||||
end
|
||||
|
||||
describe '#search' do
|
||||
it 'retries with generic query variants when the original query has weak matches' do
|
||||
it 'marks weak semantic matches as weak' do
|
||||
query = 'How do I check limits for my current monthly plan?'
|
||||
weak_match = search_match(semantic_distance: 0.9, keyword_coverage: 0.0)
|
||||
sufficient_match = search_match(semantic_distance: 0.8, keyword_coverage: 0.4, keyword_score: 2)
|
||||
weak_match = search_match(semantic_distance: 0.9)
|
||||
|
||||
allow(scope).to receive(:search_with_metadata).with(query, account_id: 1).and_return([weak_match])
|
||||
allow(scope).to receive(:search_with_metadata)
|
||||
.with('check limits current monthly plan', account_id: 1)
|
||||
.and_return([sufficient_match])
|
||||
|
||||
result = service.search(query)
|
||||
|
||||
expect(result.status).to eq('sufficient')
|
||||
expect(result.reason).to eq('keyword_match')
|
||||
expect(result.queries).to eq([query, 'check limits current monthly plan'])
|
||||
expect(result.matches.first.keyword_coverage).to eq(0.4)
|
||||
expect(result[:status]).to eq('weak')
|
||||
expect(result[:reason]).to eq('low_retrieval_confidence')
|
||||
expect(result[:queries]).to eq([query])
|
||||
end
|
||||
|
||||
it 'stops after the first query when retrieval confidence is sufficient' do
|
||||
it 'marks close semantic matches as found' do
|
||||
query = 'Where do I find billing settings?'
|
||||
sufficient_match = search_match(semantic_distance: 0.2, keyword_coverage: 0.0)
|
||||
close_match = search_match(semantic_distance: 0.2)
|
||||
|
||||
allow(scope).to receive(:search_with_metadata).with(query, account_id: 1).and_return([sufficient_match])
|
||||
allow(scope).to receive(:search_with_metadata).with(query, account_id: 1).and_return([close_match])
|
||||
|
||||
result = service.search(query)
|
||||
|
||||
expect(result.status).to eq('sufficient')
|
||||
expect(result.reason).to eq('semantic_match')
|
||||
expect(result.queries).to eq([query])
|
||||
expect(result[:status]).to eq('found')
|
||||
expect(result[:reason]).to eq('semantic_match')
|
||||
expect(result[:queries]).to eq([query])
|
||||
end
|
||||
end
|
||||
|
||||
describe '.format_for_tool' do
|
||||
it 'adds a bounded-answer instruction when no documentation is found' do
|
||||
result = described_class::Result.new(
|
||||
query: 'unknown topic',
|
||||
queries: ['unknown topic'],
|
||||
matches: [],
|
||||
status: 'weak',
|
||||
reason: 'no_results'
|
||||
)
|
||||
result = search_result(query: 'unknown topic', matches: [], status: 'weak', reason: 'no_results')
|
||||
|
||||
formatted_result = described_class.format_for_tool(result, no_results_message: 'No FAQs found')
|
||||
|
||||
@@ -81,22 +76,14 @@ RSpec.describe Captain::DocumentationSearchService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '.record' do
|
||||
it 'stores search metadata on Current for the response-level sufficiency gate' do
|
||||
result = described_class::Result.new(
|
||||
query: 'billing',
|
||||
queries: ['billing'],
|
||||
matches: [search_match(semantic_distance: 0.2, keyword_coverage: 0.0)],
|
||||
status: 'sufficient',
|
||||
reason: 'semantic_match'
|
||||
)
|
||||
describe '.serialize' do
|
||||
it 'formats search metadata for the response-level support gate' do
|
||||
result = search_result(matches: [search_match(semantic_distance: 0.2)], status: 'found', reason: 'semantic_match')
|
||||
|
||||
described_class.record(result)
|
||||
serialized_result = described_class.serialize(result)
|
||||
|
||||
expect(Current.captain_documentation_searches.first[:status]).to eq('sufficient')
|
||||
expect(Current.captain_documentation_searches.first[:matches].first[:semantic_distance]).to eq(0.2)
|
||||
ensure
|
||||
Current.captain_documentation_searches = nil
|
||||
expect(serialized_result[:status]).to eq('found')
|
||||
expect(serialized_result[:matches].first[:semantic_distance]).to eq(0.2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,17 +40,25 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
|
||||
end
|
||||
|
||||
let(:documentable) { create(:captain_document, external_link: external_link) }
|
||||
let(:recorded_searches) { [] }
|
||||
let(:service) { described_class.new(assistant, on_search: ->(search) { recorded_searches << search }) }
|
||||
let(:match) do
|
||||
Captain::AssistantResponse::SearchMatch.new(
|
||||
response: response,
|
||||
semantic_distance: 0.2,
|
||||
keyword_score: 0,
|
||||
keyword_coverage: 0.0,
|
||||
matched_terms: [],
|
||||
retrieval_methods: ['semantic']
|
||||
semantic_distance: 0.2
|
||||
)
|
||||
end
|
||||
|
||||
def search_result(matches:, status:, reason:)
|
||||
{
|
||||
query: question,
|
||||
queries: [question],
|
||||
matches: matches,
|
||||
status: status,
|
||||
reason: reason
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::TranslateQueryService).to receive(:new).and_return(translate_query_service)
|
||||
allow(translate_query_service).to receive(:translate).and_return(question)
|
||||
@@ -62,33 +70,24 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
|
||||
context 'when matching responses exist' do
|
||||
it 'returns formatted responses for the search query' do
|
||||
response.update(documentable: documentable)
|
||||
search_result = Captain::DocumentationSearchService::Result.new(
|
||||
query: question,
|
||||
queries: [question],
|
||||
matches: [match],
|
||||
status: 'sufficient',
|
||||
reason: 'semantic_match'
|
||||
allow(documentation_search_service).to receive(:search).with(question).and_return(
|
||||
search_result(matches: [match], status: 'found', reason: 'semantic_match')
|
||||
)
|
||||
allow(documentation_search_service).to receive(:search).with(question).and_return(search_result)
|
||||
|
||||
result = service.execute(query: question)
|
||||
|
||||
expect(result).to include(question)
|
||||
expect(result).to include(answer)
|
||||
expect(result).to include(external_link)
|
||||
expect(recorded_searches.first[:status]).to eq('found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no matching responses exist' do
|
||||
it 'returns a bounded no-results instruction' do
|
||||
search_result = Captain::DocumentationSearchService::Result.new(
|
||||
query: question,
|
||||
queries: [question],
|
||||
matches: [],
|
||||
status: 'weak',
|
||||
reason: 'no_results'
|
||||
allow(documentation_search_service).to receive(:search).with(question).and_return(
|
||||
search_result(matches: [], status: 'weak', reason: 'no_results')
|
||||
)
|
||||
allow(documentation_search_service).to receive(:search).with(question).and_return(search_result)
|
||||
|
||||
result = service.execute(query: question)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user