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:
parent
6aa9b6ba2a
commit
a229b0a0f1
1
Gemfile
1
Gemfile
@ -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'
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
30
progresso/2026-01-05_fix_assets_antigos_volume_public.md
Normal file
30
progresso/2026-01-05_fix_assets_antigos_volume_public.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user