Records a `Captain::Session` row for every Captain V2 assistant response delivered in a conversation, so we can show how a response was generated and report on credit, FAQ, and document usage. Stacked on #14970 (the `captain_sessions` model). ## What changed - `FaqLookupTool` now records the retrieved FAQ ids (and their backing document ids) into the shared run state, accumulated across tool calls. - `AgentRunnerService` exposes the raw ai-agents run result via `last_run_result`; the `generate_response` return shape is unchanged, so the playground path is unaffected. - New `Captain::Assistant::SessionCaptureService` builds the session: scenario resolved from the answering agent name, model from `assistant.agent_model`, token usage plus the trimmed current-turn conversation history stored in `run_context`. - `ResponseBuilderJob` captures after delivery: `credits_consumed` mirrors the actual charge (1.0 for a billed response, 0.0 for handoffs, where the session points at the customer-facing handoff message). Capture runs outside the delivery transaction and swallows its own failures, so a logging bug can never block or roll back a customer reply. V1 responses and copilot are out of scope; copilot capture comes next. ## How to test On an account with `captain_integration_v2` enabled and an inbox connected to an assistant with approved FAQs, send a customer message on a pending conversation. After the assistant replies, a `Captain::Session` row should exist with the conversation as subject, the reply message as result, the FAQs/documents used, and the run context for that turn. Asking for a human agent should produce a zero-credit session pointing at the handoff message. <img width="2428" height="1058" alt="CleanShot 2026-07-15 at 17 25 40@2x" src="https://github.com/user-attachments/assets/d8e44923-c17b-494f-8c33-c8fa4219438c" />
60 lines
1.9 KiB
Ruby
60 lines
1.9 KiB
Ruby
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:)
|
|
log_tool_usage('searching', { query: query })
|
|
|
|
# Use existing vector search on approved responses
|
|
responses = @assistant.responses.approved.search(query).to_a
|
|
record_retrieved_sources(tool_context, responses)
|
|
|
|
if responses.empty?
|
|
log_tool_usage('no_results', { query: query })
|
|
"No relevant FAQs found for: #{query}"
|
|
else
|
|
log_tool_usage('found_results', { query: query, count: responses.size })
|
|
format_responses(responses)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def record_retrieved_sources(tool_context, responses)
|
|
return if responses.empty?
|
|
|
|
metadata = tool_context.state[:cw_metadata] ||= {}
|
|
metadata[:faq_ids] = Array(metadata[:faq_ids]) | responses.map(&:id)
|
|
|
|
document_ids = responses.filter_map { |response| response.documentable_id if response.documentable_type == 'Captain::Document' }
|
|
metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids
|
|
end
|
|
|
|
def format_responses(responses)
|
|
responses.map { |response| format_response(response) }.join
|
|
end
|
|
|
|
def format_response(response)
|
|
formatted_response = "
|
|
Question: #{response.question}
|
|
Answer: #{response.answer}
|
|
"
|
|
if should_show_source?(response)
|
|
formatted_response += "
|
|
Source: #{response.documentable.external_link}
|
|
"
|
|
end
|
|
|
|
formatted_response
|
|
end
|
|
|
|
def should_show_source?(response)
|
|
return false if response.documentable.blank?
|
|
return false unless response.documentable.try(:external_link)
|
|
|
|
# Don't show source if it's a PDF placeholder
|
|
external_link = response.documentable.external_link
|
|
!external_link.start_with?('PDF:')
|
|
end
|
|
end
|