diff --git a/Gemfile b/Gemfile index 1ae6cf0..c20a4d5 100755 --- a/Gemfile +++ b/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' diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 7e6b5b1..26facfd 100755 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -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') diff --git a/enterprise/app/jobs/captain/llm/update_embedding_job.rb b/enterprise/app/jobs/captain/llm/update_embedding_job.rb index 72ab540..fdce748 100755 --- a/enterprise/app/jobs/captain/llm/update_embedding_job.rb +++ b/enterprise/app/jobs/captain/llm/update_embedding_job.rb @@ -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 diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 0462c80..d8cb341 100755 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -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 diff --git a/enterprise/app/services/captain/llm/pdf_processing_service.rb b/enterprise/app/services/captain/llm/pdf_processing_service.rb index 98df459..6aac195 100755 --- a/enterprise/app/services/captain/llm/pdf_processing_service.rb +++ b/enterprise/app/services/captain/llm/pdf_processing_service.rb @@ -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 diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index 8f45095..7d8e9c9 100755 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -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 diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb index fbc8f41..491b1ca 100755 --- a/enterprise/app/services/captain/tools/search_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -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} diff --git a/progresso/2026-01-05_fix_assets_antigos_volume_public.md b/progresso/2026-01-05_fix_assets_antigos_volume_public.md new file mode 100644 index 0000000..2e42b27 --- /dev/null +++ b/progresso/2026-01-05_fix_assets_antigos_volume_public.md @@ -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.