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'
|
gem 'pgvector'
|
||||||
# Convert Website HTML to Markdown
|
# Convert Website HTML to Markdown
|
||||||
gem 'reverse_markdown'
|
gem 'reverse_markdown'
|
||||||
|
gem 'pdf-reader'
|
||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
|
|||||||
@ -104,6 +104,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
I18n.with_locale(@assistant.account.locale) do
|
I18n.with_locale(@assistant.account.locale) do
|
||||||
create_handoff_message
|
create_handoff_message
|
||||||
@conversation.bot_handoff!
|
@conversation.bot_handoff!
|
||||||
|
apply_handoff_side_effects
|
||||||
|
log_handoff_event
|
||||||
send_out_of_office_message_if_applicable
|
send_out_of_office_message_if_applicable
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -121,6 +123,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
|
|
||||||
def create_messages
|
def create_messages
|
||||||
response_text = inject_preferred_name(@response['response'])
|
response_text = inject_preferred_name(@response['response'])
|
||||||
|
response_text = prevent_fake_handoff(response_text)
|
||||||
validate_message_content!(response_text)
|
validate_message_content!(response_text)
|
||||||
create_outgoing_message(response_text, agent_name: @response['agent_name'])
|
create_outgoing_message(response_text, agent_name: @response['agent_name'])
|
||||||
end
|
end
|
||||||
@ -155,6 +158,41 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
"#{preferred_name}, #{content}"
|
"#{preferred_name}, #{content}"
|
||||||
end
|
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)
|
def handle_error(error)
|
||||||
log_error(error)
|
log_error(error)
|
||||||
process_action('handoff')
|
process_action('handoff')
|
||||||
|
|||||||
@ -3,7 +3,13 @@ class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
|
|||||||
|
|
||||||
def perform(record, content)
|
def perform(record, content)
|
||||||
account_id = record.account_id
|
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)
|
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
|
||||||
record.update!(embedding: embedding)
|
record.update!(embedding: embedding)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[CAPTAIN][embedding] Stored embedding for record_id=#{record.id} vector_size=#{embedding&.length}"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -103,7 +103,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_tools
|
def build_tools
|
||||||
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil, conversation: @conversation)]
|
||||||
end
|
end
|
||||||
|
|
||||||
def system_message
|
def system_message
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
|
|||||||
attr_reader :document
|
attr_reader :document
|
||||||
|
|
||||||
def extract_text_from_pdf
|
def extract_text_from_pdf
|
||||||
|
ensure_pdf_reader!
|
||||||
content = ''
|
content = ''
|
||||||
document.pdf_file.open do |file|
|
document.pdf_file.open do |file|
|
||||||
reader = PDF::Reader.new(file)
|
reader = PDF::Reader.new(file)
|
||||||
@ -27,10 +28,20 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
|
|||||||
end
|
end
|
||||||
|
|
||||||
if content.present?
|
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
|
# Update content and ensure openai_file_id is nil to force standard FAQ generation
|
||||||
document.update!(content: content, openai_file_id: nil)
|
document.update!(content: content, openai_file_id: nil)
|
||||||
else
|
else
|
||||||
Rails.logger.warn "PDF extracted content is empty for document #{document.id}"
|
Rails.logger.warn "PDF extracted content is empty for document #{document.id}"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -155,10 +155,9 @@ class Captain::Llm::SystemPromptsService
|
|||||||
def assistant_response_generator(assistant_name, product_name, config = {})
|
def assistant_response_generator(assistant_name, product_name, config = {})
|
||||||
assistant_citation_guidelines = if config['feature_citation']
|
assistant_citation_guidelines = if config['feature_citation']
|
||||||
<<~CITATION_TEXT
|
<<~CITATION_TEXT
|
||||||
- Always include citations for any information provided, referencing the specific source (document only - skip if it was derived from a conversation).
|
- 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)]` (where n is the sequential number) at the end of each paragraph or sentence where external information is used.
|
- 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.
|
- 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
|
CITATION_TEXT
|
||||||
else
|
else
|
||||||
''
|
''
|
||||||
@ -182,7 +181,8 @@ class Captain::Llm::SystemPromptsService
|
|||||||
- Sometimes the user might just want to chat. Ask them relevant follow-up questions.
|
- 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 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.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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 return list numbers in the steps, just the plain text is enough.
|
||||||
- Do not share anything outside of the context provided.
|
- Do not share anything outside of the context provided.
|
||||||
- Add the reasoning why you arrived at the answer
|
- Your answers must be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
||||||
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
|
||||||
#{config['instructions'] || ''}
|
#{config['instructions'] || ''}
|
||||||
|
|
||||||
[SDR Playbook]
|
[SDR Playbook]
|
||||||
@ -205,12 +204,10 @@ class Captain::Llm::SystemPromptsService
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
reasoning: '',
|
|
||||||
response: '',
|
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
|
- If the answer is not provided in context sections, ask one objective question or return response="conversation_handoff".
|
||||||
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
|
||||||
SYSTEM_PROMPT_MESSAGE
|
SYSTEM_PROMPT_MESSAGE
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
||||||
|
LOW_CONFIDENCE_DISTANCE = 0.55
|
||||||
|
|
||||||
def self.name
|
def self.name
|
||||||
'search_documentation'
|
'search_documentation'
|
||||||
end
|
end
|
||||||
@ -6,18 +8,59 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
|||||||
|
|
||||||
param :query, desc: 'Search Query', required: true
|
param :query, desc: 'Search Query', required: true
|
||||||
|
|
||||||
|
def initialize(assistant, user: nil, conversation: nil)
|
||||||
|
@conversation = conversation
|
||||||
|
super(assistant, user: user)
|
||||||
|
end
|
||||||
|
|
||||||
def execute(query:)
|
def execute(query:)
|
||||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||||
|
|
||||||
responses = assistant.responses.approved.search(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
|
responses.map { |response| format_response(response) }.join
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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)
|
def format_response(response)
|
||||||
formatted_response = "
|
formatted_response = "
|
||||||
Question: #{response.question}
|
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