feat: Aprimora o Captain com tratamento de baixa confiança na busca, melhorias no handoff, processamento de PDF e logging detalhado.

This commit is contained in:
Rodrigo Borba 2026-01-05 01:37:59 -03:00
parent 6aa9b6ba2a
commit a229b0a0f1
8 changed files with 137 additions and 11 deletions

View File

@ -188,6 +188,7 @@ gem 'neighbor'
gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'pdf-reader'
gem 'iso-639'
gem 'ruby-openai'

View File

@ -104,6 +104,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
apply_handoff_side_effects
log_handoff_event
send_out_of_office_message_if_applicable
end
end
@ -121,6 +123,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def create_messages
response_text = inject_preferred_name(@response['response'])
response_text = prevent_fake_handoff(response_text)
validate_message_content!(response_text)
create_outgoing_message(response_text, agent_name: @response['agent_name'])
end
@ -155,6 +158,41 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
"#{preferred_name}, #{content}"
end
def prevent_fake_handoff(content)
return content if content.blank? || handoff_requested?
handoff_message = @assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
return content unless content.strip == handoff_message.to_s.strip
fallback_question
end
def fallback_question
'Pode me dizer sua duvida de forma mais especifica?'
end
def apply_handoff_side_effects
@conversation.add_labels(['handoff_requested'])
return if @conversation.assignee.present?
allowed_agent_ids = @conversation.inbox.member_ids_with_assignment_capacity
AutoAssignment::AgentAssignmentService.new(conversation: @conversation, allowed_agent_ids: allowed_agent_ids).perform
end
def log_handoff_event
Rails.logger.info(
"[CAPTAIN][handoff] request_id=#{extract_request_id} conversation_id=#{@conversation.id} assistant_id=#{@assistant.id} " \
"assignee_id=#{@conversation.assignee_id} team_id=#{@conversation.team_id}"
)
end
def extract_request_id
return RequestStore.store[:request_id] if defined?(RequestStore) && RequestStore.store[:request_id].present?
Thread.current[:request_id] || 'unknown'
end
def handle_error(error)
log_error(error)
process_action('handoff')

View File

@ -3,7 +3,13 @@ class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
def perform(record, content)
account_id = record.account_id
Rails.logger.info(
"[CAPTAIN][embedding] Generating embedding for record_id=#{record.id} account_id=#{account_id} type=#{record.class.name}"
)
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
record.update!(embedding: embedding)
Rails.logger.info(
"[CAPTAIN][embedding] Stored embedding for record_id=#{record.id} vector_size=#{embedding&.length}"
)
end
end

View File

@ -103,7 +103,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
end
def build_tools
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil, conversation: @conversation)]
end
def system_message

View File

@ -20,6 +20,7 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
attr_reader :document
def extract_text_from_pdf
ensure_pdf_reader!
content = ''
document.pdf_file.open do |file|
reader = PDF::Reader.new(file)
@ -27,10 +28,20 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
end
if content.present?
Rails.logger.info "PDF extracted content for document #{document.id} (chars=#{content.length})"
# Update content and ensure openai_file_id is nil to force standard FAQ generation
document.update!(content: content, openai_file_id: nil)
else
Rails.logger.warn "PDF extracted content is empty for document #{document.id}"
end
end
def ensure_pdf_reader!
return if defined?(PDF::Reader)
require 'pdf/reader'
rescue LoadError => e
Rails.logger.error "PDF Processing Error: missing pdf-reader gem (#{e.message})"
raise e
end
end

View File

