Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub 7a76cee723 Merge branch 'feature/cw-7495-llm' into feature/cw-7495-api 2026-07-23 15:12:49 +05:30
Sivin VargheseandGitHub 4842f724ea Merge branch 'develop' into feature/cw-7495-llm 2026-07-23 15:12:34 +05:30
aakashb95 b11f2e4a0c fix(captain): revalidate FAQ matches after edits 2026-07-22 23:32:24 +05:30
aakashb95 d5a9747b81 test(captain): cover restricted FAQ suggestion sources 2026-07-22 23:30:35 +05:30
aakashb95 a8ef787878 fix(captain): allow FAQ approval across languages 2026-07-22 23:30:08 +05:30
aakashb95 d85ab03444 fix(captain): retain approved FAQ suggestion sources 2026-07-22 23:29:40 +05:30
aakashb95 b4d2fa5554 Merge remote-tracking branch 'origin/feature/cw-7495-llm' into cook/pr-14979-review 2026-07-22 23:29:08 +05:30
aakashb95 f1753f08b2 fix(captain): match approved FAQs across languages 2026-07-22 23:28:53 +05:30
aakashb95 fc82c7939e refactor(captain): clarify accessible suggestion scope 2026-07-22 23:20:41 +05:30
aakashb95 45e065eca8 docs(captain): note FAQ search indexing follow-up 2026-07-22 23:20:13 +05:30
aakashb95 1ee939a910 feat(captain): allow agents to review FAQ suggestions 2026-07-22 23:19:49 +05:30
Aakash BakhleandGitHub 837343b12d Merge branch 'feature/cw-7495-llm' into feature/cw-7495-api 2026-07-22 16:06:36 +05:30
aakashb95 9f658fbfaa fix(captain): route FAQ matching model 2026-07-22 13:41:21 +05:30
aakashb95 c183b2aa80 fix(captain): allow FAQ answers across agent messages 2026-07-22 13:36:19 +05:30
aakashb95 9a780e8716 fix(captain): serialize FAQ suggestion grouping 2026-07-22 13:14:48 +05:30
aakashb95 e48d612a55 fix(captain): preserve FAQ suggestion assistant 2026-07-22 13:11:44 +05:30
aakashb95 3662224396 fix(captain): retry failed FAQ comparisons 2026-07-22 13:10:20 +05:30
aakashb95 961c4a8951 test(captain): cover FAQ suggestion details 2026-07-22 11:45:35 +05:30
aakashb95 439ac80019 test(captain): cover FAQ review through API 2026-07-22 11:42:25 +05:30
aakashb95 84c41f03d6 Merge remote-tracking branch 'origin/feature/cw-7495-llm' into cook/faq-comparison-api 2026-07-22 10:39:37 +05:30
aakashb95 a17734676c fix(captain): use mini model for FAQ matching 2026-07-22 10:37:25 +05:30
Aakash BakhleandGitHub e0aa099487 Merge branch 'feature/cw-7495-llm' into feature/cw-7495-api 2026-07-21 18:17:44 +05:30
Aakash BakhleandGitHub 398b84d5de Merge branch 'develop' into feature/cw-7495-llm 2026-07-21 18:17:36 +05:30
aakashb95 cf417a2f3e refactor(captain): share FAQ suggestion visibility scope 2026-07-21 18:04:39 +05:30
Sony MathewandGitHub 11143672a6 Merge branch 'develop' into feature/cw-7495-llm 2026-07-21 17:01:38 +05:30
Aakash BakhleandGitHub 412319462a Merge branch 'develop' into feature/cw-7495-llm 2026-07-20 22:36:29 +05:30
aakashb95 8600e9b170 fix(captain): refine FAQ suggestion generation 2026-07-17 15:24:50 +05:30
aakashb95 64d2751c06 fix(captain): scope FAQ suggestions to agent access 2026-07-17 13:11:33 +05:30
aakashb95 f6d97c56b4 fix(captain): harden FAQ suggestion review flow 2026-07-17 12:57:54 +05:30
Aakash BakhleandGitHub fc63c7bddf Merge branch 'feature/cw-7495-llm' into feature/cw-7495-api 2026-07-16 21:32:58 +05:30
Aakash BakhleandGitHub 916b510ecf Merge branch 'develop' into feature/cw-7495-llm 2026-07-16 17:47:12 +05:30
aakashb95 4a73de1581 fix(captain): normalize FAQ suggestion languages 2026-07-16 17:12:45 +05:30
aakashb95 d09ece0b85 docs(captain): explain exact FAQ search 2026-07-16 17:00:51 +05:30
aakashb95 68f60b0672 refactor(captain): extract FAQ conversation content 2026-07-16 15:34:52 +05:30
aakashb95 e1445e4c2d Merge remote-tracking branch 'origin/develop' into feature/cw-7495-llm 2026-07-16 15:32:27 +05:30
aakashb95 f0edabebb1 Merge remote-tracking branch 'origin/feature/cw-7495-llm' into codex/update-cw-7495-api 2026-07-14 23:40:24 +05:30
aakashb95 3a36f096d2 fix(captain): use exact search for FAQ grouping 2026-07-14 23:40:13 +05:30
aakashb95 30e50c00e8 Merge remote-tracking branch 'origin/feature/cw-7495-llm' into codex/update-cw-7495-api 2026-07-14 23:18:21 +05:30
aakashb95 2e84ebb4de fix(captain): cap FAQ source previews 2026-07-14 23:15:08 +05:30
aakashb95 cf1fdff04b fix(captain): filter FAQ signals with business context 2026-07-14 23:13:03 +05:30
Aakash BakhleandGitHub b95e75f4ca Merge branch 'develop' into feature/cw-7495-llm 2026-07-14 14:51:40 +05:30
aakashb95 44ad817fb3 fix(captain): guard FAQ suggestion review data 2026-07-14 14:10:26 +05:30
aakashb95 f98cfd2184 fix(captain): authorize FAQ suggestion review actions 2026-07-14 14:10:26 +05:30
aakashb95 7723f19816 feat(captain): add FAQ suggestion review API 2026-07-14 14:10:26 +05:30
aakashb95 69216db45d fix(captain): compare FAQ languages by base code 2026-07-14 14:10:17 +05:30
aakashb95 c272de5be9 feat(captain): group FAQ suggestions by language 2026-07-14 14:10:17 +05:30
aakashb95 f6f688acbb feat(captain): group conversation FAQ signals 2026-07-14 14:10:17 +05:30
aakashb95 61f5198b99 fix(captain): enforce FAQ observation account scope 2026-07-14 14:10:03 +05:30
Aakash BakhleandGitHub 1dd91cda92 Merge branch 'develop' into feature/cw-7495-models 2026-07-14 13:12:50 +05:30
aakashb95 c3635128ef fix(captain): avoid FAQ migration and callback conflicts 2026-07-13 18:44:24 +05:30
Aakash BakhleandGitHub cb5b4a539c Merge branch 'develop' into feature/cw-7495-models 2026-07-13 18:39:28 +05:30
aakashb95 dfb93ef58f fix(captain): scope FAQ observations to account 2026-07-13 18:32:19 +05:30
aakashb95 d1ab6edaf1 feat(captain): store FAQ suggestion language 2026-07-11 21:39:58 +05:30
aakashb95 07dc14b3fa feat(captain): add FAQ suggestion data model 2026-07-10 14:02:19 +05:30
28 changed files with 1172 additions and 210 deletions
+14
View File
@@ -143,6 +143,20 @@ features:
gemini-3-pro,
]
default: gpt-5.2
conversation_faq_matching:
models:
[
gpt-4.1-mini,
gpt-5-mini,
gpt-4.1,
gpt-5.1,
gpt-5.2,
claude-haiku-4.5,
claude-sonnet-4.5,
gemini-3-flash,
gemini-3-pro,
]
default: gpt-4.1-mini
pdf_faq_generation:
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
default: gpt-4.1-mini
+1
View File
@@ -626,6 +626,7 @@ en:
label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation'
conversation_faq_generation: 'Conversation FAQ generation'
conversation_faq_matching: 'Conversation FAQ matching'
help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation'
+4
View File
@@ -79,6 +79,10 @@ Rails.application.routes.draw do
end
resources :agent_sessions, only: [:show]
resources :assistant_responses
resources :faq_suggestions, only: [:index, :show, :update] do
post :approve, on: :member
post :dismiss, on: :member
end
resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index, :create] do
@@ -0,0 +1,80 @@
class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::FaqSuggestion) }
before_action :set_accessible_suggestions
before_action :set_suggestion, except: [:index]
RESULTS_PER_PAGE = 25
SOURCE_PREVIEW_LIMIT = 50
def index
@current_page = permitted_params[:page] || 1
filtered_query = apply_filters(@suggestions)
@suggestions_count = filtered_query.count
@suggestions = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show
@observations = @suggestion.observations
.where(conversation_id: accessible_conversations.select(:id))
.includes(:conversation)
.order(created_at: :desc)
.limit(SOURCE_PREVIEW_LIMIT)
end
def update
@suggestion.with_lock do
raise ActiveRecord::RecordNotFound unless @suggestion.open?
@suggestion.update!(suggestion_params)
end
end
def approve
attributes = params[:faq_suggestion].present? ? suggestion_params : {}
@response = Captain::FaqSuggestionApprovalService.new(@suggestion, attributes).perform
end
def dismiss
@suggestion.with_lock do
raise ActiveRecord::RecordNotFound unless @suggestion.open?
@suggestion.dismissed!
end
end
private
def apply_filters(base_query)
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
if permitted_params[:search].present?
# TODO: Move FAQ suggestion search to Elasticsearch when the records are indexed there.
search_term = "%#{permitted_params[:search]}%"
base_query = base_query.where('question ILIKE :search OR answer ILIKE :search', search: search_term)
end
base_query
end
def set_accessible_suggestions
@suggestions = Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform.includes(:assistant).ordered
end
def set_suggestion
@suggestion = @suggestions.find(permitted_params[:id])
end
def accessible_conversations
Conversations::PermissionFilterService.new(Current.account.conversations, Current.user, Current.account).perform
end
def permitted_params
params.permit(:id, :assistant_id, :page, :status, :search)
end
def suggestion_params
params.require(:faq_suggestion).permit(:question, :answer)
end
end
@@ -0,0 +1,26 @@
class Captain::FaqSuggestionFinder
def initialize(current_user, current_account)
@current_user = current_user
@current_account = current_account
end
def perform
suggestions = @current_account.captain_faq_suggestions
return suggestions if account_user&.administrator?
accessible_suggestion_ids = Captain::FaqObservation
.where(conversation_id: accessible_conversations.select(:id))
.select(:faq_suggestion_id)
suggestions.where(id: accessible_suggestion_ids)
end
private
def accessible_conversations
Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform
end
def account_user
@account_user ||= @current_account.account_users.find_by(user_id: @current_user.id)
end
end
@@ -0,0 +1,30 @@
class Captain::Llm::ConversationFaqJob < MutexApplicationJob
queue_as :low
LOCK_TIMEOUT = 10.minutes
retry_on_lock_conflict wait: 30.seconds, attempts: 30
def perform(conversation, assistant)
inbox = conversation.inbox
return unless conversation.resolved?
return unless inbox.captain_active?
return if assistant.config['feature_faq'].blank?
with_lock(lock_key(assistant, conversation), LOCK_TIMEOUT) do
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_suggestions
end
end
private
def lock_key(assistant, conversation)
format(
::Redis::Alfred::CAPTAIN_CONVERSATION_FAQ_MUTEX,
assistant_id: assistant.id,
language: Captain::Llm::ConversationFaqService.language_for(conversation)
)
end
end
+1 -1
View File
@@ -8,6 +8,6 @@ class CaptainListener < BaseListener
return unless conversation.inbox.captain_active?
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
Captain::Llm::ConversationFaqJob.perform_later(conversation, assistant) if assistant.config['feature_faq'].present?
end
end
@@ -35,6 +35,14 @@ class Captain::AssistantPolicy < ApplicationPolicy
@account_user.administrator?
end
def approve?
update?
end
def dismiss?
update?
end
def destroy?
@account_user.administrator?
end
@@ -0,0 +1,21 @@
class Captain::FaqSuggestionPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def update?
true
end
def approve?
true
end
def dismiss?
true
end
end
@@ -0,0 +1,26 @@
class Captain::FaqSuggestionApprovalService
def initialize(suggestion, attributes = {})
@suggestion = suggestion
@attributes = attributes
end
def perform
suggestion.with_lock do
raise ActiveRecord::RecordNotFound unless suggestion.open?
suggestion.update!(attributes) if attributes.present?
response = suggestion.assistant.responses.create!(
question: suggestion.question,
answer: suggestion.answer,
status: :approved
)
suggestion.approved!
response
end
end
private
attr_reader :suggestion, :attributes
end
@@ -0,0 +1,66 @@
class Captain::Llm::ConversationFaqContentService
def initialize(assistant, conversation)
@assistant = assistant
@conversation = conversation
end
def generate
[
'Business Context:',
JSON.pretty_generate(business_context),
"Conversation ID: ##{conversation.display_id}",
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
conversation_messages
].join("\n")
end
private
attr_reader :assistant, :conversation
def conversation_messages
messages = conversation
.messages
.where(message_type: %i[incoming outgoing], private: false)
.order(created_at: :asc)
return "No messages in this conversation\n" if messages.empty?
messages.filter_map { |message| format_message(message) }.join
end
def format_message(message)
return unless source_message?(message)
message_content = message.content_for_llm
return if message_content.blank?
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
"#{sender}: #{message_content}\n"
end
def source_message?(message)
return true if message.incoming? && message.sender_type == 'Contact'
human_support_reply?(message)
end
def human_support_reply?(message)
return false unless message.outgoing?
return false if message.content_attributes['automation_rule_id'].present?
return false if message.additional_attributes['campaign_id'].present?
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
end
def business_context
{
product_name: assistant.config['product_name'],
assistant_description: assistant.description,
instructions: assistant.config['instructions'],
response_guidelines: assistant.response_guidelines,
guardrails: assistant.guardrails
}.compact
end
end
@@ -0,0 +1,75 @@
class Captain::Llm::ConversationFaqPromptsService
class << self
def generator(language = 'english')
<<~PROMPT
You create high-quality FAQ candidates from resolved support conversations.
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
## Source rules
- The input starts with trusted business context. Use it to reject conversations about other businesses or topics, but never use it as the source of an FAQ answer.
- The conversation history contains only customer messages and human support agent messages.
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
- For each FAQ, identify the human support agent message or messages that together provide a complete public answer to the same question. Combine facts only across related agent messages; never combine separate questions or unrelated topics. If those messages do not provide a complete public answer, remove that FAQ.
## Decision gate
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
1. The answer is fully stated by a human support agent, not by the customer.
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
Do not rescue a rejected conversation by rewriting it as a generic support question.
## Return no FAQ for
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
- Questions already answered only by asking the customer for more information.
## FAQ quality rules
- Prefer returning no FAQ over a weak or narrow FAQ.
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
- Do not create duplicate or overlapping FAQs in the same response.
- Questions must be general enough for a help center, not personalized to the current customer.
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
- Answers must be complete, self-contained, and supported by the human agent's messages.
## Examples
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
Generate the FAQs only in the #{language}, use no other language.
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
Return only valid JSON in this exact structure:
```json
{ "faqs": [ { "question": "", "answer": "" } ] }
```
PROMPT
end
def same_faq
<<~PROMPT
Decide whether the new FAQ and existing FAQ are the same.
Return `same_faq` as true only when both questions ask the same thing and both answers give the same guidance.
Wording, grammar, level of detail, and examples may differ. Return false when either FAQ adds, removes, contradicts, or changes a condition,
policy, procedure, audience, product, plan, time frame, or outcome. Related FAQs are not the same FAQ. When uncertain, return false.
Return only valid JSON in this exact structure:
```json
{ "same_faq": true }
```
PROMPT
end
end
end
@@ -1,167 +1,208 @@
class Captain::Llm::ConversationFaqService < Llm::BaseAiService
include Integrations::LlmInstrumentation
class SuggestionChangedError < StandardError; end
DISTANCE_THRESHOLD = 0.3
MATCH_LIMIT = 5
LLM_FEATURE = 'conversation_faq_generation'.freeze
def self.language_for(conversation)
language = conversation.language.presence || conversation.account.locale.presence || I18n.default_locale.to_s
normalize_language(language)
end
def self.normalize_language(language)
language.to_s.tr('-', '_').split('_').first.downcase
end
private_class_method :normalize_language
def initialize(assistant, conversation)
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
@assistant = assistant
@conversation = conversation
@content = conversation_faq_content
@content = Captain::Llm::ConversationFaqContentService.new(assistant, conversation).generate
@embedding_service = Captain::Llm::EmbeddingService.new(account_id: conversation.account_id)
end
# Generates and deduplicates FAQs from conversation content
# Skips processing if there was no human interaction
def generate_and_deduplicate
def generate_suggestions
return [] if no_human_interaction?
new_faqs = generate
return [] if new_faqs.empty?
duplicate_faqs, unique_faqs = find_and_separate_duplicates(new_faqs)
save_new_faqs(unique_faqs)
log_duplicate_faqs(duplicate_faqs) if Rails.env.development?
generate.map { |faq| route_candidate(faq) }
end
private
attr_reader :content, :conversation, :assistant
def conversation_faq_content
[
"Conversation ID: ##{conversation.display_id}",
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
conversation_faq_messages
].join("\n")
end
def conversation_faq_messages
messages = conversation
.messages
.where(message_type: %i[incoming outgoing], private: false)
.order(created_at: :asc)
return "No messages in this conversation\n" if messages.empty?
messages.filter_map { |message| format_conversation_faq_message(message) }.join
end
def format_conversation_faq_message(message)
return unless faq_source_message?(message)
content = message.content_for_llm
return if content.blank?
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
"#{sender}: #{content}\n"
end
def faq_source_message?(message)
return true if message.incoming? && message.sender_type == 'Contact'
human_support_reply?(message)
end
def human_support_reply?(message)
return false unless message.outgoing?
return false if message.content_attributes['automation_rule_id'].present?
return false if message.additional_attributes['campaign_id'].present?
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
end
attr_reader :content, :conversation, :assistant, :embedding_service
def no_human_interaction?
conversation.first_reply_created_at.nil?
end
def find_and_separate_duplicates(faqs)
duplicate_faqs = []
unique_faqs = []
def route_candidate(faq)
embedding = embedding_service.get_embedding(candidate_text(faq))
faqs.each do |faq|
combined_text = "#{faq['question']}: #{faq['answer']}"
embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
similar_faqs = find_similar_faqs(embedding)
return discard_observation(faq) if matching_record(approved_faqs, faq, embedding)
return discard_observation(faq) if matching_record(dismissed_suggestions_for_language, faq, embedding)
if similar_faqs.any?
duplicate_faqs << { faq: faq, similar_faqs: similar_faqs }
else
unique_faqs << faq
end
suggestion = matching_record(open_suggestions_for_language, faq, embedding)
matched_content = suggestion&.slice('question', 'answer')
suggestion ||= assistant.faq_suggestions.create!(
question: faq.fetch('question'),
answer: faq.fetch('answer'),
embedding: embedding,
language: faq_language
)
attach_observation(suggestion, faq, matched_content)
end
def matching_record(relation, faq, embedding)
likely_matches(relation, embedding).find { |record| same_faq?(faq, record) }
end
def likely_matches(relation, embedding)
return [] unless relation.exists?
ApplicationRecord.transaction do
# Force an exact search because IVFFlat can miss matches after relation filters.
# SET LOCAL keeps the planner change scoped to this transaction.
ApplicationRecord.connection.execute('SET LOCAL enable_indexscan = off')
relation
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
.limit(MATCH_LIMIT)
.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
end
end
def same_faq?(candidate, existing_record)
comparison = {
candidate: candidate.slice('question', 'answer'),
existing: { question: existing_record.question, answer: existing_record.answer }
}
prompt = Captain::Llm::ConversationFaqPromptsService.same_faq
faq_match_model = Llm::FeatureRouter.resolve(feature: 'conversation_faq_matching', account: conversation.account)[:model]
response = instrument_llm_call(match_instrumentation_params(prompt, comparison, faq_match_model)) do
chat(model: faq_match_model)
.with_params(response_format: { type: 'json_object' })
.with_instructions(prompt)
.ask(comparison.to_json)
end
[duplicate_faqs, unique_faqs]
same_faq = JSON.parse(sanitize_json_response(response.content)).fetch('same_faq')
raise TypeError, 'same_faq must be a boolean' unless [true, false].include?(same_faq)
same_faq
rescue JSON::ParserError, KeyError, TypeError, RubyLLM::Error => e
Rails.logger.error "FAQ match failed: #{e.message}"
raise
end
def find_similar_faqs(embedding)
similar_faqs = assistant
.responses
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
Rails.logger.debug(similar_faqs.map { |faq| [faq.question, faq.neighbor_distance] })
similar_faqs.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
end
def attach_observation(suggestion, faq, matched_content)
suggestion.with_lock do
next unless suggestion.open?
raise SuggestionChangedError if matched_content && suggestion.slice('question', 'answer') != matched_content
def save_new_faqs(faqs)
faqs.map do |faq|
assistant.responses.create!(
question: faq['question'],
answer: faq['answer'],
status: 'pending',
documentable: conversation
existing_observation = suggestion.observations.find_by(conversation: conversation)
next existing_observation if existing_observation
observation = suggestion.observations.create!(
conversation: conversation,
generated_question: faq.fetch('question'),
generated_answer: faq.fetch('answer'),
language: faq_language,
status: :attached
)
suggestion.update!(source_count: suggestion.source_count + 1)
observation
end
end
def log_duplicate_faqs(duplicate_faqs)
return if duplicate_faqs.empty?
def discard_observation(faq)
Captain::FaqObservation.find_or_create_by!(
conversation: conversation,
generated_question: faq.fetch('question'),
generated_answer: faq.fetch('answer'),
language: faq_language,
status: :discarded
)
end
Rails.logger.info "Found #{duplicate_faqs.length} duplicate FAQs:"
duplicate_faqs.each do |duplicate|
Rails.logger.info(
"Q: #{duplicate[:faq]['question']}\n" \
"A: #{duplicate[:faq]['answer']}\n\n" \
"Similar existing FAQs: #{duplicate[:similar_faqs].map { |f| "Q: #{f.question} A: #{f.answer}" }.join(', ')}"
)
end
def open_suggestions_for_language
assistant.faq_suggestions.where(account_id: conversation.account_id).open.by_language(faq_language)
end
def dismissed_suggestions_for_language
assistant.faq_suggestions.where(account_id: conversation.account_id).dismissed.by_language(faq_language)
end
def approved_faqs
assistant.responses.approved
end
def candidate_text(faq)
"#{faq.fetch('question')}: #{faq.fetch('answer')}"
end
def generate
response = instrument_llm_call(instrumentation_params) do
response = instrument_llm_call(generation_instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
.ask(content)
end
parse_response(response.content)
parse_generation_response(response.content)
rescue RubyLLM::Error => e
Rails.logger.error "LLM API Error: #{e.message}"
[]
end
def instrumentation_params
def generation_instrumentation_params
{
span_name: 'llm.captain.conversation_faq',
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.display_id,
model: model,
temperature: temperature,
account_id: conversation.account_id,
conversation_id: conversation.display_id,
feature_name: 'conversation_faq',
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
{ role: 'user', content: content }
],
metadata: { assistant_id: @assistant.id }
metadata: { assistant_id: assistant.id, language: faq_language }
}
end
def match_instrumentation_params(prompt, comparison, faq_match_model)
{
span_name: 'llm.captain.faq_match',
model: faq_match_model,
temperature: temperature,
account_id: conversation.account_id,
conversation_id: conversation.display_id,
feature_name: 'conversation_faq_match',
messages: [
{ role: 'system', content: prompt },
{ role: 'user', content: comparison.to_json }
],
metadata: { assistant_id: assistant.id, language: faq_language }
}
end
def system_prompt
account_language = @conversation.account.locale_english_name
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
Captain::Llm::ConversationFaqPromptsService.generator(language_name(faq_language))
end
def parse_response(response)
def faq_language
@faq_language ||= self.class.language_for(conversation)
end
def language_name(language)
ISO_639.find(language)&.english_name&.downcase || 'english'
end
def parse_generation_response(response)
return [] if response.nil?
JSON.parse(sanitize_json_response(response)).fetch('faqs', [])
@@ -51,62 +51,6 @@ class Captain::Llm::SystemPromptsService
PROMPT
end
def conversation_faq_generator(language = 'english')
<<~SYSTEM_PROMPT_MESSAGE
You create high-quality FAQ candidates from resolved support conversations.
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
## Source rules
- The conversation history contains only customer messages and human support agent messages.
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
- For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
## Decision gate
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
1. The answer is fully stated by a human support agent, not by the customer.
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
Do not rescue a rejected conversation by rewriting it as a generic support question.
## Return no FAQ for
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
- Questions already answered only by asking the customer for more information.
## FAQ quality rules
- Prefer returning no FAQ over a weak or narrow FAQ.
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
- Do not create duplicate or overlapping FAQs in the same response.
- Questions must be general enough for a help center, not personalized to the current customer.
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
- Answers must be complete, self-contained, and supported by the human agent's messages.
## Examples
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
Generate the FAQs only in the #{language}, use no other language.
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
Return only valid JSON in this exact structure:
```json
{ "faqs": [ { "question": "", "answer": "" } ] }
```
SYSTEM_PROMPT_MESSAGE
end
def notes_generator(language = 'english')
<<~SYSTEM_PROMPT_MESSAGE
You are a note taker looking to convert the conversation with a contact into actionable notes for the CRM.
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: @response
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
@@ -0,0 +1,10 @@
json.payload do
json.array! @suggestions do |suggestion|
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: suggestion
end
end
json.meta do
json.total_count @suggestions_count
json.page @current_page
end
@@ -0,0 +1,6 @@
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
json.observations do
json.array! @observations do |observation|
json.partial! 'api/v1/models/captain/faq_observation', formats: [:json], resource: observation
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
@@ -0,0 +1,10 @@
json.id resource.id
json.generated_question resource.generated_question
json.generated_answer resource.generated_answer
json.language resource.language
json.status resource.status
json.created_at resource.created_at.to_i
json.conversation do
json.id resource.conversation.id
json.display_id resource.conversation.display_id
end
@@ -0,0 +1,12 @@
json.id resource.id
json.account_id resource.account_id
json.question resource.question
json.answer resource.answer
json.language resource.language
json.source_count resource.source_count
json.status resource.status
json.created_at resource.created_at.to_i
json.updated_at resource.updated_at.to_i
json.assistant do
json.partial! 'api/v1/models/captain/assistant', formats: [:json], resource: resource.assistant
end
+1
View File
@@ -89,6 +89,7 @@ module Redis::RedisKeys
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<inbox_id>s::%<sender_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_id>s'.freeze
CAPTAIN_CONVERSATION_FAQ_MUTEX = 'CAPTAIN_CONVERSATION_FAQ_LOCK::%<assistant_id>s::%<language>s'.freeze
## Auto Assignment Keys
# Track conversation assignments to agents for rate limiting
@@ -0,0 +1,216 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do
let(:account) { create(:account, locale: 'en') }
let(:assistant) { create(:captain_assistant, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:suggestion) do
assistant.faq_suggestions.create!(
question: 'How do I enable the feature?',
answer: 'Turn it on in settings.',
source_count: 1
)
end
before do
suggestion.observations.create!(
conversation: conversation,
generated_question: suggestion.question,
generated_answer: suggestion.answer,
language: suggestion.language
)
end
describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions' do
it 'returns suggestions and their count to an administrator' do
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions",
params: { assistant_id: assistant.id },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to contain_exactly(
include('id' => suggestion.id, 'question' => suggestion.question, 'status' => 'open')
)
expect(response.parsed_body['meta']).to include('total_count' => 1)
end
it 'returns only suggestions backed by conversations the agent can access' do
create(:inbox_member, user: agent, inbox: inbox)
hidden_inbox = create(:inbox, account: account)
hidden_conversation = create(:conversation, account: account, inbox: hidden_inbox)
hidden_suggestion = assistant.faq_suggestions.create!(question: 'Hidden question', answer: 'Hidden answer')
hidden_suggestion.observations.create!(
conversation: hidden_conversation,
generated_question: hidden_suggestion.question,
generated_answer: hidden_suggestion.answer,
language: hidden_suggestion.language
)
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions",
params: { assistant_id: assistant.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload'].pluck('id')).to contain_exactly(suggestion.id)
expect(response.parsed_body['meta']).to include('total_count' => 1)
end
end
describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do
it 'returns the suggestion with its source conversation' do
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to include('id' => suggestion.id, 'question' => suggestion.question)
expect(response.parsed_body['observations']).to contain_exactly(
include('conversation' => include('id' => conversation.id, 'display_id' => conversation.display_id))
)
end
it 'returns only source conversations the agent can access' do
create(:inbox_member, user: agent, inbox: inbox)
hidden_conversation = create(:conversation, account: account, inbox: create(:inbox, account: account))
suggestion.observations.create!(
conversation: hidden_conversation,
generated_question: suggestion.question,
generated_answer: suggestion.answer,
language: suggestion.language
)
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['observations'].pluck('conversation').pluck('id')).to contain_exactly(conversation.id)
end
end
describe 'PATCH /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do
it 'lets an administrator edit an open suggestion' do
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
params: { faq_suggestion: { question: 'Updated question' } },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['question']).to eq('Updated question')
expect(suggestion.reload.question).to eq('Updated question')
end
it 'lets an agent edit an accessible suggestion' do
create(:inbox_member, user: agent, inbox: inbox)
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
params: { faq_suggestion: { question: 'Updated question' } },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(suggestion.reload.question).to eq('Updated question')
end
it 'does not let an agent edit an inaccessible suggestion' do
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
params: { faq_suggestion: { question: 'Updated question' } },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
expect(suggestion.reload.question).to eq('How do I enable the feature?')
end
end
describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/approve' do
it 'lets an administrator approve an edited suggestion as an FAQ' do
expect do
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
params: { faq_suggestion: { answer: 'Enable it in account settings.' } },
headers: admin.create_new_auth_token,
as: :json
end.to change(assistant.responses.approved, :count).by(1)
expect(response).to have_http_status(:success)
expect(response.parsed_body['answer']).to eq('Enable it in account settings.')
expect(suggestion.reload).to be_approved
expect(suggestion.observations.pluck(:conversation_id)).to contain_exactly(conversation.id)
end
it 'lets an agent approve an accessible suggestion' do
create(:inbox_member, user: agent, inbox: inbox)
expect do
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
headers: agent.create_new_auth_token,
as: :json
end.to change(assistant.responses.approved, :count).by(1)
expect(response).to have_http_status(:success)
expect(suggestion.reload).to be_approved
end
it 'approves a suggestion written in a language other than the account locale' do
suggestion.update!(language: 'pt')
expect do
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
headers: admin.create_new_auth_token,
as: :json
end.to change(assistant.responses.approved, :count).by(1)
expect(response).to have_http_status(:success)
expect(suggestion.reload).to be_approved
end
it 'does not let an agent approve an inaccessible suggestion' do
expect do
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
headers: agent.create_new_auth_token,
as: :json
end.not_to change(assistant.responses, :count)
expect(response).to have_http_status(:not_found)
expect(suggestion.reload).to be_open
end
end
describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/dismiss' do
it 'lets an administrator dismiss an open suggestion without creating an FAQ' do
expect do
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
headers: admin.create_new_auth_token,
as: :json
end.not_to change(assistant.responses, :count)
expect(response).to have_http_status(:success)
expect(suggestion.reload).to be_dismissed
end
it 'lets an agent dismiss an accessible suggestion' do
create(:inbox_member, user: agent, inbox: inbox)
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(suggestion.reload).to be_dismissed
end
it 'does not let an agent dismiss an inaccessible suggestion' do
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
expect(suggestion.reload).to be_open
end
end
end
@@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ConversationFaqJob, type: :job do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assistant) { create(:captain_assistant, account: account, config: { feature_faq: true }) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, first_reply_created_at: Time.zone.now) }
let(:faq_service) { instance_double(Captain::Llm::ConversationFaqService, generate_suggestions: []) }
let(:lock_manager) { instance_double(Redis::LockManager, lock: true, unlock: true) }
let(:lock_key) { "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::en" }
before do
create(:captain_inbox, inbox: inbox, captain_assistant: assistant)
conversation.update!(status: :resolved)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(Captain::Llm::ConversationFaqService).to receive(:new).and_return(faq_service)
end
describe '#perform' do
it 'uses the assistant captured when the job was enqueued' do
replacement_assistant = create(:captain_assistant, account: account, config: { feature_faq: true })
inbox.captain_inbox.update!(captain_assistant: replacement_assistant)
expect(inbox.reload.captain_assistant).to eq(replacement_assistant)
expect(Captain::Llm::ConversationFaqService).to receive(:new)
.with(assistant, conversation)
.and_return(faq_service)
expect(faq_service).to receive(:generate_suggestions)
described_class.perform_now(conversation, assistant)
end
it 'locks FAQ grouping for the assistant and normalized language' do
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
expected_key = "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::pt"
expect(lock_manager).to receive(:lock).with(expected_key, described_class::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(expected_key)
described_class.perform_now(conversation, assistant)
end
context 'when another job holds the grouping lock' do
before do
allow(lock_manager).to receive(:lock).with(lock_key, described_class::LOCK_TIMEOUT).and_return(false)
end
it 'does not generate suggestions concurrently' do
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
expect do
described_class.new.perform(conversation, assistant)
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
end
end
end
end
@@ -29,7 +29,7 @@ describe CaptainListener do
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
expect(Captain::Llm::ConversationFaqJob).not_to receive(:perform_later)
listener.conversation_resolved(event)
end
@@ -42,11 +42,8 @@ describe CaptainListener do
assistant.save!
end
it 'generates and deduplicates FAQs' do
expect(Captain::Llm::ConversationFaqService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
it 'enqueues FAQ suggestion generation' do
expect(Captain::Llm::ConversationFaqJob).to receive(:perform_later).with(conversation, assistant)
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
listener.conversation_resolved(event)
@@ -0,0 +1,13 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ConversationFaqPromptsService do
describe '.generator' do
it 'allows a complete FAQ answer to use several related agent messages' do
prompt = described_class.generator
expect(prompt).to include('message or messages that together provide a complete public answer')
expect(prompt).to include('Combine facts only across related agent messages')
expect(prompt).not_to include('no single human agent message')
end
end
end
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe Captain::Llm::ConversationFaqService do
let(:captain_assistant) { create(:captain_assistant) }
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
let(:conversation) { create(:conversation, account: captain_assistant.account, first_reply_created_at: Time.zone.now) }
let(:service) { described_class.new(captain_assistant, conversation) }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
@@ -15,6 +15,8 @@ RSpec.describe Captain::Llm::ConversationFaqService do
let(:mock_response) do
instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
end
let(:embedding_one) { [1.0] + Array.new(1535, 0.0) }
let(:embedding_two) { [0.0, 1.0] + Array.new(1534, 0.0) }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
@@ -26,11 +28,10 @@ RSpec.describe Captain::Llm::ConversationFaqService do
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#generate_and_deduplicate' do
describe '#generate_suggestions' do
context 'when successful' do
before do
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
end
it 'uses the conversation FAQ generation feature model' do
@@ -38,7 +39,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
model: Llm::Models.default_model_for('conversation_faq_generation')
).and_return(mock_chat)
described_class.new(captain_assistant, conversation).generate_and_deduplicate
described_class.new(captain_assistant, conversation).generate_suggestions
end
it 'uses the conversation FAQ default ahead of the legacy global installation model' do
@@ -48,7 +49,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
model: Llm::Models.default_model_for('conversation_faq_generation')
).and_return(mock_chat)
described_class.new(captain_assistant, conversation).generate_and_deduplicate
described_class.new(captain_assistant, conversation).generate_suggestions
end
it 'keeps account conversation FAQ model overrides ahead of the feature default' do
@@ -57,7 +58,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
described_class.new(captain_assistant, conversation).generate_and_deduplicate
described_class.new(captain_assistant, conversation).generate_suggestions
end
it 'resolves the feature model from the conversation account' do
@@ -66,7 +67,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
account: conversation.account
).and_call_original
described_class.new(captain_assistant, conversation).generate_and_deduplicate
described_class.new(captain_assistant, conversation).generate_suggestions
end
it 'sends only customer and human support agent messages to the LLM' do
@@ -84,7 +85,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
message_type: :activity, content: 'Activity message')
service.generate_and_deduplicate
service.generate_suggestions
expected_content = satisfy do |content|
content.include?('User: Customer question') &&
@@ -104,7 +105,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
content_attributes: { external_echo: true })
service.generate_and_deduplicate
service.generate_suggestions
expected_content = satisfy do |content|
content.include?('User: Customer asks in a native channel') &&
@@ -133,22 +134,27 @@ RSpec.describe Captain::Llm::ConversationFaqService do
block.call
end
service.generate_and_deduplicate
service.generate_suggestions
end
it 'creates new FAQs for valid conversation content' do
it 'creates suggestions instead of trusted FAQs for valid conversation content' do
expect do
service.generate_and_deduplicate
end.to change(captain_assistant.responses, :count).by(2)
service.generate_suggestions
end.to change(captain_assistant.faq_suggestions, :count).by(2)
expect(Captain::FaqObservation.count).to eq(2)
expect(captain_assistant.responses.count).to be_zero
end
it 'saves FAQs with pending status linked to conversation' do
service.generate_and_deduplicate
it 'saves open suggestions with one attached source each' do
service.generate_suggestions
expect(
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
captain_assistant.faq_suggestions.pluck(:question, :answer, :status, :source_count, :language)
).to contain_exactly(
['What is the purpose?', 'To help users.', 'pending', conversation.id],
['How does it work?', 'Through AI.', 'pending', conversation.id]
['What is the purpose?', 'To help users.', 'open', 1, 'en'],
['How does it work?', 'Through AI.', 'open', 1, 'en']
)
expect(Captain::FaqObservation.attached.pluck(:conversation_id, :language)).to contain_exactly(
[conversation.id, 'en'], [conversation.id, 'en']
)
end
end
@@ -157,37 +163,309 @@ RSpec.describe Captain::Llm::ConversationFaqService do
let(:conversation) { create(:conversation) }
it 'returns an empty array without generating FAQs' do
expect(service.generate_and_deduplicate).to eq([])
expect(service.generate_suggestions).to eq([])
end
it 'does not call the LLM API' do
expect(RubyLLM).not_to receive(:chat)
service.generate_and_deduplicate
service.generate_suggestions
end
end
context 'when finding duplicates' do
let(:existing_response) do
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
question: 'Similar question', answer: 'Similar answer', embedding: embedding_one)
end
let(:similar_neighbor) do
OpenStruct.new(
id: 1,
question: existing_response.question,
answer: existing_response.answer,
neighbor_distance: 0.1
)
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
before do
existing_response
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
allow(mock_chat).to receive(:ask) do |input|
input.start_with?('{') ? match_response : mock_response
end
end
it 'discards candidates the LLM confirms are covered by an approved FAQ' do
expect do
service.generate_suggestions
end.to change(Captain::FaqObservation.discarded, :count).by(2)
expect(captain_assistant.faq_suggestions.count).to be_zero
end
it 'uses the conversation FAQ matching feature model' do
expect(RubyLLM).to receive(:chat).with(
model: Llm::Models.default_model_for('conversation_faq_matching')
).at_least(:once).and_return(mock_chat)
service.generate_suggestions
end
it 'uses the account model override for conversation FAQ matching' do
conversation.account.update!(captain_models: { 'conversation_faq_matching' => 'gpt-5-mini' })
expect(RubyLLM).to receive(:chat).with(model: 'gpt-5-mini').at_least(:once).and_return(mock_chat)
service.generate_suggestions
end
it 'resolves the matching feature model from the conversation account' do
allow(Llm::FeatureRouter).to receive(:resolve).and_call_original
expect(Llm::FeatureRouter).to receive(:resolve).with(
feature: 'conversation_faq_matching',
account: conversation.account
).and_call_original
service.generate_suggestions
end
end
context 'when FAQ comparison cannot be completed' do
let(:existing_response) do
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
question: 'Similar question', answer: 'Similar answer', embedding: embedding_one)
end
let(:comparison_response) { instance_double(RubyLLM::Message, content: comparison_response_content) }
let(:comparison_response_content) { 'invalid json' }
before do
existing_response
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
allow(mock_chat).to receive(:ask) do |input|
input.start_with?('{') ? comparison_response : mock_response
end
allow(Rails.logger).to receive(:error)
end
it 'raises when the comparison response is malformed' do
expect do
service.generate_suggestions
end.to raise_error(JSON::ParserError)
expect(captain_assistant.faq_suggestions.count).to be_zero
end
context 'when the response omits the comparison result' do
let(:comparison_response_content) { {}.to_json }
it 'raises instead of treating the response as a non-match' do
expect do
service.generate_suggestions
end.to raise_error(KeyError)
expect(captain_assistant.faq_suggestions.count).to be_zero
end
end
context 'when the comparison result is not a boolean' do
let(:comparison_response_content) { { same_faq: 'false' }.to_json }
it 'raises instead of treating the response as a non-match' do
expect do
service.generate_suggestions
end.to raise_error(TypeError, 'same_faq must be a boolean')
expect(captain_assistant.faq_suggestions.count).to be_zero
end
end
context 'when the comparison provider fails' do
before do
allow(mock_chat).to receive(:ask) do |input|
raise RubyLLM::Error.new(nil, 'API Error') if input.start_with?('{')
mock_response
end
end
it 'raises instead of treating the failure as a non-match' do
expect do
service.generate_suggestions
end.to raise_error(RubyLLM::Error)
expect(captain_assistant.faq_suggestions.count).to be_zero
end
end
end
context 'when the classifier confirms a non-match' do
let(:sample_faqs) { [{ 'question' => 'How can I use the feature?', 'answer' => 'Enable it in settings.' }] }
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: false }.to_json) }
before do
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
embedding: embedding_one)
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
allow(mock_chat).to receive(:ask) do |input|
input.start_with?('{') ? match_response : mock_response
end
end
it 'creates a new suggestion' do
expect do
service.generate_suggestions
end.to change(captain_assistant.faq_suggestions, :count).by(1)
end
end
context 'when an open suggestion is the same FAQ' do
let(:sample_faqs) { [{ 'question' => 'How can I use the feature?', 'answer' => 'Enable it in settings.' }] }
let(:existing_suggestion) do
captain_assistant.faq_suggestions.create!(
question: 'How do I enable the feature?',
answer: 'Turn it on in settings.',
embedding: embedding_one
).tap do |suggestion|
suggestion.observations.create!(
conversation: create(:conversation, account: captain_assistant.account),
generated_question: suggestion.question,
generated_answer: suggestion.answer,
language: suggestion.language
)
suggestion.update!(source_count: suggestion.observations.attached.count)
end
end
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
before do
existing_suggestion
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
allow(mock_chat).to receive(:ask) do |input|
input.start_with?('{') ? match_response : mock_response
end
end
it 'attaches the observation and increments the source count' do
expect do
service.generate_suggestions
end.to change(existing_suggestion.observations, :count).by(1)
expect(existing_suggestion.reload.source_count).to eq(2)
expect(captain_assistant.faq_suggestions.count).to eq(1)
end
it 'does not attach the observation when the suggestion changes after classification' do
allow(mock_chat).to receive(:ask) do |input|
if input.start_with?('{')
existing_suggestion.update!(question: 'Edited after classification started')
match_response
else
mock_response
end
end
expect do
service.generate_suggestions
end.to raise_error(described_class::SuggestionChangedError)
expect(existing_suggestion.observations.count).to eq(1)
expect(existing_suggestion.reload.source_count).to eq(1)
end
end
context 'when a similar open suggestion uses another language' do
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
let!(:existing_suggestion) do
captain_assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
embedding: embedding_one, language: 'en', source_count: 1)
end
before do
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([similar_neighbor])
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
end
it 'filters out duplicate FAQs based on embedding similarity' do
it 'creates a separate suggestion in the conversation language' do
expect do
service.generate_and_deduplicate
end.not_to change(captain_assistant.responses, :count)
service.generate_suggestions
end.to change(captain_assistant.faq_suggestions, :count).by(1)
expect(captain_assistant.faq_suggestions.pluck(:language)).to contain_exactly('en', 'pt')
expect(existing_suggestion.reload.source_count).to eq(1)
end
end
context 'when an open suggestion uses another locale variant of the same language' do
let(:account) { create(:account, locale: 'pt_BR') }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:conversation) { create(:conversation, account: account, first_reply_created_at: Time.zone.now) }
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
let(:existing_suggestion) do
captain_assistant.faq_suggestions.create!(
question: 'Como habilito o recurso?',
answer: 'Ative nas configuracoes.',
embedding: embedding_one,
language: 'pt',
source_count: 1
)
end
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
before do
existing_suggestion
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
allow(mock_chat).to receive(:ask) do |input|
input.start_with?('{') ? match_response : mock_response
end
end
it 'attaches the observation to the existing base-language suggestion' do
expect do
service.generate_suggestions
end.to change(existing_suggestion.observations, :count).by(1)
expect(existing_suggestion.reload.source_count).to eq(2)
expect(captain_assistant.faq_suggestions.count).to eq(1)
expect(existing_suggestion.observations.last.language).to eq('pt')
end
end
context 'when a similar approved FAQ uses another language' do
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
before do
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
embedding: embedding_one)
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
allow(mock_chat).to receive(:ask) do |input|
input.start_with?('{') ? match_response : mock_response
end
end
it 'deduplicates against the approved FAQ' do
expect do
service.generate_suggestions
end.to change(Captain::FaqObservation.discarded, :count).by(1)
expect(captain_assistant.faq_suggestions.count).to be_zero
end
end
context 'when conversation and account locales share a base language' do
let(:account) { create(:account, locale: 'pt_BR') }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:conversation) do
create(:conversation, account: account, first_reply_created_at: Time.zone.now,
additional_attributes: { conversation_language: 'pt' })
end
let!(:existing_response) do
create(:captain_assistant_response, assistant: captain_assistant, account: account,
question: 'Como ativo o recurso?', answer: 'Ative nas configuracoes.',
embedding: embedding_one)
end
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
before do
existing_response
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
allow(mock_chat).to receive(:ask) do |input|
input.start_with?('{') ? match_response : mock_response
end
end
it 'deduplicates against approved FAQs in the same base language' do
expect do
service.generate_suggestions
end.to change(Captain::FaqObservation.discarded, :count).by(2)
expect(captain_assistant.faq_suggestions.count).to be_zero
end
end
@@ -199,7 +477,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
it 'returns empty array and logs the error' do
expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
expect(service.generate_and_deduplicate).to eq([])
expect(service.generate_suggestions).to eq([])
end
end
@@ -214,7 +492,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
it 'handles JSON parsing errors gracefully' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
expect(service.generate_and_deduplicate).to eq([])
expect(service.generate_suggestions).to eq([])
end
end
@@ -228,7 +506,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
end
it 'returns empty array' do
expect(service.generate_and_deduplicate).to eq([])
expect(service.generate_suggestions).to eq([])
end
end
end
@@ -236,22 +514,44 @@ RSpec.describe Captain::Llm::ConversationFaqService do
describe 'language handling' do
context 'when conversation has different language' do
let(:account) { create(:account, locale: 'fr') }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:conversation) do
create(:conversation, account: account, first_reply_created_at: Time.zone.now)
end
before do
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
end
it 'uses account language for system prompt' do
expect(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator)
expect(Captain::Llm::ConversationFaqPromptsService).to receive(:generator)
.with('french')
.at_least(:once)
.and_call_original
service.generate_and_deduplicate
service.generate_suggestions
end
end
context 'when conversation language differs from account language' do
let(:account) { create(:account, locale: 'en') }
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:conversation) do
create(:conversation, account: account, first_reply_created_at: Time.zone.now,
additional_attributes: { conversation_language: 'pt-BR' })
end
before do
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
end
it 'uses the conversation language for the system prompt' do
expect(Captain::Llm::ConversationFaqPromptsService).to receive(:generator)
.with('portuguese')
.at_least(:once)
.and_call_original
service.generate_suggestions
end
end
end
+2 -1
View File
@@ -26,9 +26,10 @@ RSpec.describe Llm::Models do
end
end
it 'routes document and conversation FAQ generation independently' do
it 'routes each FAQ operation independently' do
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini')
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
expect(described_class.default_model_for('conversation_faq_matching')).to eq('gpt-4.1-mini')
end
end