Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12c419cb89 | ||
|
|
987000b95f | ||
|
|
02f288520e | ||
|
|
2ccb466598 |
@@ -0,0 +1,17 @@
|
|||||||
|
class CreateCaptainMessageSources < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :captain_message_sources do |t|
|
||||||
|
t.references :account, null: false, index: true
|
||||||
|
t.references :assistant, null: false, index: true
|
||||||
|
t.references :conversation, null: false, index: true
|
||||||
|
t.references :message, null: false, index: true
|
||||||
|
t.references :document, null: false, index: true
|
||||||
|
t.bigint :assistant_response_id, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :captain_message_sources, [:message_id, :assistant_response_id],
|
||||||
|
unique: true, name: 'idx_captain_message_sources_on_message_and_response'
|
||||||
|
end
|
||||||
|
end
|
||||||
+18
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
ActiveRecord::Schema[7.1].define(version: 2026_07_21_100000) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -477,6 +477,23 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
|||||||
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
|
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "captain_message_sources", force: :cascade do |t|
|
||||||
|
t.bigint "account_id", null: false
|
||||||
|
t.bigint "assistant_id", null: false
|
||||||
|
t.bigint "conversation_id", null: false
|
||||||
|
t.bigint "message_id", null: false
|
||||||
|
t.bigint "document_id", null: false
|
||||||
|
t.bigint "assistant_response_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_captain_message_sources_on_account_id"
|
||||||
|
t.index ["assistant_id"], name: "index_captain_message_sources_on_assistant_id"
|
||||||
|
t.index ["conversation_id"], name: "index_captain_message_sources_on_conversation_id"
|
||||||
|
t.index ["document_id"], name: "index_captain_message_sources_on_document_id"
|
||||||
|
t.index ["message_id", "assistant_response_id"], name: "idx_captain_message_sources_on_message_and_response", unique: true
|
||||||
|
t.index ["message_id"], name: "index_captain_message_sources_on_message_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "captain_scenarios", force: :cascade do |t|
|
create_table "captain_scenarios", force: :cascade do |t|
|
||||||
t.string "title"
|
t.string "title"
|
||||||
t.text "description"
|
t.text "description"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
|||||||
@documents = filtered_documents
|
@documents = filtered_documents
|
||||||
@documents_count = @documents.count
|
@documents_count = @documents.count
|
||||||
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
|
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
|
||||||
@documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE)
|
@documents = with_document_usage(@documents).page(@current_page).per(RESULTS_PER_PAGE)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -61,14 +61,23 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
|||||||
apply_sort(documents, permitted_params[:sort])
|
apply_sort(documents, permitted_params[:sort])
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_responses_count(scope)
|
def with_document_usage(scope)
|
||||||
scope.left_joins(:responses)
|
response_counts = Captain::AssistantResponse.where(documentable_type: 'Captain::Document')
|
||||||
.select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count')
|
.group(:documentable_id)
|
||||||
.group('captain_documents.id')
|
.select('documentable_id AS document_id, COUNT(*) AS responses_count')
|
||||||
|
source_counts = Captain::MessageSource.group(:document_id).select(
|
||||||
|
'document_id, COUNT(DISTINCT message_id) AS used_in_answers_count, COUNT(DISTINCT conversation_id) AS used_in_conversations_count'
|
||||||
|
)
|
||||||
|
|
||||||
|
scope.joins("LEFT JOIN (#{response_counts.to_sql}) response_counts ON response_counts.document_id = captain_documents.id")
|
||||||
|
.joins("LEFT JOIN (#{source_counts.to_sql}) source_counts ON source_counts.document_id = captain_documents.id")
|
||||||
|
.select('captain_documents.*, COALESCE(response_counts.responses_count, 0) AS responses_count, ' \
|
||||||
|
'COALESCE(source_counts.used_in_answers_count, 0) AS used_in_answers_count, ' \
|
||||||
|
'COALESCE(source_counts.used_in_conversations_count, 0) AS used_in_conversations_count')
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_document
|
def set_document
|
||||||
@document = @documents.find(permitted_params[:id])
|
@document = with_document_usage(@documents).find(permitted_params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_assistant
|
def set_assistant
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
module Captain::Conversation::MessageBuilder
|
module Captain::Conversation::MessageBuilder
|
||||||
|
SOURCE_MARKER_PATTERN = /\[\[source:(\d+)\]\]/
|
||||||
|
MALFORMED_SOURCE_MARKER_PATTERN = /\[\[source:[^\]\n]*\]\]?/
|
||||||
|
CUSTOMER_CITATION_PATTERN = /\[\[(\d+)\]\([^\)\n]*\)\]/
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def collect_previous_messages
|
def collect_previous_messages
|
||||||
@@ -28,8 +32,67 @@ module Captain::Conversation::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_messages
|
def create_messages
|
||||||
validate_message_content!(@response['response'])
|
message_content = resolve_citations(@response['response'])
|
||||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
|
validate_message_content!(message_content)
|
||||||
|
create_outgoing_message(message_content, agent_name: @response['agent_name'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_citations(content)
|
||||||
|
return content if content.blank?
|
||||||
|
|
||||||
|
citation_urls = trusted_citation_urls
|
||||||
|
content = content.gsub(CUSTOMER_CITATION_PATTERN) do
|
||||||
|
citation_for(Regexp.last_match(1), citation_urls)
|
||||||
|
end
|
||||||
|
content = content.gsub(SOURCE_MARKER_PATTERN) do
|
||||||
|
citation_for(Regexp.last_match(1), citation_urls)
|
||||||
|
end
|
||||||
|
content.gsub(MALFORMED_SOURCE_MARKER_PATTERN, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
def citation_for(source_number, citation_urls)
|
||||||
|
url = citation_urls[source_number.to_s]
|
||||||
|
return '' if url.blank?
|
||||||
|
|
||||||
|
"[[#{source_number}](#{url})]"
|
||||||
|
end
|
||||||
|
|
||||||
|
def trusted_citation_urls
|
||||||
|
return {} unless @assistant.config['feature_citation']
|
||||||
|
|
||||||
|
source_map = citation_source_map
|
||||||
|
return {} if source_map.empty?
|
||||||
|
|
||||||
|
document_links = Captain::Document.where(
|
||||||
|
id: source_map.values,
|
||||||
|
account_id: account.id,
|
||||||
|
assistant_id: @assistant.id
|
||||||
|
).pluck(:id, :external_link).to_h
|
||||||
|
|
||||||
|
source_map.each_with_object({}) do |(source_number, document_id), urls|
|
||||||
|
external_link = document_links[document_id]
|
||||||
|
urls[source_number] = external_link if customer_safe_document_link?(external_link)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def citation_source_map
|
||||||
|
raw_citation_source_map.filter_map do |source_number, document_id|
|
||||||
|
[source_number.to_s, document_id.to_i] if valid_citation_source?(source_number, document_id)
|
||||||
|
end.to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_citation_source_map
|
||||||
|
context = @run_result&.context || {}
|
||||||
|
state = context[:state] || context['state'] || {}
|
||||||
|
state[:captain_v2_citation_source_map] || state['captain_v2_citation_source_map'] || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_citation_source?(source_number, document_id)
|
||||||
|
source_number.to_s.match?(/\A[1-9]\d*\z/) && document_id.to_s.match?(/\A\d+\z/)
|
||||||
|
end
|
||||||
|
|
||||||
|
def customer_safe_document_link?(external_link)
|
||||||
|
external_link.present? && !external_link.start_with?('PDF:')
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_message_content!(content)
|
def validate_message_content!(content)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
# left is the customer-facing follow-up message.
|
# left is the customer-facing follow-up message.
|
||||||
process_v2_handoff
|
process_v2_handoff
|
||||||
end
|
end
|
||||||
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0)
|
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0, capture_message_sources: false)
|
||||||
elsif v1_handoff_requested?
|
elsif v1_handoff_requested?
|
||||||
# V1 only signals via the response string — no state has been touched yet. If
|
# V1 only signals via the response string — no state has been touched yet. If
|
||||||
# the conversation isn't pending anymore, a human took over mid-run; bail out
|
# the conversation isn't pending anymore, a human took over mid-run; bail out
|
||||||
@@ -83,7 +83,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||||
account.increment_response_usage
|
account.increment_response_usage
|
||||||
end
|
end
|
||||||
capture_assistant_session(result_message: message, credits_consumed: 1.0)
|
capture_assistant_session(result_message: message, credits_consumed: 1.0, capture_message_sources: captain_v2_enabled?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,9 +142,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
# Capture runs outside the delivery transaction and never raises (the service
|
# Capture runs outside the delivery transaction and never raises (the service
|
||||||
# swallows its own failures): a session-logging bug must never roll back the
|
# swallows its own failures): a session-logging bug must never roll back the
|
||||||
# customer reply or trigger the top-level handle_error handoff on top of it.
|
# customer reply or trigger the top-level handle_error handoff on top of it.
|
||||||
def capture_assistant_session(result_message:, credits_consumed:)
|
def capture_assistant_session(result_message:, credits_consumed:, capture_message_sources:)
|
||||||
Captain::Assistant::SessionCaptureService.new(assistant: @assistant, conversation: @conversation, run_result: @run_result,
|
Captain::Assistant::SessionCaptureService.new(assistant: @assistant, conversation: @conversation, run_result: @run_result,
|
||||||
result_message: result_message, credits_consumed: credits_consumed).capture
|
result_message: result_message, credits_consumed: credits_consumed,
|
||||||
|
capture_message_sources: capture_message_sources).capture
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_error(error)
|
def handle_error(error)
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
name: name,
|
name: name,
|
||||||
description: description,
|
description: description,
|
||||||
product_name: config['product_name'] || 'this product',
|
product_name: config['product_name'] || 'this product',
|
||||||
|
citation_enabled: config['feature_citation'],
|
||||||
scenarios: scenarios.enabled.map do |scenario|
|
scenarios: scenarios.enabled.map do |scenario|
|
||||||
{
|
{
|
||||||
title: scenario.title,
|
title: scenario.title,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class Captain::Document < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
|
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
|
||||||
|
has_many :message_sources, class_name: 'Captain::MessageSource', dependent: :destroy_async
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
has_one_attached :pdf_file
|
has_one_attached :pdf_file
|
||||||
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
|
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: captain_message_sources
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# assistant_id :bigint not null
|
||||||
|
# assistant_response_id :bigint not null
|
||||||
|
# conversation_id :bigint not null
|
||||||
|
# document_id :bigint not null
|
||||||
|
# message_id :bigint not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# idx_captain_message_sources_on_message_and_response (message_id,assistant_response_id) UNIQUE
|
||||||
|
# index_captain_message_sources_on_account_id (account_id)
|
||||||
|
# index_captain_message_sources_on_assistant_id (assistant_id)
|
||||||
|
# index_captain_message_sources_on_conversation_id (conversation_id)
|
||||||
|
# index_captain_message_sources_on_document_id (document_id)
|
||||||
|
# index_captain_message_sources_on_message_id (message_id)
|
||||||
|
#
|
||||||
|
class Captain::MessageSource < ApplicationRecord
|
||||||
|
self.table_name = 'captain_message_sources'
|
||||||
|
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||||
|
belongs_to :conversation, class_name: '::Conversation'
|
||||||
|
belongs_to :message
|
||||||
|
belongs_to :document, class_name: 'Captain::Document'
|
||||||
|
belongs_to :assistant_response, class_name: 'Captain::AssistantResponse', optional: true
|
||||||
|
end
|
||||||
@@ -16,6 +16,7 @@ module Enterprise::Concerns::Account
|
|||||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||||
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
|
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
|
||||||
|
has_many :captain_message_sources, dependent: :destroy_async, class_name: 'Captain::MessageSource'
|
||||||
|
|
||||||
has_many :copilot_threads, dependent: :destroy_async
|
has_many :copilot_threads, dependent: :destroy_async
|
||||||
has_many :companies, dependent: :destroy_async
|
has_many :companies, dependent: :destroy_async
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ module Enterprise::Concerns::Message
|
|||||||
included do
|
included do
|
||||||
has_one :call, dependent: :nullify
|
has_one :call, dependent: :nullify
|
||||||
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
|
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
|
||||||
|
has_many :captain_message_sources, class_name: 'Captain::MessageSource', dependent: :destroy_async
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
class Captain::Assistant::SessionCaptureService
|
class Captain::Assistant::SessionCaptureService
|
||||||
SCENARIO_AGENT_REGEX = /\A#{Captain::Scenario::HANDOFF_KEY_PREFIX}_(\d+)_/
|
SCENARIO_AGENT_REGEX = /\A#{Captain::Scenario::HANDOFF_KEY_PREFIX}_(\d+)_/
|
||||||
|
|
||||||
def initialize(assistant:, conversation:, run_result:, result_message:, credits_consumed:)
|
def initialize(assistant:, conversation:, run_result:, result_message:, **options)
|
||||||
@assistant = assistant
|
@assistant = assistant
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
@run_result = run_result
|
@run_result = run_result
|
||||||
@result_message = result_message
|
@result_message = result_message
|
||||||
@credits_consumed = credits_consumed
|
@credits_consumed = options.fetch(:credits_consumed)
|
||||||
|
@capture_message_sources = options.fetch(:capture_message_sources, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def capture
|
def capture
|
||||||
@@ -23,7 +24,7 @@ class Captain::Assistant::SessionCaptureService
|
|||||||
def capture!
|
def capture!
|
||||||
model = @assistant.agent_model
|
model = @assistant.agent_model
|
||||||
|
|
||||||
Captain::AgentSession.create!(
|
session = Captain::AgentSession.create!(
|
||||||
assistant: @assistant,
|
assistant: @assistant,
|
||||||
session_type: :assistant,
|
session_type: :assistant,
|
||||||
subject: @conversation,
|
subject: @conversation,
|
||||||
@@ -35,6 +36,8 @@ class Captain::Assistant::SessionCaptureService
|
|||||||
scenario_ids: scenario_ids,
|
scenario_ids: scenario_ids,
|
||||||
run_context: current_turn_history
|
run_context: current_turn_history
|
||||||
)
|
)
|
||||||
|
capture_message_sources(metadata) if @capture_message_sources
|
||||||
|
session
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -70,6 +73,42 @@ class Captain::Assistant::SessionCaptureService
|
|||||||
ids & @assistant.scenarios.where(id: ids).pluck(:id)
|
ids & @assistant.scenarios.where(id: ids).pluck(:id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def capture_message_sources(metadata)
|
||||||
|
sources = Array(metadata[:message_sources])
|
||||||
|
return if sources.empty?
|
||||||
|
|
||||||
|
document_ids = @assistant.documents.where(id: sources.pluck(:document_id)).pluck(:id)
|
||||||
|
return if document_ids.empty?
|
||||||
|
|
||||||
|
insert_message_sources(message_source_rows(sources, document_ids))
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_source_rows(sources, document_ids)
|
||||||
|
timestamp = Time.current
|
||||||
|
sources.filter_map do |source|
|
||||||
|
next unless document_ids.include?(source[:document_id])
|
||||||
|
|
||||||
|
{
|
||||||
|
account_id: @assistant.account_id,
|
||||||
|
assistant_id: @assistant.id,
|
||||||
|
conversation_id: @conversation.id,
|
||||||
|
message_id: @result_message.id,
|
||||||
|
document_id: source[:document_id],
|
||||||
|
assistant_response_id: source[:assistant_response_id],
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_message_sources(rows)
|
||||||
|
return if rows.empty?
|
||||||
|
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
Captain::MessageSource.insert_all(rows, unique_by: :idx_captain_message_sources_on_message_and_response)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
end
|
||||||
|
|
||||||
# Trim to the current turn: the last user message and everything after it
|
# Trim to the current turn: the last user message and everything after it
|
||||||
# (assistant replies, tool calls/results, handoff hops).
|
# (assistant replies, tool calls/results, handoff hops).
|
||||||
def current_turn_history
|
def current_turn_history
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ json.file_size resource.file_size
|
|||||||
json.pdf_document resource.pdf_document?
|
json.pdf_document resource.pdf_document?
|
||||||
responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
|
responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
|
||||||
json.responses_count responses_count.to_i
|
json.responses_count responses_count.to_i
|
||||||
|
used_in_answers_count = resource.respond_to?(:used_in_answers_count) ? resource.used_in_answers_count : resource.message_sources.distinct.count(:message_id)
|
||||||
|
json.used_in_answers_count used_in_answers_count.to_i
|
||||||
|
used_in_conversations_count = resource.respond_to?(:used_in_conversations_count) ? resource.used_in_conversations_count : resource.message_sources.distinct.count(:conversation_id)
|
||||||
|
json.used_in_conversations_count used_in_conversations_count.to_i
|
||||||
json.id resource.id
|
json.id resource.id
|
||||||
json.name resource.name
|
json.name resource.name
|
||||||
json.status resource.status
|
json.status resource.status
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ You are {{name}}, a helpful, friendly, and knowledgeable assistant for the produ
|
|||||||
|
|
||||||
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}}, use the `captain--tools--faq_lookup` tool to check the available information first.
|
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}}, use the `captain--tools--faq_lookup` tool to check the available information first.
|
||||||
|
|
||||||
|
{% if citation_enabled -%}
|
||||||
|
# Citations
|
||||||
|
When you use FAQ content that includes a source marker, add that exact marker at the end of the supported sentence or paragraph. Source markers have the form `[[source:n]]`. Do not create URLs or markdown links. Reuse the same source marker when the same document supports more than one statement. Do not cite conversation context.
|
||||||
|
{% endif -%}
|
||||||
|
|
||||||
{% render 'current_time', current_time: current_time %}
|
{% render 'current_time', current_time: current_time %}
|
||||||
|
|
||||||
{% render 'core_rules' %}
|
{% render 'core_rules' %}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
|||||||
"No relevant FAQs found for: #{query}"
|
"No relevant FAQs found for: #{query}"
|
||||||
else
|
else
|
||||||
log_tool_usage('found_results', { query: query, count: responses.size })
|
log_tool_usage('found_results', { query: query, count: responses.size })
|
||||||
format_responses(responses)
|
format_responses(tool_context, responses)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -26,34 +26,46 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
|||||||
metadata = tool_context.state[:cw_metadata] ||= {}
|
metadata = tool_context.state[:cw_metadata] ||= {}
|
||||||
metadata[:faq_ids] = Array(metadata[:faq_ids]) | responses.map(&:id)
|
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' }
|
document_ids = document_responses(responses).map(&:documentable_id)
|
||||||
metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids
|
metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids
|
||||||
|
metadata[:message_sources] = Array(metadata[:message_sources]) | message_sources(document_responses(responses))
|
||||||
|
|
||||||
|
document_ids.each { |document_id| citation_source_number(tool_context, document_id) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_responses(responses)
|
def document_responses(responses)
|
||||||
responses.map { |response| format_response(response) }.join
|
responses.select { |response| response.documentable_type == 'Captain::Document' }
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_response(response)
|
def message_sources(responses)
|
||||||
|
responses.map { |response| { assistant_response_id: response.id, document_id: response.documentable_id } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_responses(tool_context, responses)
|
||||||
|
responses.map { |response| format_response(tool_context, response) }.join
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_response(tool_context, response)
|
||||||
formatted_response = "
|
formatted_response = "
|
||||||
Question: #{response.question}
|
Question: #{response.question}
|
||||||
Answer: #{response.answer}
|
Answer: #{response.answer}
|
||||||
"
|
"
|
||||||
if should_show_source?(response)
|
if response.documentable_type == 'Captain::Document'
|
||||||
formatted_response += "
|
formatted_response += "
|
||||||
Source: #{response.documentable.external_link}
|
Source marker: [[source:#{citation_source_number(tool_context, response.documentable_id)}]]
|
||||||
"
|
"
|
||||||
end
|
end
|
||||||
|
|
||||||
formatted_response
|
formatted_response
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_show_source?(response)
|
def citation_source_number(tool_context, document_id)
|
||||||
return false if response.documentable.blank?
|
source_map = tool_context.state[:captain_v2_citation_source_map] ||= {}
|
||||||
return false unless response.documentable.try(:external_link)
|
existing_number = source_map.find { |_number, id| id.to_i == document_id }&.first
|
||||||
|
return existing_number if existing_number.present?
|
||||||
|
|
||||||
# Don't show source if it's a PDF placeholder
|
source_number = (source_map.keys.map(&:to_i).max || 0) + 1
|
||||||
external_link = response.documentable.external_link
|
source_map[source_number.to_s] = document_id
|
||||||
!external_link.start_with?('PDF:')
|
source_number.to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -66,12 +66,13 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
|||||||
expect(result).to include('Answer: Go to settings and update email')
|
expect(result).to include('Answer: Go to settings and update email')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes source link when document has external_link' do
|
it 'includes a source marker instead of the document link' do
|
||||||
document.update!(external_link: 'https://help.example.com/password')
|
document.update!(external_link: 'https://help.example.com/password')
|
||||||
|
|
||||||
result = tool.perform(tool_context, query: 'password')
|
result = tool.perform(tool_context, query: 'password')
|
||||||
|
|
||||||
expect(result).to include('Source: https://help.example.com/password')
|
expect(result).to include('Source marker: [[source:1]]')
|
||||||
|
expect(result).not_to include('https://help.example.com/password')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'logs tool usage for search' do
|
it 'logs tool usage for search' do
|
||||||
|
|||||||
Reference in New Issue
Block a user