@ -155,10 +155,9 @@ class Captain::Llm::SystemPromptsService
def assistant_response_generator(assistant_name, product_name, config = {})
assistant_citation_guidelines = if config['feature_citation']
<<~CITATION_TEXT
- Always include citations for any information provided, referencing the specific source (document only - skip if it was derived from a conversation).
- Citations must be numbered sequentially and formatted as `[[n](URL)]` (where n is the sequential number) at the end of each paragraph or sentence where external information is used.
- When you use information from documentation, include citations that reference the specific source (document only - skip if it was derived from a conversation).
- Citations must be numbered sequentially and formatted as `[[n](URL)]` at the end of the sentence that uses the source.
- If multiple sentences share the same source, reuse the same citation number.
- Do not generate citations if the information is derived from a conversation and not an external document.
CITATION_TEXT
else
''
@ -182,7 +181,8 @@ class Captain::Llm::SystemPromptsService
- Sometimes the user might just want to chat. Ask them relevant follow-up questions.
- Don't ask them if there's anything else they need help with (e.g. don't say things like "How can I assist you further?").
- Don't use lists, markdown, bullet points, or other formatting that's not typically spoken.
- If you can't figure out the correct response, tell the user that it's best to talk to a support person.
- If you cannot answer from the provided context, ask one brief, objective follow-up question or return response="conversation_handoff".
- Never say you will hand off to a human unless you return response="conversation_handoff".
- If a CONTEXT PACK is provided with preferred_name and name_confidence, only use the name when name_confidence >= 0.8.
- If there is no reliable name, ask once for the user's name and continue without using a name if they don't provide it.
- Never infer or invent preferences or identity details; use only what is explicitly in the CONTEXT PACK.
@ -196,8 +196,7 @@ class Captain::Llm::SystemPromptsService
- Provide the user with the steps required to complete the action one by one.
- Do not return list numbers in the steps, just the plain text is enough.
- Do not share anything outside of the context provided.
- Add the reasoning why you arrived at the answer
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
- Your answers must be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
#{config['instructions'] || ''}
[SDR Playbook]
@ -205,12 +204,10 @@ class Captain::Llm::SystemPromptsService
```json
{
reasoning: '',
response: '',
}
```
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
- If the answer is not provided in context sections, ask one objective question or return response="conversation_handoff".
SYSTEM_PROMPT_MESSAGE
end

View File

@ -1,4 +1,6 @@
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
LOW_CONFIDENCE_DISTANCE = 0.55
def self.name
'search_documentation'
end
@ -6,18 +8,59 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
param :query, desc: 'Search Query', required: true
def initialize(assistant, user: nil, conversation: nil)
@conversation = conversation
super(assistant, user: user)
end
def execute(query:)
Rails.logger.info { "#{self.class.name}: #{query}" }
responses = assistant.responses.approved.search(query)
return 'No FAQs found for the given query' if responses.empty?
log_results(query, responses)
return 'No FAQs found for the given query' if responses.empty? || low_confidence?(responses.first)
responses.map { |response| format_response(response) }.join
end
private
def log_results(query, responses)
request_id = extract_request_id
conversation_id = @conversation&.id
response_count = responses.length
scores = responses.map { |response| result_score_payload(response) }
top_response = responses.first
top_snippet = top_response&.answer.to_s.strip[0, 200]
low_confidence = top_response&.neighbor_distance.to_f > LOW_CONFIDENCE_DISTANCE if top_response
Rails.logger.info(
"[CAPTAIN][search_documentation] request_id=#{request_id} conversation_id=#{conversation_id} query=#{query.inspect} " \
"count=#{response_count} results=#{scores} low_confidence=#{low_confidence} top_snippet=#{top_snippet.inspect}"
)
end
def low_confidence?(response)
return false unless response&.respond_to?(:neighbor_distance)
response.neighbor_distance.to_f > LOW_CONFIDENCE_DISTANCE
end
def result_score_payload(response)
{
id: response.id,
distance: response.respond_to?(:neighbor_distance) ? response.neighbor_distance : nil
}
end
def extract_request_id
return RequestStore.store[:request_id] if defined?(RequestStore) && RequestStore.store[:request_id].present?
Thread.current[:request_id] || 'unknown'
end
def format_response(response)
formatted_response = "
Question: #{response.question}

View File

@ -0,0 +1,30 @@
# Fix: assets antigos por volume em /app/public
## Sintoma
- Build da imagem passava, mas o front em producao (arm64) nao mostrava mudancas novas.
- Dentro do container, o codigo fonte tinha os componentes novos, mas os assets servidos nao tinham as alteracoes.
## Causa
- O stack montava um volume em `/app/public`, sobrescrevendo os assets gerados na imagem.
- Resultado: mesmo com imagem nova, o container servia assets antigos do volume.
## Como confirmar
No container:
- `grep -R -n "i-lucide-brain" /app/public 2>/dev/null | head -n 5`
- Se nao retorna nada, os assets no volume estao desatualizados.
No Portainer (Inspect do container):
- Ver se existe mount com `Destination: /app/public`.
## Resolucao (Portainer)
1. Portainer -> Stacks -> `chatwoot-wuzapi` -> **Stop**.
2. Portainer -> Volumes -> remover `chatwoot-wuzapi_chatwoot_public`.
3. Portainer -> Stacks -> **Start/Deploy**.
4. Validar no front se as mudancas apareceram.
## Observacoes
- `assets:precompile` nao roda no container final (nao tem pnpm/vite). Os assets devem vir prontos da imagem.
- Se precisar manter volume, e necessario copiar os assets da imagem para o volume na hora do deploy.
## Prevencao
- Evitar montar volume em `/app/public` em producao, a menos que exista processo explicito para atualizar os assets.